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
@@ -92,3 +92,4 @@ backend/cmd/static/
|
||||
backend/kubectl
|
||||
opsxsql/
|
||||
test_monitoring_es.py
|
||||
aiops-hot
|
||||
@@ -10,7 +10,7 @@
|
||||
**AIOps** 是一个企业级云原生智能运维平台,聚焦三大核心能力:
|
||||
|
||||
1. **资产管理** — 服务器 CRUD、批量导入、WebShell、云账号/域名/账单、Kubernetes 多集群。
|
||||
2. **监控告警** — Agent 采集 `/proc` 指标、告警引擎(阈值/离线检测)、告警中心、监控大屏、Prometheus/Zabbix/SNMP/站点拨测。
|
||||
2. **监控告警** — Agent 采集 `/proc` 指标、告警引擎(阈值/离线/日志告警)、告警中心、监控大屏、Prometheus/Zabbix/SNMP/站点拨测/中间件监控。
|
||||
3. **自动化运维 / AIOps** — CI/CD 流水线、定时任务/脚本库、Go-native SSH 远程执行、资源申请审批、AI 智能助手、Agent CLI(ReAct + Evolver)。
|
||||
|
||||
项目采用前后端分离架构,但通过 `go:embed` 支持将前端构建产物嵌入后端二进制,实现单文件 / 单容器部署。
|
||||
@@ -35,7 +35,30 @@
|
||||
|
||||
## 2. 技术栈与关键配置
|
||||
|
||||
### 2.1 后端
|
||||
### 2.1 关键配置文件
|
||||
|
||||
项目不存在 `pyproject.toml`、`Cargo.toml`、`pom.xml` 等配置。核心配置文件为:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `backend/go.mod` | 后端 Go 模块定义,模块名 `ezops`,Go 1.24.0 |
|
||||
| `agent/go.mod` | Agent 独立模块,模块名 `ezops/agent`,Go 1.22(与后端共用 1.24 工具链) |
|
||||
| `frontend/package.json` | 前端 npm 依赖与脚本 |
|
||||
| `frontend/vite.config.ts` | Vite 配置:端口 5173,代理 `/api`、`/ws`、`/health`、`/version`、`/agent` 到 `localhost:8080` |
|
||||
| `frontend/eslint.config.js` | ESLint flat config,禁止显式 `any` |
|
||||
| `frontend/tailwind.config.js` | CSS 变量主题体系 |
|
||||
| `config.yaml` | 本地开发默认配置(SQLite + redka,端口 8080) |
|
||||
| `docker-compose.yml` | 3 容器:MySQL 8.0 + Redis 7-alpine + AIOps(:8080) |
|
||||
| `docker-compose.k8s.yml` | 单应用容器 + 外部 DB |
|
||||
| `Dockerfile.embed` | 4-stage Embed 镜像(前端→后端→Agent→Runtime),Compose 与 K8s 共用 |
|
||||
| `.env.example` | Docker Compose 环境变量示例 |
|
||||
| `deploy/kubernetes/` | K8s 清单:`devops` 命名空间、NodePort 30080 |
|
||||
| `run.sh` | 统一入口:dev / build / docker / kubernetes / release / binary |
|
||||
| `Makefile` | 快捷命令(dev / build / docker-up / lint 等),部分委托给 `run.sh`,部分已陈旧 |
|
||||
|
||||
> 注意:项目根目录**没有**独立的 `Dockerfile`,只有 `Dockerfile.embed`。`docker-compose.yml` 直接引用 `Dockerfile.embed`。
|
||||
|
||||
### 2.2 后端
|
||||
|
||||
- **语言 / 版本**:Go 1.24+
|
||||
- **Web 框架**:Gin v1.9.1
|
||||
@@ -52,13 +75,12 @@
|
||||
|
||||
关键文件:
|
||||
|
||||
- `backend/go.mod` — Go 模块定义,模块名 `ezops`
|
||||
- `backend/main.go` — 应用入口、CLI 参数解析、配置加载、优雅关闭
|
||||
- `backend/embed.go` — `go:embed static`,SPA fallback 逻辑
|
||||
- `backend/config/config.go` — `Config` 结构体与默认配置
|
||||
- `backend/router/routes.go` — 所有路由注册(约 1018 行,核心入口)
|
||||
- `backend/router/routes.go` — 所有路由注册(实际约 1054 行,核心入口)
|
||||
|
||||
### 2.2 前端
|
||||
### 2.3 前端
|
||||
|
||||
- **框架**:React 19.2+
|
||||
- **语言**:TypeScript 5.9+
|
||||
@@ -75,34 +97,13 @@
|
||||
- **国际化**:i18next + react-i18next(中/英双语)
|
||||
- **表单校验**:Zod + React Hook Form
|
||||
|
||||
关键文件:
|
||||
|
||||
- `frontend/package.json`
|
||||
- `frontend/vite.config.ts` — 端口 5173,代理 `/api`、`/ws`、`/health`、`/version` 到 `localhost:8080`
|
||||
- `frontend/tailwind.config.js` — CSS 变量主题体系
|
||||
- `frontend/eslint.config.js` — ESLint flat config,禁止 `any`
|
||||
|
||||
### 2.3 Agent
|
||||
### 2.4 Agent
|
||||
|
||||
- **模块名**:`ezops/agent`
|
||||
- **Go 版本**:go 1.22(文件声明),与后端共用 1.24 工具链
|
||||
- **依赖**:仅 `golang.org/x/sys`
|
||||
- **架构**:`collector/`(采集器)、`task/`(远程任务)、`reporter/`(HTTP 上报)、`client/`(HTTP 客户端)、`model/`(数据结构)
|
||||
|
||||
### 2.4 部署与构建配置
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `run.sh` | 统一入口:dev / build / docker / kubernetes / release / binary |
|
||||
| `Makefile` | 快捷命令(dev / build / docker-up / lint 等),实际多委托给 `run.sh` |
|
||||
| `Dockerfile` | 3-stage 构建镜像(Frontend→Backend+Agent→Alpine Runtime),Docker Compose 使用 |
|
||||
| `Dockerfile.embed` | 4-stage Embed 镜像(前端→后端→Agent→Runtime),K8s 单容器部署使用 |
|
||||
| `docker-compose.yml` | 3 容器:MySQL 8.0 + Redis 7-alpine + AIOps(:8080) |
|
||||
| `docker-compose.k8s.yml` | 单应用容器 + 外部 DB |
|
||||
| `config.yaml` | 本地开发默认配置(SQLite + redka,端口 8080) |
|
||||
| `.env.example` | Docker Compose 环境变量示例 |
|
||||
| `deploy/kubernetes/` | K8s 清单:`devops` 命名空间、NodePort 30080 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构与模块划分
|
||||
@@ -117,11 +118,13 @@ aiops/
|
||||
├── deploy/ # 部署脚本与 K8s 清单
|
||||
├── docs/ # 中文开发文档
|
||||
├── tests/ # 集成测试脚本
|
||||
├── scripts/ # 构建辅助脚本(kubectl 安装、alpine 镜像源等)
|
||||
├── run.sh # 统一入口
|
||||
├── Dockerfile.embed # 推荐生产镜像
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.k8s.yml
|
||||
├── config.yaml
|
||||
└── README.md / CLAUDE.md
|
||||
└── README.md / CLAUDE.md / AGENTS.md
|
||||
```
|
||||
|
||||
### 3.2 后端目录职责
|
||||
@@ -129,11 +132,11 @@ aiops/
|
||||
| 目录 | 职责 |
|
||||
|------|------|
|
||||
| `backend/main.go` / `embed.go` | 应用入口 + 前端嵌入 |
|
||||
| `backend/controller/` | HTTP 处理器(约 60+ 个文件,按领域拆分) |
|
||||
| `backend/controller/` | HTTP 处理器(60+ 个文件,按领域拆分;总代码行约 27K) |
|
||||
| `backend/model/` | GORM 数据模型(约 30 个文件,86+ 张表) |
|
||||
| `backend/dto/` | 请求/响应 DTO |
|
||||
| `backend/service/` | 业务逻辑层(V2 Services 依赖注入) |
|
||||
| `backend/repository/` | 数据访问层 |
|
||||
| `backend/dto/` | 请求/响应 DTO(当前 `dto.go` 一个文件) |
|
||||
| `backend/service/` | 业务逻辑层(V2 Services,当前 `service.go`) |
|
||||
| `backend/repository/` | 数据访问层(当前 `repo.go`) |
|
||||
| `backend/router/` | 路由注册 |
|
||||
| `backend/middleware/` | JWT、Admin、PipelineAccess、CORS、日志 |
|
||||
| `backend/config/` | Viper 配置加载 |
|
||||
@@ -145,13 +148,13 @@ aiops/
|
||||
|
||||
- 用户/角色/权限:`user.go`, `user_admin.go`, `item_role.go`
|
||||
- LDAP:`ldap.go`, `ldap_users.go`, `ldap_ou.go`
|
||||
- 资产:`server.go`(V2 `server_handler.go`), `cloud_accounts.go`, `cloud_sync.go`, `cloud_domain.go`, `cloud_platform.go`, `cloud_region.go`, `billing.go`, `billing_alert.go`, `resource_model.go`
|
||||
- 资产:`server.go`, `cloud_accounts.go`, `cloud_sync.go`, `cloud_domain.go`, `cloud_platform.go`, `cloud_region.go`, `billing.go`, `billing_alert.go`, `resource_model.go`
|
||||
- K8s:`kubernetes.go`, `k8s_cluster.go`, `k8s_node.go`, `k8s_pod.go`, `k8s_workload.go`, `k8s_yaml.go`, `k8s_resource.go`, `k8s_namespace.go`, `k8s_events.go`, `k8s_compare.go`, `k8s_export.go`, `k8s_hpa_cronjob.go`, `k8s_pod_files.go`, `kubectl_shell.go`
|
||||
- 监控告警:`alert.go`, `alert_rules.go`, `alert_engine.go`, `alert_channels.go`, `alert_contacts.go`, `alert_silence.go`, `metrics.go`, `health_probe.go`, `site.go`, `snmp.go`, `custom_dashboard.go`
|
||||
- 数据源代理:`prometheus_proxy.go`, `prom_ds.go`, `zabbix_proxy.go`, `zabbix_ds.go`, `log_proxy.go`, `log_datasource.go`
|
||||
- 监控告警:`alert.go`, `alert_rules.go`, `alert_engine.go`, `alert_channels.go`, `alert_contacts.go`, `alert_silence.go`, `metrics.go`, `health_probe.go`, `site.go`, `snmp.go`, `custom_dashboard.go`, `middleware_monitor.go`
|
||||
- 数据源代理:`prometheus_proxy.go`, `prom_ds.go`, `zabbix_proxy.go`, `zabbix_ds.go`, `log_proxy.go`, `log_datasource.go`, `trace_datasource.go`
|
||||
- 日志中心:`UnifiedQueryPanel.tsx`, `EsQueryPanel.tsx`, `LokiQueryPanel.tsx`, `LogDatasourcePage.tsx`, `LogDatasourceDialog.tsx`, `TimeRangePicker.tsx`
|
||||
- 应用发布/部署:`pipeline.go`, `pipeline_executor.go`, `deploy.go`, `deploy_groups.go`, `deploy_environments.go`, `tool_config.go`
|
||||
- 自动化运维:`automation.go`, `automation_cron.go`, `automation_script.go`, `remote_exec.go`
|
||||
- 自动化运维:`automation.go`, `automation_cron.go`, `automation_script.go`, `remote_exec.go`, `ansible.go`
|
||||
- 审批:`approval.go`, `approval_process.go`, `approval_workflow.go`
|
||||
- 审计:`audit.go`, `audit_hub.go`, `terminal_session.go`, `session_command.go`, `login_log.go`
|
||||
- AI:`llm.go`, `llm_chat.go`, `llm_history.go`, `agent_cli.go`, `agent_cli_api.go`
|
||||
@@ -169,7 +172,7 @@ aiops/
|
||||
| `frontend/src/store/` | Zustand 状态管理 |
|
||||
| `frontend/src/lib/` | 工具函数 |
|
||||
| `frontend/src/types/` | TypeScript 类型 |
|
||||
| `frontend/src/i18n/` / `locales/` | 国际化(zh-CN / en-US 双语,120+ 翻译键) |
|
||||
| `frontend/src/i18n/` / `locales/` | 国际化(zh-CN / en-US 双语) |
|
||||
| `frontend/src/pages/` | 少量页面级组件 |
|
||||
| `frontend/src/assets/` | 静态资源 |
|
||||
|
||||
@@ -187,11 +190,19 @@ aiops/
|
||||
| `agent/model/` | 上报数据结构 |
|
||||
| `agent/deploy/` | `install.sh` + systemd 服务文件 |
|
||||
|
||||
### 3.5 代码规模(实测)
|
||||
|
||||
| 层级 | 文件数 | 说明 |
|
||||
|------|--------|------|
|
||||
| 前端 `frontend/src` | 约 426 个 `.ts` / `.tsx` | 含组件、API、Hooks、Store、类型等 |
|
||||
| 后端 `backend` | 约 172 个 `.go` | 含 controller、model、pkg、router 等 |
|
||||
| Agent `agent` | 21 个 `.go` | 独立模块,仅依赖 `golang.org/x/sys` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建与运行命令
|
||||
|
||||
统一入口是 `./run.sh`,优先使用它。`Makefile` 仅作为快捷别名。
|
||||
统一入口是 `./run.sh`,优先使用它。`Makefile` 仅作为快捷别名,且部分命令已落后于 `run.sh`(例如 `make dev` 调用 `./run.sh start` 实际不存在)。
|
||||
|
||||
### 4.1 本地开发
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ curl http://localhost:8080/version # 查看运行版本
|
||||
```bash
|
||||
./run.sh build agent # 交叉编译 Agent (linux-amd64 + linux-arm64)
|
||||
|
||||
./agent/aiops-agent-linux-amd64 version
|
||||
./agent/bin/aiops-agent-linux-amd64 version
|
||||
```
|
||||
|
||||
### 默认账号
|
||||
|
||||
+171
-37
@@ -54,9 +54,10 @@ type wsEvent struct {
|
||||
|
||||
// wsControl 前端→后端控制消息 // Logan.Li
|
||||
type wsControl struct {
|
||||
Type string `json:"type"` // start/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start 时任务描述
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
Type string `json:"type"` // start/continue/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start 时任务描述
|
||||
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
}
|
||||
|
||||
// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li
|
||||
@@ -73,15 +74,21 @@ func writeWSCloseError(conn *websocket.Conn, msg string) {
|
||||
|
||||
// agentConn 单个 WS 连接的运行态 // Logan.Li
|
||||
type agentConn struct {
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
server model.Server
|
||||
session *model.AgentCLISession
|
||||
cancel context.CancelFunc
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
server model.Server
|
||||
session *model.AgentCLISession
|
||||
taskCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
totalTokens int
|
||||
tokenMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li
|
||||
running bool
|
||||
runningMu sync.Mutex
|
||||
}
|
||||
|
||||
// Run WebSocket 入口:GET /ws/agent-cli?id=<serverID>&token=<jwt> // Logan.Li
|
||||
@@ -121,6 +128,7 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
ac := &agentConn{
|
||||
conn: conn, db: h.db,
|
||||
userID: middleware.GetUserID(c), server: server,
|
||||
taskCtx: context.Background(),
|
||||
confirms: make(map[string]chan bool),
|
||||
}
|
||||
// 推送主机上下文
|
||||
@@ -144,7 +152,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
|
||||
continue
|
||||
}
|
||||
ac.startTask(sshCfg, llmCfg, ctrl.Query)
|
||||
ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
|
||||
case "continue":
|
||||
if ctrl.Query == "" {
|
||||
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
|
||||
continue
|
||||
}
|
||||
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
|
||||
case "confirm":
|
||||
ac.resolveConfirm(ctrl.ConfirmID, true)
|
||||
case "deny":
|
||||
@@ -155,11 +169,28 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// startTask 启动一次任务(新会话 + 新引擎) // Logan.Li
|
||||
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConfig, query string) {
|
||||
ac.abort() // 取消上一次任务
|
||||
// parseSafetyMode 解析前端传入的安全模式 // Logan.Li
|
||||
func parseSafetyMode(mode string) safety.Mode {
|
||||
switch mode {
|
||||
case "strict":
|
||||
return safety.ModeStrict
|
||||
case "permissive":
|
||||
return safety.ModePermissive
|
||||
default:
|
||||
return safety.ModeBalanced
|
||||
}
|
||||
}
|
||||
|
||||
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
|
||||
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
|
||||
ac.abort()
|
||||
|
||||
var llmCfg model.LLMConfig
|
||||
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建 DB 会话
|
||||
title := query
|
||||
if r := []rune(title); len(r) > 60 {
|
||||
title = string(r[:60]) + "..."
|
||||
@@ -175,38 +206,96 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf
|
||||
return
|
||||
}
|
||||
ac.session = sess
|
||||
ac.convHistory = nil
|
||||
ac.setTotalTokens(0)
|
||||
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
|
||||
}
|
||||
|
||||
// 存用户消息
|
||||
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
||||
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
|
||||
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
|
||||
if ac.isRunning() {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||
return
|
||||
}
|
||||
if ac.session == nil {
|
||||
ac.write(wsEvent{Type: "error", Message: "请先开始新任务"})
|
||||
return
|
||||
}
|
||||
if ac.session.Status == "running" {
|
||||
ac.write(wsEvent{Type: "error", Message: "当前会话仍在执行中"})
|
||||
return
|
||||
}
|
||||
|
||||
var llmCfg model.LLMConfig
|
||||
if err := ac.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
|
||||
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
|
||||
return
|
||||
}
|
||||
ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true)
|
||||
}
|
||||
|
||||
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
|
||||
func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
|
||||
if ac.isRunning() {
|
||||
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
|
||||
return
|
||||
}
|
||||
|
||||
prior := ac.convHistory
|
||||
priorTokens := ac.getTotalTokens()
|
||||
roundOffset := 0
|
||||
if !isContinue {
|
||||
prior = nil
|
||||
priorTokens = 0
|
||||
} else {
|
||||
roundOffset = ac.lastRound()
|
||||
}
|
||||
|
||||
coreCfg := core.DefaultConfig()
|
||||
coreCfg.RoundOffset = roundOffset
|
||||
|
||||
// 构建引擎
|
||||
engine, err := agentcli.NewEngine(agentcli.EngineConfig{
|
||||
LLM: llm.Config{
|
||||
Protocol: resolveProtocol(llmCfg.Provider),
|
||||
Endpoint: resolveEndpoint(llmCfg.Provider, llmCfg.Endpoint),
|
||||
APIKey: llmCfg.APIKey, Model: llmCfg.Model,
|
||||
Temperature: llmCfg.Temperature, MaxTokens: llmCfg.MaxTokens,
|
||||
},
|
||||
LLM: buildLLMConfigFromModel(llmCfg),
|
||||
SSH: sshCfg,
|
||||
SafetyMode: safety.ModeBalanced,
|
||||
SafetyMode: parseSafetyMode(safetyMode),
|
||||
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
|
||||
Core: coreCfg,
|
||||
})
|
||||
if err != nil {
|
||||
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), 0, time.Duration(0))
|
||||
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
|
||||
return
|
||||
}
|
||||
engine.SetCallback(ac.onEvent)
|
||||
engine.SetConfirmFn(ac.confirmFn)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ac.taskCtx = ctx
|
||||
ac.cancel = cancel
|
||||
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s", ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
|
||||
ac.setRunning(true)
|
||||
ac.db.Model(sess).Update("status", "running")
|
||||
|
||||
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: "任务已开始"})
|
||||
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
|
||||
|
||||
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
|
||||
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
|
||||
|
||||
msg := "任务已开始"
|
||||
if isContinue {
|
||||
msg = "继续追问"
|
||||
}
|
||||
ac.write(wsEvent{Type: "session", SessionID: sess.ID, Message: msg})
|
||||
|
||||
go func() {
|
||||
defer engine.Close()
|
||||
defer func() {
|
||||
ac.setRunning(false)
|
||||
engine.Close()
|
||||
}()
|
||||
start := time.Now()
|
||||
_, err := engine.Run(ctx, query, serverInfo)
|
||||
result, err := engine.Run(ctx, query, serverInfo, prior, priorTokens)
|
||||
if result != nil && len(result.History) > 0 {
|
||||
ac.convHistory = result.History
|
||||
ac.setTotalTokens(result.TotalTokens)
|
||||
}
|
||||
dur := time.Since(start)
|
||||
if ctx.Err() != nil {
|
||||
ac.finish(sess, "aborted", "任务已中断", ac.lastRound(), dur)
|
||||
@@ -218,8 +307,37 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, llmCfg model.LLMConf
|
||||
}()
|
||||
}
|
||||
|
||||
func (ac *agentConn) isRunning() bool {
|
||||
ac.runningMu.Lock()
|
||||
defer ac.runningMu.Unlock()
|
||||
return ac.running
|
||||
}
|
||||
|
||||
func (ac *agentConn) setRunning(v bool) {
|
||||
ac.runningMu.Lock()
|
||||
ac.running = v
|
||||
ac.runningMu.Unlock()
|
||||
}
|
||||
|
||||
// setTotalTokens 更新当前任务 token 计数 // Logan.Li
|
||||
func (ac *agentConn) setTotalTokens(n int) {
|
||||
ac.tokenMu.Lock()
|
||||
ac.totalTokens = n
|
||||
ac.tokenMu.Unlock()
|
||||
}
|
||||
|
||||
func (ac *agentConn) getTotalTokens() int {
|
||||
ac.tokenMu.Lock()
|
||||
defer ac.tokenMu.Unlock()
|
||||
return ac.totalTokens
|
||||
}
|
||||
|
||||
// onEvent 引擎事件回调:推 WS + 持久化 // Logan.Li
|
||||
func (ac *agentConn) onEvent(e core.Event) {
|
||||
if e.Type == core.EventProgress && e.Tokens > 0 {
|
||||
ac.setTotalTokens(e.Tokens)
|
||||
}
|
||||
|
||||
ev := wsEvent{
|
||||
Type: string(e.Type), Round: e.Round, Thought: e.Thought,
|
||||
ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk,
|
||||
@@ -231,7 +349,7 @@ func (ac *agentConn) onEvent(e core.Event) {
|
||||
}
|
||||
ac.write(ev)
|
||||
|
||||
// 持久化(progress 太频繁不入库)
|
||||
// 持久化(progress / thought_delta 太频繁不入库) // Logan.Li
|
||||
if ac.session == nil {
|
||||
return
|
||||
}
|
||||
@@ -245,6 +363,7 @@ func (ac *agentConn) onEvent(e core.Event) {
|
||||
case core.EventToolResult:
|
||||
ac.persistMsg(sid, e.Round, "tool_result", "tool", e.Output, e.ToolName, "", "", "", e.Success, e.Duration, 0)
|
||||
case core.EventDone:
|
||||
ac.setTotalTokens(e.Tokens)
|
||||
ac.persistMsg(sid, e.Round, "done", "assistant", e.Answer, "", "", "", "", true, "", e.Tokens)
|
||||
case core.EventError:
|
||||
ac.persistMsg(sid, e.Round, "error", "assistant", e.Message, "", "", "", "", false, "", 0)
|
||||
@@ -265,6 +384,9 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris
|
||||
select {
|
||||
case approved := <-ch:
|
||||
return approved, nil
|
||||
case <-ac.taskCtx.Done():
|
||||
ac.delConfirm(confirmID)
|
||||
return false, ac.taskCtx.Err()
|
||||
case <-time.After(5 * time.Minute):
|
||||
ac.delConfirm(confirmID)
|
||||
return false, fmt.Errorf("确认超时")
|
||||
@@ -290,12 +412,21 @@ func (ac *agentConn) delConfirm(id string) {
|
||||
ac.cfMu.Unlock()
|
||||
}
|
||||
|
||||
// abort 中断当前任务 // Logan.Li
|
||||
// abort 中断当前任务并唤醒所有等待中的确认 // Logan.Li
|
||||
func (ac *agentConn) abort() {
|
||||
if ac.cancel != nil {
|
||||
ac.cancel()
|
||||
ac.cancel = nil
|
||||
}
|
||||
ac.cfMu.Lock()
|
||||
for id, ch := range ac.confirms {
|
||||
select {
|
||||
case ch <- false:
|
||||
default:
|
||||
}
|
||||
delete(ac.confirms, id)
|
||||
}
|
||||
ac.cfMu.Unlock()
|
||||
}
|
||||
|
||||
// lastRound 估算已完成轮次(从 DB 取该会话最大 round) // Logan.Li
|
||||
@@ -309,13 +440,16 @@ func (ac *agentConn) lastRound() int {
|
||||
return maxRound
|
||||
}
|
||||
|
||||
// finish 结束会话并更新 DB // Logan.Li
|
||||
// finish 结束本轮执行并更新会话 DB // Logan.Li
|
||||
func (ac *agentConn) finish(sess *model.AgentCLISession, status, errMsg string, rounds int, dur time.Duration) {
|
||||
ac.setRunning(false)
|
||||
tokens := ac.getTotalTokens()
|
||||
if status == "done" || status == "aborted" || status == "error" {
|
||||
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds})
|
||||
ac.write(wsEvent{Type: status, SessionID: sess.ID, Message: errMsg, Round: rounds, Tokens: tokens})
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status, "rounds": rounds, "duration": dur.Milliseconds(),
|
||||
"total_tokens": tokens,
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error"] = errMsg
|
||||
@@ -342,4 +476,4 @@ func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolNam
|
||||
// 持久化失败不阻断主流程
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,10 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
|
||||
// GetSession 会话详情(含完整推理消息,供回放) // Logan.Li
|
||||
// GET /api/v1/aiops/agent-cli/sessions/:id
|
||||
func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
||||
uid := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
var sess model.AgentCLISession
|
||||
if err := h.db.First(&sess, id).Error; err != nil {
|
||||
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
@@ -57,7 +58,13 @@ func (h *AgentCLIHandler) GetSession(c *gin.Context) {
|
||||
// DeleteSession 删除会话及其消息 // Logan.Li
|
||||
// DELETE /api/v1/aiops/agent-cli/sessions/:id
|
||||
func (h *AgentCLIHandler) DeleteSession(c *gin.Context) {
|
||||
uid := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
var sess model.AgentCLISession
|
||||
if err := h.db.Where("user_id = ?", uid).First(&sess, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Where("session_id = ?", id).Delete(&model.AgentCLIMessage{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -25,6 +26,56 @@ func NewAgentInstallHandler(db *gorm.DB) *AgentInstallHandler {
|
||||
return &AgentInstallHandler{db: db}
|
||||
}
|
||||
|
||||
// auditCtx 快照请求上下文(用户身份 + 来源 IP),用于在异步 goroutine 中安全记录审计 // Logan.Li
|
||||
// gin.Context 在请求返回后会被 gin 池复用,goroutine 内禁止直接访问 c,必须提前快照
|
||||
type auditCtx struct {
|
||||
userID uint
|
||||
username string
|
||||
ip string
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// newAuditCtx 从 gin.Context 提取审计所需上下文快照 // Logan.Li
|
||||
func newAuditCtx(c *gin.Context) auditCtx {
|
||||
uid, _ := c.Get("userID")
|
||||
uname, _ := c.Get("username")
|
||||
id, _ := uid.(uint)
|
||||
name, _ := uname.(string)
|
||||
return auditCtx{
|
||||
userID: id,
|
||||
username: name,
|
||||
ip: c.ClientIP(),
|
||||
userAgent: c.Request.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
// truncateAuditText 截断过长输出,避免审计 Error 字段膨胀 // Logan.Li
|
||||
func truncateAuditText(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计 // Logan.Li
|
||||
// status: 1=成功, 0=失败;errMsg 记录失败原因(SSH 错误、脚本输出等),用于追溯"安装过程遇到什么问题"
|
||||
func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, errMsg string) {
|
||||
uid := ac.userID
|
||||
h.db.Create(&model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: ac.username,
|
||||
IP: ac.ip,
|
||||
UserAgent: ac.userAgent,
|
||||
Module: "agent",
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// sshDial 复用 webshell.go 的 SSH 连接逻辑(TOFU HostKey 验证)
|
||||
func sshDial(server model.Server) (*ssh.Client, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
@@ -54,16 +105,77 @@ func sshDial(server model.Server) (*ssh.Client, error) {
|
||||
return ssh.Dial("tcp", addr, config)
|
||||
}
|
||||
|
||||
// runSSHCommand 在远程主机执行命令并返回输出
|
||||
// runSSHCommand 在远程主机执行命令并返回输出(默认 60s 超时,防止 sudo 密码等待导致挂死)// Logan.Li
|
||||
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
|
||||
return runSSHCommandWithTimeout(client, cmd, 60*time.Second)
|
||||
}
|
||||
|
||||
// runSSHCommandWithTimeout 支持自定义超时
|
||||
func runSSHCommandWithTimeout(client *ssh.Client, cmd string, timeout time.Duration) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(cmd)
|
||||
return string(output), err
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
out, err := session.CombinedOutput(cmd)
|
||||
done <- result{output: out, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Signal(ssh.SIGTERM)
|
||||
return "", fmt.Errorf("命令执行超时(%s): %s", timeout, cmd)
|
||||
case r := <-done:
|
||||
return string(r.output), r.err
|
||||
}
|
||||
}
|
||||
|
||||
// installServerURL 根据请求构造后端可访问地址,兼容 Nginx 等反向代理 // Logan.Li
|
||||
func installServerURL(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
// 优先使用 X-Forwarded-Host(Nginx 暴露给客户端的真实域名),其次用 Host // Logan.Li
|
||||
host := c.Request.Host
|
||||
if fwd := c.GetHeader("X-Forwarded-Host"); fwd != "" {
|
||||
host = fwd
|
||||
}
|
||||
// 去掉 scheme 的默认端口,避免生成 http://example.com:80 这类无效地址 // Logan.Li
|
||||
if scheme == "http" && strings.HasSuffix(host, ":80") {
|
||||
host = strings.TrimSuffix(host, ":80")
|
||||
}
|
||||
if scheme == "https" && strings.HasSuffix(host, ":443") {
|
||||
host = strings.TrimSuffix(host, ":443")
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// detectAgentVersion SSH 到目标主机读取已安装 Agent 的真实版本号 // Logan.Li
|
||||
func detectAgentVersion(client *ssh.Client) string {
|
||||
verOut, _ := runSSHCommand(client, "/usr/local/bin/aiops-agent version 2>/dev/null || /usr/local/bin/aiops-agent -v 2>/dev/null || echo ''")
|
||||
verOut = strings.TrimSpace(verOut)
|
||||
// 输出形如: AIOps Agent v2.1.0 (built ...) go=go1.24...
|
||||
if strings.HasPrefix(verOut, "AIOps Agent ") {
|
||||
fields := strings.Fields(verOut)
|
||||
if len(fields) >= 3 {
|
||||
return fields[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallAgent 远程安装 Agent 到指定服务器
|
||||
@@ -98,6 +210,11 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
// 允许安装,不强制 running 状态
|
||||
}
|
||||
|
||||
// 审计上下文 + 资源描述(同步流程,提前快照)// Logan.Li
|
||||
ac := newAuditCtx(c)
|
||||
resource := server.Hostname + " (" + server.IP + ")"
|
||||
resourceID := strconv.Itoa(int(server.ID))
|
||||
|
||||
// 如果请求中提供了凭据,使用请求凭据覆盖服务器存储的凭据进行 SSH
|
||||
effectiveServer := server
|
||||
if req.Username != "" {
|
||||
@@ -118,6 +235,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
client, err := sshDial(effectiveServer)
|
||||
if err != nil {
|
||||
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 500,
|
||||
Message: "SSH 连接失败: " + formatSSHError(err),
|
||||
@@ -153,21 +271,35 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 3. 构造安装命令(使用 Agent API 服务地址)
|
||||
// 从请求头获取后端可访问地址
|
||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||
token := c.Query("token")
|
||||
serverURL := installServerURL(c)
|
||||
token := req.Token
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
// 4. 构造安装命令:root 用户直接 bash,非 root 用 sudo -n(无密码才执行,避免挂死)// Logan.Li
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
|
||||
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
|
||||
serverURL, serverURL, token,
|
||||
serverURL, serverURL, token,
|
||||
)
|
||||
|
||||
// 4. 执行安装
|
||||
// 5. 执行安装
|
||||
installOut, err := runSSHCommand(client, installCmd)
|
||||
if err != nil {
|
||||
msg := "安装命令执行失败"
|
||||
lowerOut := strings.ToLower(installOut)
|
||||
if strings.Contains(lowerOut, "sudo: a password is required") || strings.Contains(lowerOut, "需要密码") {
|
||||
msg = "目标主机 SSH 用户没有免密 sudo 权限,请使用 root 账号或配置 passwordless sudo"
|
||||
} else if strings.Contains(lowerOut, "curl") && (strings.Contains(lowerOut, "not found") || strings.Contains(lowerOut, "未找到")) {
|
||||
msg = "目标主机未安装 curl"
|
||||
} else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") {
|
||||
msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
|
||||
}
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, msg+": "+truncateAuditText(installOut, 300))
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 500,
|
||||
Message: "安装命令执行失败(可能需要 sudo 权限或 curl 未安装)",
|
||||
Message: msg,
|
||||
Data: installOut,
|
||||
})
|
||||
return
|
||||
@@ -178,12 +310,22 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
||||
|
||||
if installed {
|
||||
// 更新服务器 Agent 状态(版本号:安装成功后由 Agent 下一次上报自动更新)
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li
|
||||
updates := map[string]interface{}{
|
||||
"agent_status": "online",
|
||||
"agent_version": "v0.1.0",
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
}
|
||||
if agentVersion := detectAgentVersion(client); agentVersion != "" {
|
||||
updates["agent_version"] = agentVersion
|
||||
}
|
||||
h.db.Model(&server).Updates(updates)
|
||||
}
|
||||
|
||||
// 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li
|
||||
if installed {
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 1, "")
|
||||
} else {
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "安装命令已执行但服务未检测到运行: "+truncateAuditText(verifyOut, 300))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
@@ -214,7 +356,8 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
|
||||
|
||||
installCmd := fmt.Sprintf(
|
||||
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
|
||||
`if [ "$(id -u)" -eq 0 ]; then curl -fsSL %s/agent/install.sh | bash -s -- --server %s --token %s; else curl -fsSL %s/agent/install.sh | sudo -n bash -s -- --server %s --token %s; fi`,
|
||||
serverURL, serverURL, token,
|
||||
serverURL, serverURL, token,
|
||||
)
|
||||
|
||||
@@ -227,11 +370,15 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
||||
|
||||
if installed {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本 // Logan.Li
|
||||
updates := map[string]interface{}{
|
||||
"agent_status": "online",
|
||||
"agent_version": "v0.1.0",
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
}
|
||||
if agentVersion := detectAgentVersion(client); agentVersion != "" {
|
||||
updates["agent_version"] = agentVersion
|
||||
}
|
||||
h.db.Model(&server).Updates(updates)
|
||||
}
|
||||
|
||||
return installed, installOut, verifyOut, nil
|
||||
@@ -239,17 +386,25 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
||||
|
||||
// BatchInstallAgent 批量安装 Agent(异步执行,立即返回)// Logan.Li
|
||||
// POST /servers/install-agent
|
||||
// 支持传入临时凭据:key 为服务器 ID,首次连接成功后自动保存到服务器记录
|
||||
func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
var req struct {
|
||||
ServerIDs []uint `json:"server_ids"`
|
||||
ServerIDs []uint `json:"server_ids"`
|
||||
Credentials map[string]struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SSHKey string `json:"ssh_key"`
|
||||
AuthType string `json:"auth_type"`
|
||||
} `json:"credentials"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.ServerIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请提供要安装的服务器 ID 列表"})
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||
serverURL := installServerURL(c)
|
||||
token := c.Query("token")
|
||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||
|
||||
// 异步执行:立即返回,后台 goroutine 逐台安装
|
||||
go func() {
|
||||
@@ -260,15 +415,39 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
log.Printf("[BatchInstall] 服务器 %d 不存在,跳过", sid)
|
||||
continue
|
||||
}
|
||||
installed, _, _, err := h.installOne(server, serverURL, token)
|
||||
|
||||
// 如果请求提供了该服务器的临时凭据,覆盖使用(不立即写入 DB,由 installOne 首次连接成功后保存) // Logan.Li
|
||||
effectiveServer := server
|
||||
if cred, ok := req.Credentials[fmt.Sprintf("%d", sid)]; ok {
|
||||
if cred.Username != "" {
|
||||
effectiveServer.Username = cred.Username
|
||||
}
|
||||
if cred.Password != "" {
|
||||
effectiveServer.Password = cred.Password
|
||||
}
|
||||
if cred.SSHKey != "" {
|
||||
effectiveServer.SSHKey = cred.SSHKey
|
||||
effectiveServer.AuthType = "key"
|
||||
}
|
||||
if cred.AuthType != "" {
|
||||
effectiveServer.AuthType = cred.AuthType
|
||||
}
|
||||
}
|
||||
|
||||
installed, _, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
if err != nil {
|
||||
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, truncateAuditText(err.Error(), 500))
|
||||
} else {
|
||||
successCount++
|
||||
if installed {
|
||||
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 1, "")
|
||||
} else {
|
||||
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, "服务未检测到运行: "+truncateAuditText(verifyOut, 300))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,17 +489,23 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ac := newAuditCtx(c) // Logan.Li
|
||||
resource := server.Hostname + " (" + server.IP + ")"
|
||||
resourceID := strconv.Itoa(int(server.ID))
|
||||
|
||||
// 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li
|
||||
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
"agent_status": "offline", "agent_version": "",
|
||||
})
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "无SSH凭据,已标记离线")
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)})
|
||||
return
|
||||
}
|
||||
@@ -331,6 +516,7 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
"agent_status": "offline", "agent_version": "",
|
||||
})
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "")
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)})
|
||||
}
|
||||
@@ -346,6 +532,8 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||
|
||||
// 异步执行:立即返回,后台逐台卸载
|
||||
go func() {
|
||||
successCount := 0
|
||||
@@ -355,21 +543,26 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
||||
log.Printf("[BatchUninstall] 服务器 %d 不存在,跳过", id)
|
||||
continue
|
||||
}
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
|
||||
log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "无SSH凭据,已标记离线")
|
||||
successCount++
|
||||
continue
|
||||
}
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
continue
|
||||
}
|
||||
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
|
||||
client.Close()
|
||||
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
|
||||
log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "")
|
||||
successCount++
|
||||
}
|
||||
log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||
@@ -393,7 +586,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := fmt.Sprintf("http://%s", c.Request.Host)
|
||||
serverURL := installServerURL(c)
|
||||
ac := newAuditCtx(c) // 提前快照上下文,goroutine 内不可再用 c // Logan.Li
|
||||
|
||||
// 异步执行:立即返回,后台逐台升级
|
||||
go func() {
|
||||
@@ -409,10 +603,14 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
|
||||
// 检测架构
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -424,7 +622,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
|
||||
// 下载新二进制 + 获取版本号 + 重启服务
|
||||
upgradeScript := fmt.Sprintf(
|
||||
`curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent output 2>/dev/null | grep 'agent.version' | awk -F'[|[|]' '{print $2}' | tr -d ' '); echo "UPGRADE_OK ver=$ver"`,
|
||||
`curl -fsSL -o /tmp/aiops-agent %s/api/agent/bin/%s && chmod +x /tmp/aiops-agent && systemctl stop aiops-agent 2>/dev/null; mv /tmp/aiops-agent /usr/local/bin/aiops-agent && systemctl start aiops-agent; ver=$(/usr/local/bin/aiops-agent version 2>/dev/null | awk '/AIOps Agent/{print $3}'); echo "UPGRADE_OK ver=$ver"`,
|
||||
serverURL, binaryFile,
|
||||
)
|
||||
|
||||
@@ -433,6 +631,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
|
||||
if err != nil || !contains(out, "UPGRADE_OK") {
|
||||
log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "升级失败: "+truncateAuditText(out, 300))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -448,6 +647,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 1, "")
|
||||
successCount++
|
||||
}
|
||||
log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||
|
||||
@@ -145,6 +145,7 @@ func (h *AuditHandler) GetAuditModules(c *gin.Context) {
|
||||
{"value": "navigation", "label": "站内导航"},
|
||||
{"value": "cloud_account", "label": "云账号管理"},
|
||||
{"value": "domain", "label": "域名管理"},
|
||||
{"value": "agent", "label": "Agent管理"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: modules}) // Logan.Li
|
||||
|
||||
@@ -73,6 +73,21 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
serverStats.AvgMemory = math.Round(avgMemory*100) / 100
|
||||
serverStats.AvgDisk = math.Round(avgDisk*100) / 100
|
||||
|
||||
// 总资源概览:按 asset_servers 的 cpu/memory/disk 字段汇总(memory 单位为 MB,需转 GB)// Logan.Li
|
||||
var totalCPU, totalMemoryMB, totalDiskGB int64
|
||||
h.db.Model(&model.Server{}).Select("COALESCE(SUM(cpu),0)").Scan(&totalCPU)
|
||||
h.db.Model(&model.Server{}).Select("COALESCE(SUM(memory),0)").Scan(&totalMemoryMB)
|
||||
h.db.Model(&model.Server{}).Select("COALESCE(SUM(disk),0)").Scan(&totalDiskGB)
|
||||
serverStats.TotalCPU = totalCPU
|
||||
serverStats.TotalMemory = totalMemoryMB / 1024 // MB -> GB
|
||||
serverStats.TotalDisk = totalDiskGB
|
||||
if serverStats.TotalMemory > 0 && serverStats.AvgMemory > 0 {
|
||||
serverStats.UsedMemory = int64(math.Round(float64(serverStats.TotalMemory) * serverStats.AvgMemory / 100))
|
||||
}
|
||||
if serverStats.TotalDisk > 0 && serverStats.AvgDisk > 0 {
|
||||
serverStats.UsedDisk = int64(math.Round(float64(serverStats.TotalDisk) * serverStats.AvgDisk / 100))
|
||||
}
|
||||
|
||||
stats.ServerStats = serverStats
|
||||
|
||||
// K8s集群统计
|
||||
|
||||
+24
-21
@@ -55,21 +55,22 @@ func (h *LLMHandler) GetLLMConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"provider": "", "model": "", "api_key": "",
|
||||
"endpoint": "", "temperature": 0.7, "max_tokens": 2000,
|
||||
"system_prompt": "", "enabled": false, "configured": false,
|
||||
"system_prompt": "", "enabled": false, "reasoning_enabled": false, "configured": false,
|
||||
}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"configured": true,
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"reasoning_enabled": cfg.ReasoningEnabled,
|
||||
"configured": true,
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -95,6 +96,7 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
|
||||
cfg.MaxTokens = req.MaxTokens
|
||||
cfg.SystemPrompt = req.SystemPrompt
|
||||
cfg.Enabled = req.Enabled
|
||||
cfg.ReasoningEnabled = req.ReasoningEnabled
|
||||
if cfg.ID == 0 {
|
||||
if err := h.db.Create(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存失败: " + err.Error()})
|
||||
@@ -109,16 +111,17 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
|
||||
// 返回脱敏后的配置
|
||||
cfg.APIKey = maskAPIKey(cfg.APIKey)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: gin.H{
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"configured": true,
|
||||
"id": cfg.ID,
|
||||
"provider": cfg.Provider,
|
||||
"model": cfg.Model,
|
||||
"api_key": cfg.APIKey,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"temperature": cfg.Temperature,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"system_prompt": cfg.SystemPrompt,
|
||||
"enabled": cfg.Enabled,
|
||||
"reasoning_enabled": cfg.ReasoningEnabled,
|
||||
"configured": true,
|
||||
}})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// buildLLMClient 从数据库配置构造 LLM 客户端(按 provider 协议自动路由 OpenAI/Anthropic) // Logan.Li
|
||||
func (h *LLMHandler) buildLLMClient(cfg model.LLMConfig) llm.Client {
|
||||
return llm.NewClient(llm.Config{
|
||||
Protocol: resolveProtocol(cfg.Provider),
|
||||
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
|
||||
APIKey: cfg.APIKey,
|
||||
Model: cfg.Model,
|
||||
Temperature: cfg.Temperature,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
})
|
||||
}
|
||||
|
||||
// toLLMMessages model.ChatMessage[] → llm.Message[](智能助手纯对话,仅填 Role/Content) // Logan.Li
|
||||
func toLLMMessages(msgs []model.ChatMessage) []llm.Message {
|
||||
out := make([]llm.Message, len(msgs))
|
||||
@@ -57,7 +45,7 @@ func (h *LLMHandler) Chat(c *gin.Context) {
|
||||
}
|
||||
messages := append([]model.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
|
||||
|
||||
resp, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: toLLMMessages(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -127,7 +115,7 @@ func (h *LLMHandler) ChatStream(c *gin.Context) {
|
||||
|
||||
// 流式补全:逐 token 回调转发 SSE;ctx 取消(客户端断开)由 llm.Client 内部终止上游读取
|
||||
var contentBuilder strings.Builder
|
||||
_, streamErr := h.buildLLMClient(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
|
||||
_, streamErr := buildLLMClientFromConfig(cfg).Stream(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: toLLMMessages(messages),
|
||||
}, func(delta string) {
|
||||
contentBuilder.WriteString(delta)
|
||||
@@ -188,7 +176,7 @@ func writeSSEError(c *gin.Context, code int, message string) {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// TestLLMConnection 测试 LLM 连接(发送一条 ping 消息) // Logan.Li
|
||||
// TestLLMConnection 测试 LLM 连接(询问模型身份并返回回答) // Logan.Li
|
||||
// POST /api/v1/aiops/llm-config/test
|
||||
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
|
||||
var cfg model.LLMConfig
|
||||
@@ -196,12 +184,22 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先保存配置"})
|
||||
return
|
||||
}
|
||||
_, err := h.buildLLMClient(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: "ping"}},
|
||||
if !cfg.Enabled {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先启用 LLM 服务"})
|
||||
return
|
||||
}
|
||||
resp, err := buildLLMClientFromConfig(cfg).Complete(c.Request.Context(), &llm.CompletionRequest{
|
||||
Messages: []llm.Message{{Role: "user", Content: "你是什么模型?请简短回答。"}},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 502, Message: "连接失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功"})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{
|
||||
"answer": resp.Message.Content,
|
||||
"model": resp.Model,
|
||||
"tokens_used": resp.Usage.TotalTokens,
|
||||
"provider": cfg.Provider,
|
||||
"reasoning_enabled": cfg.ReasoningEnabled,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// llm_client.go - LLM 客户端构造公共函数 // Logan.Li
|
||||
// Agent CLI、智能助手等模块共用,避免字段遗漏(如 ReasoningEnabled)。
|
||||
package controller
|
||||
|
||||
import (
|
||||
"ezops/model"
|
||||
"ezops/pkg/agentcli/llm"
|
||||
)
|
||||
|
||||
// buildLLMConfigFromModel 从 model.LLMConfig 构造引擎用 llm.Config // Logan.Li
|
||||
func buildLLMConfigFromModel(cfg model.LLMConfig) llm.Config {
|
||||
return llm.Config{
|
||||
Protocol: resolveProtocol(cfg.Provider),
|
||||
Endpoint: resolveEndpoint(cfg.Provider, cfg.Endpoint),
|
||||
APIKey: cfg.APIKey,
|
||||
Model: cfg.Model,
|
||||
Temperature: cfg.Temperature,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
ReasoningEnabled: cfg.ReasoningEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// buildLLMClientFromConfig 从 model.LLMConfig 构造 LLM 客户端(OpenAI/Anthropic 自动路由) // Logan.Li
|
||||
func buildLLMClientFromConfig(cfg model.LLMConfig) llm.Client {
|
||||
return llm.NewClient(buildLLMConfigFromModel(cfg))
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type LogProxyHandler struct {
|
||||
ds *LogDSHandler
|
||||
}
|
||||
|
||||
const maxLogQueryLimit = 500
|
||||
const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li
|
||||
|
||||
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
|
||||
return &LogProxyHandler{ds: ds}
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
@@ -47,6 +48,16 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
|
||||
if req.Command != "" {
|
||||
query = query.Where("command LIKE ?", "%"+req.Command+"%")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||
query = query.Where("timestamp >= ?", t)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||
query = query.Where("timestamp < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -54,17 +65,43 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var commands []model.SessionCommand
|
||||
if err := query.Order("id DESC").
|
||||
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
|
||||
var items []model.SessionCommandItem
|
||||
listQuery := h.db.Table("sys_session_commands AS sc").
|
||||
Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size").
|
||||
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id")
|
||||
if req.SessionID > 0 {
|
||||
listQuery = listQuery.Where("sc.session_id = ?", req.SessionID)
|
||||
}
|
||||
if req.Username != "" {
|
||||
listQuery = listQuery.Where("sc.username LIKE ?", "%"+req.Username+"%")
|
||||
}
|
||||
if req.ServerID > 0 {
|
||||
listQuery = listQuery.Where("sc.server_id = ?", req.ServerID)
|
||||
}
|
||||
if req.Command != "" {
|
||||
listQuery = listQuery.Where("sc.command LIKE ?", "%"+req.Command+"%")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||
listQuery = listQuery.Where("sc.timestamp >= ?", t)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||
listQuery = listQuery.Where("sc.timestamp < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
if err := listQuery.Order("sc.id DESC").
|
||||
Offset((req.Page - 1) * req.PageSize).
|
||||
Limit(req.PageSize).
|
||||
Find(&commands).Error; err != nil {
|
||||
Scan(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{
|
||||
List: commands,
|
||||
List: items,
|
||||
Total: total,
|
||||
}})
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,6 +50,26 @@ func (h *TerminalSessionHandler) ListTerminalSessions(c *gin.Context) {
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if kw := strings.TrimSpace(req.Keyword); kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
query = query.Where(
|
||||
"username LIKE ? OR server_name LIKE ? OR server_ip LIKE ? OR client_ip LIKE ?",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
|
||||
query = query.Where("start_time >= ?", t)
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
|
||||
query = query.Where("start_time < ?", t.Add(24*time.Hour))
|
||||
}
|
||||
}
|
||||
if req.HasRecord == "1" || strings.EqualFold(req.HasRecord, "true") {
|
||||
query = query.Where("record_size > 0")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -173,11 +195,17 @@ func (h *TerminalSessionHandler) KillSession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 强制终止 WebShell 连接
|
||||
// 强制终止 WebShell 连接;在线连接由 webshell finalize 落盘录制,勿抢先 UPDATE 覆盖 // Logan.Li
|
||||
killed := false
|
||||
if h.killFunc != nil {
|
||||
h.killFunc(session.ID)
|
||||
killed = h.killFunc(session.ID)
|
||||
}
|
||||
if killed {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
|
||||
return
|
||||
}
|
||||
|
||||
// 僵尸会话(连接已断开、服务重启等):无录制可落盘,仅关闭记录
|
||||
now := time.Now()
|
||||
duration := int(now.Sub(session.StartTime).Seconds())
|
||||
if err := h.db.Model(&session).Updates(map[string]interface{}{
|
||||
@@ -222,6 +250,36 @@ func (h *TerminalSessionHandler) DownloadSession(c *gin.Context) {
|
||||
c.String(http.StatusOK, session.RecordData)
|
||||
}
|
||||
|
||||
// LogReplayView 记录终端回放查看审计(二次审计留痕) // Logan.Li
|
||||
// POST /api/v1/terminal-sessions/:id/replay-view
|
||||
func (h *TerminalSessionHandler) LogReplayView(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
|
||||
return
|
||||
}
|
||||
var session model.TerminalSession
|
||||
if err := h.db.Select("id", "username", "server_name", "server_ip").First(&session, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
|
||||
return
|
||||
}
|
||||
uid := middleware.GetUserID(c)
|
||||
log := model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: middleware.GetUsername(c),
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Module: "terminal_audit",
|
||||
Action: "replay_view",
|
||||
Resource: "terminal_session",
|
||||
ResourceID: strconv.FormatUint(id, 10),
|
||||
Request: fmt.Sprintf("回放终端会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
|
||||
Status: 200,
|
||||
}
|
||||
_ = h.db.Create(&log).Error // 落库失败不阻断回放
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
|
||||
}
|
||||
|
||||
// StreamSession 终端会话实时流(WebSocket 暂未实现)
|
||||
// GET /api/v1/terminal-sessions/:id/stream
|
||||
func (h *TerminalSessionHandler) StreamSession(c *gin.Context) {
|
||||
|
||||
@@ -190,23 +190,50 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
recorder := newAsciicastRecorder()
|
||||
const maxRecordSize = 1024 * 1024
|
||||
var cmdBuf strings.Builder
|
||||
sessionStatus := "closed"
|
||||
var finalizeOnce sync.Once
|
||||
|
||||
finalizeSession := func(status string) {
|
||||
endTime := time.Now()
|
||||
duration := int(endTime.Sub(termSession.StartTime).Seconds())
|
||||
recData := recorder.String()
|
||||
recSize := recorder.Len()
|
||||
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
|
||||
// Raw SQL with error logging
|
||||
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
|
||||
status, duration, endTime, recData, recSize, termSession.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
|
||||
} else {
|
||||
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
|
||||
// 命令输出字节统计:每条命令对应「下一条命令前」的 stdout 累计量 // Logan.Li
|
||||
var cmdTrack struct {
|
||||
mu sync.Mutex
|
||||
lastCmdID uint
|
||||
stdoutBytes int
|
||||
}
|
||||
flushCmdOutput := func() {
|
||||
cmdTrack.mu.Lock()
|
||||
prevID := cmdTrack.lastCmdID
|
||||
bytes := cmdTrack.stdoutBytes
|
||||
cmdTrack.stdoutBytes = 0
|
||||
cmdTrack.mu.Unlock()
|
||||
if prevID > 0 {
|
||||
_ = h.db.Model(&model.SessionCommand{}).Where("id = ?", prevID).
|
||||
Update("output_size", bytes).Error
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeSession 会话结束落盘(sync.Once 防重复写入) // Logan.Li
|
||||
finalizeSession := func(status string) {
|
||||
sessionStatus = status
|
||||
finalizeOnce.Do(func() {
|
||||
endTime := time.Now()
|
||||
duration := int(endTime.Sub(termSession.StartTime).Seconds())
|
||||
recData := recorder.String()
|
||||
recSize := recorder.Len()
|
||||
log.Printf("[WebShell] Finalize session=%d status=%s duration=%ds rec=%d bytes", termSession.ID, status, duration, recSize)
|
||||
result := h.db.Exec("UPDATE sys_terminal_sessions SET status=?, duration=?, end_time=?, record_data=?, record_size=? WHERE id=?",
|
||||
status, duration, endTime, recData, recSize, termSession.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("[WebShell] ERROR finalize session=%d: %v", termSession.ID, result.Error)
|
||||
} else {
|
||||
log.Printf("[WebShell] Finalize OK session=%d rows_affected=%d", termSession.ID, result.RowsAffected)
|
||||
}
|
||||
})
|
||||
}
|
||||
defer func() {
|
||||
flushCmdOutput()
|
||||
finalizeSession(sessionStatus)
|
||||
}()
|
||||
|
||||
connectingMsg := "\r\n\x1b[36mConnecting to " + server.Hostname + " (" + server.IP + ":" + strconv.Itoa(server.Port) + ")...\x1b[0m\r\n"
|
||||
conn.WriteJSON(WSMessage{Status: 0, Data: connectingMsg})
|
||||
|
||||
@@ -335,6 +362,9 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
if recorder.Len() < maxRecordSize {
|
||||
recorder.WriteRow(data)
|
||||
}
|
||||
cmdTrack.mu.Lock()
|
||||
cmdTrack.stdoutBytes += n
|
||||
cmdTrack.mu.Unlock()
|
||||
if h.auditHub != nil {
|
||||
h.auditHub.Broadcast(termSession.ID, data)
|
||||
}
|
||||
@@ -366,13 +396,21 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
case 0:
|
||||
if msg.Data != "" {
|
||||
stdinPipe.Write([]byte(msg.Data))
|
||||
// 录入用户输入,回放可见键入内容 // Logan.Li
|
||||
if recorder.Len() < maxRecordSize {
|
||||
recorder.WriteRow([]byte(msg.Data))
|
||||
}
|
||||
|
||||
// Command extraction from stdin
|
||||
cmdBuf.WriteString(msg.Data)
|
||||
if strings.Contains(msg.Data, "\r") || strings.Contains(msg.Data, "\n") {
|
||||
cmd := extractCommand(cmdBuf.String())
|
||||
if cmd != "" {
|
||||
recordCommand(h.db, termSession.ID, termSession.ServerID, usernameStr, cmd)
|
||||
flushCmdOutput()
|
||||
newID := recordCommand(h.db, termSession.ID, termSession.ServerID, termSession.ServerName, usernameStr, cmd)
|
||||
cmdTrack.mu.Lock()
|
||||
cmdTrack.lastCmdID = newID
|
||||
cmdTrack.mu.Unlock()
|
||||
}
|
||||
cmdBuf.Reset()
|
||||
}
|
||||
@@ -392,10 +430,16 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||
sshClient.Close()
|
||||
<-done
|
||||
stdinPipe.Close()
|
||||
finalizeSession("closed")
|
||||
sessionStatus = "closed"
|
||||
log.Printf("[WebShell] Disconnected %s (%s)", server.Hostname, addr)
|
||||
}
|
||||
|
||||
// shortShellCmds 常见 2 字符 shell 命令白名单 // Logan.Li
|
||||
var shortShellCmds = map[string]bool{
|
||||
"ls": true, "cd": true, "ps": true, "df": true, "id": true, "wc": true,
|
||||
"mv": true, "cp": true, "vi": true, "sh": true, "ll": true, "pwd": true,
|
||||
}
|
||||
|
||||
// extractCommand extracts a clean command from raw terminal input
|
||||
func extractCommand(input string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
@@ -411,25 +455,31 @@ func extractCommand(input string) string {
|
||||
if len(cleaned) < 2 || len(cleaned) > 500 {
|
||||
return ""
|
||||
}
|
||||
// 过滤输入中途误触回车产生的 2 字符残片(如 up/uo),保留 ls/cd 等 // Logan.Li
|
||||
if len(cleaned) == 2 && !shortShellCmds[cleaned] {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(cleaned, "\x1b[") {
|
||||
return ""
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// recordCommand records a terminal command to sys_session_commands
|
||||
func recordCommand(db *gorm.DB, sessionID, serverID uint, username, command string) {
|
||||
// recordCommand 写入命令审计并返回记录 ID // Logan.Li
|
||||
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
|
||||
now := time.Now()
|
||||
cmd := model.SessionCommand{
|
||||
SessionID: sessionID,
|
||||
ServerID: serverID,
|
||||
Username: username,
|
||||
Command: command,
|
||||
Timestamp: now,
|
||||
SessionID: sessionID,
|
||||
ServerID: serverID,
|
||||
ServerName: serverName,
|
||||
Username: username,
|
||||
Command: command,
|
||||
Timestamp: now,
|
||||
}
|
||||
if err := db.Create(&cmd).Error; err != nil {
|
||||
log.Printf("[WebShell] Command record failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
|
||||
return 0
|
||||
}
|
||||
log.Printf("[WebShell] Command recorded session=%d cmd=%q", sessionID, command)
|
||||
return cmd.ID
|
||||
}
|
||||
+14
-12
@@ -12,10 +12,11 @@ type LLMConfig struct {
|
||||
Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1
|
||||
Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2
|
||||
MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token
|
||||
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
|
||||
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
|
||||
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
|
||||
ReasoningEnabled bool `gorm:"default:false" json:"reasoning_enabled"` // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 / Claude thinking 等) // Logan.Li
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 表名 // Logan.Li
|
||||
@@ -23,14 +24,15 @@ func (LLMConfig) TableName() string { return "ai_llm_configs" }
|
||||
|
||||
// SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li
|
||||
type SaveLLMConfigRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
|
||||
Endpoint string `json:"endpoint"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
|
||||
Endpoint string `json:"endpoint"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled"` // 是否启用推理/Plan 模式
|
||||
}
|
||||
|
||||
// ChatMessage 对话消息 // Logan.Li
|
||||
|
||||
@@ -216,4 +216,10 @@ type ServerStats struct {
|
||||
AvgCPU float64 `json:"avg_cpu"`
|
||||
AvgMemory float64 `json:"avg_memory"`
|
||||
AvgDisk float64 `json:"avg_disk"`
|
||||
// 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li
|
||||
TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
|
||||
TotalMemory int64 `json:"total_memory"` // 内存总量 GB
|
||||
UsedMemory int64 `json:"used_memory"` // 内存已用 GB
|
||||
TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
|
||||
UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type SessionCommand struct {
|
||||
ServerID uint `json:"server_id"`
|
||||
ServerName string `gorm:"size:128" json:"server_name"`
|
||||
Command string `gorm:"type:text" json:"command"` // 执行的原始命令
|
||||
OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
|
||||
Timestamp time.Time `json:"timestamp"` // 命令执行时间
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -19,12 +20,14 @@ func (SessionCommand) TableName() string {
|
||||
return "sys_session_commands"
|
||||
}
|
||||
|
||||
// SessionCommandQuery 会话命令查询
|
||||
// SessionCommandQuery 会话命令查询 // Logan.Li
|
||||
type SessionCommandQuery struct {
|
||||
SessionID uint `form:"session_id"`
|
||||
Username string `form:"username"`
|
||||
ServerID uint `form:"server_id"`
|
||||
Command string `form:"command"`
|
||||
StartDate string `form:"start_date"` // YYYY-MM-DD
|
||||
EndDate string `form:"end_date"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
// SessionCommandItem 命令列表项(附带关联会话录制大小) // Logan.Li
|
||||
type SessionCommandItem struct {
|
||||
SessionCommand
|
||||
SessionRecordSize int `gorm:"column:session_record_size" json:"session_record_size"` // 关联会话 record_size,0=无录制 // Logan.Li
|
||||
}
|
||||
@@ -26,11 +26,15 @@ func (TerminalSession) TableName() string {
|
||||
return "sys_terminal_sessions"
|
||||
}
|
||||
|
||||
// TerminalSessionQuery 终端会话查询
|
||||
// TerminalSessionQuery 终端会话查询 // Logan.Li
|
||||
type TerminalSessionQuery struct {
|
||||
ServerID uint `form:"server_id"`
|
||||
Username string `form:"username"`
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
ServerID uint `form:"server_id"`
|
||||
Username string `form:"username"`
|
||||
Status string `form:"status"`
|
||||
Keyword string `form:"keyword"` // 模糊匹配用户/主机名/IP/客户端IP
|
||||
StartDate string `form:"start_date"` // YYYY-MM-DD
|
||||
EndDate string `form:"end_date"` // YYYY-MM-DD
|
||||
HasRecord string `form:"has_record"` // 1/true 仅含录制
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ package agentcli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ezops/pkg/agentcli/core"
|
||||
"ezops/pkg/agentcli/evolver"
|
||||
@@ -54,9 +55,13 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
|
||||
// LLM 客户端
|
||||
client := llm.NewClient(cfg.LLM)
|
||||
|
||||
// 工具注册中心:ssh_execute(注入目标主机凭据)+ analyze_output
|
||||
// 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout))
|
||||
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
|
||||
}
|
||||
registry.Register(sshTool)
|
||||
registry.Register(tools.NewAnalyzeOutputTool())
|
||||
|
||||
// 安全控制器
|
||||
@@ -81,16 +86,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) }
|
||||
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
|
||||
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
|
||||
|
||||
// Run 执行任务(ReAct 循环),serverInfo 为目标主机摘要(注入 system prompt) // Logan.Li
|
||||
func (e *Engine) Run(ctx context.Context, query, serverInfo string) (string, error) {
|
||||
res, err := e.agent.Run(ctx, query, serverInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res != nil {
|
||||
return res.Output, nil
|
||||
}
|
||||
return "", nil
|
||||
// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
|
||||
func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
|
||||
return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
|
||||
}
|
||||
|
||||
// EvolveStats 进化统计(供审计展示) // Logan.Li
|
||||
@@ -101,8 +99,11 @@ func (e *Engine) EvolveStats() map[string]interface{} {
|
||||
return e.evolver.GetStats()
|
||||
}
|
||||
|
||||
// Close 释放资源 // Logan.Li
|
||||
// Close 释放资源(含 SSH 长连接) // Logan.Li
|
||||
func (e *Engine) Close() {
|
||||
if e.tools != nil {
|
||||
e.tools.Close()
|
||||
}
|
||||
if e.agent != nil {
|
||||
e.agent.Close()
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ import (
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventThought EventType = "thought" // AI 思考/推理文本
|
||||
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
|
||||
EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容)
|
||||
EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token
|
||||
EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险)
|
||||
EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时)
|
||||
EventProgress EventType = "progress" // 轮次进度 + token
|
||||
EventDone EventType = "done" // 任务完成(最终答案)
|
||||
@@ -50,6 +51,14 @@ type Event struct {
|
||||
// EventCallback 事件回调(controller 注入) // Logan.Li
|
||||
type EventCallback func(Event)
|
||||
|
||||
// RunResult ReAct 执行结果(含对话历史供多轮追问) // Logan.Li
|
||||
type RunResult struct {
|
||||
Success bool
|
||||
Output string
|
||||
History []llm.Message // system 之后的对话历史
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
// Config Agent 运行配置 // Logan.Li
|
||||
type Config struct {
|
||||
MaxIterations int
|
||||
@@ -60,6 +69,7 @@ type Config struct {
|
||||
OutputMaxLength int
|
||||
MaxContextTokens int
|
||||
MemoryDir string // Evolver 记忆目录
|
||||
RoundOffset int // 轮次偏移(多轮追问时接续上一轮编号) // Logan.Li
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置 // Logan.Li
|
||||
@@ -112,9 +122,9 @@ func (a *Agent) emit(e Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Run 执行单次任务(ReAct 循环 + Evolver 集成) // Logan.Li
|
||||
func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Result, error) {
|
||||
a.totalTokens = 0
|
||||
// Run 执行单次任务(ReAct 循环 + Evolver 集成);prior 为不含 system 的历史对话 // Logan.Li
|
||||
func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) {
|
||||
a.totalTokens = priorTokens
|
||||
start := time.Now()
|
||||
|
||||
// Evolver PREDICT:获取历史经验注入 system prompt
|
||||
@@ -127,10 +137,11 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
toolDescs := a.buildToolDescriptions()
|
||||
systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
|
||||
|
||||
messages := []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: query},
|
||||
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
|
||||
if len(prior) > 0 {
|
||||
messages = append(messages, prior...)
|
||||
}
|
||||
messages = append(messages, llm.Message{Role: "user", Content: query})
|
||||
llmTools := a.registry.ToLLMTools()
|
||||
|
||||
toolCallHistory := make(map[string]int) // 循环检测
|
||||
@@ -144,7 +155,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.emit(Event{Type: EventProgress, Round: iter + 1})
|
||||
a.emit(Event{Type: EventProgress, Round: a.roundNum(iter)})
|
||||
|
||||
// 自适应检查点
|
||||
if hint := a.checkAdaptiveCheckpoint(iter, toolCallHistory, consecutiveFailures, totalToolCalls); hint != "" {
|
||||
@@ -154,12 +165,17 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
// 裁剪历史
|
||||
trimmed := a.trimMessages(messages)
|
||||
|
||||
// 调用 LLM
|
||||
resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{
|
||||
// 调用 LLM(流式推送思考增量,降低长推理等待感) // Logan.Li
|
||||
round := a.roundNum(iter)
|
||||
resp, err := a.llm.Stream(ctx, &llm.CompletionRequest{
|
||||
Messages: trimmed,
|
||||
Tools: llmTools,
|
||||
Temperature: a.config.Temperature,
|
||||
MaxTokens: a.config.MaxTokens,
|
||||
}, func(delta string) {
|
||||
if strings.TrimSpace(delta) != "" {
|
||||
a.emit(Event{Type: EventThoughtDelta, Round: round, Thought: delta})
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()})
|
||||
@@ -169,9 +185,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
a.totalTokens += resp.Usage.TotalTokens
|
||||
a.emit(Event{Type: EventProgress, Tokens: a.totalTokens})
|
||||
|
||||
// 推送思考文本
|
||||
// 推送完整思考文本(供持久化与回放) // Logan.Li
|
||||
if strings.TrimSpace(resp.Message.Content) != "" {
|
||||
a.emit(Event{Type: EventThought, Round: iter + 1, Thought: resp.Message.Content})
|
||||
a.emit(Event{Type: EventThought, Round: round, Thought: resp.Message.Content})
|
||||
}
|
||||
messages = append(messages, resp.Message)
|
||||
|
||||
@@ -180,11 +196,14 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
answer := resp.Message.Content
|
||||
a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens})
|
||||
a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start)
|
||||
return &tools.Result{Success: true, Output: answer}, nil
|
||||
return &RunResult{
|
||||
Success: true, Output: answer,
|
||||
History: a.extractHistory(messages), TotalTokens: a.totalTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, iter+1)
|
||||
observations, records, failInc := a.processToolCalls(ctx, resp.Message.ToolCalls, toolCallHistory, round)
|
||||
toolRecords = append(toolRecords, records...)
|
||||
messages = append(messages, observations...)
|
||||
totalToolCalls += len(resp.Message.ToolCalls)
|
||||
@@ -195,7 +214,9 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string) (*tools.Resul
|
||||
msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations)
|
||||
a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens})
|
||||
a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start)
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
return &RunResult{
|
||||
Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens,
|
||||
}, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// processToolCalls 处理工具调用,返回 observation 消息 + 记录 // Logan.Li
|
||||
@@ -264,6 +285,13 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
|
||||
success = false
|
||||
}
|
||||
|
||||
// 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
|
||||
if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
|
||||
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
|
||||
output = analyzed
|
||||
}
|
||||
}
|
||||
|
||||
// 输出截断
|
||||
if len(output) > a.config.OutputMaxLength {
|
||||
output = output[:a.config.OutputMaxLength] +
|
||||
@@ -313,6 +341,43 @@ func (a *Agent) triggerEvolve(ctx context.Context, query, serverHost, answer str
|
||||
}()
|
||||
}
|
||||
|
||||
// roundNum 计算对外展示的 ReAct 轮次(含多轮追问偏移) // Logan.Li
|
||||
func (a *Agent) roundNum(iter int) int {
|
||||
return a.config.RoundOffset + iter + 1
|
||||
}
|
||||
|
||||
// extractHistory 提取 system 之后的对话历史(供多轮追问) // Logan.Li
|
||||
func (a *Agent) extractHistory(messages []llm.Message) []llm.Message {
|
||||
if len(messages) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make([]llm.Message, len(messages)-1)
|
||||
copy(out, messages[1:])
|
||||
return out
|
||||
}
|
||||
|
||||
// autoAnalyzeThreshold 触发自动 analyze_output 的输出长度阈值 // Logan.Li
|
||||
func (a *Agent) autoAnalyzeThreshold() int {
|
||||
t := a.config.OutputMaxLength / 2
|
||||
if t < 4000 {
|
||||
return 4000
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// autoAnalyzeOutput 对超长命令输出自动调用 analyze_output // Logan.Li
|
||||
func (a *Agent) autoAnalyzeOutput(ctx context.Context, output string) string {
|
||||
tool, err := a.registry.Get("analyze_output")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r, err := tool.Execute(ctx, map[string]interface{}{"output": output})
|
||||
if err != nil || r == nil || !r.Success || strings.TrimSpace(r.Output) == "" {
|
||||
return ""
|
||||
}
|
||||
return r.Output + "\n[系统自动分析超长输出]"
|
||||
}
|
||||
|
||||
// buildToolDescriptions 工具描述文本 // Logan.Li
|
||||
func (a *Agent) buildToolDescriptions() string {
|
||||
var sb strings.Builder
|
||||
@@ -428,9 +493,5 @@ func estimateTokens(s string) int {
|
||||
return len([]rune(s))/3 + 1
|
||||
}
|
||||
|
||||
// Close 释放资源 // Logan.Li
|
||||
func (a *Agent) Close() {
|
||||
if a.registry != nil {
|
||||
// Registry 无 Closer 实现(Web 工具无连接池),保留接口位
|
||||
}
|
||||
}
|
||||
// Close 释放资源(SSH 长连接由 Engine.Close 统一释放) // Logan.Li
|
||||
func (a *Agent) Close() {}
|
||||
@@ -42,6 +42,7 @@ type EvolveResult struct {
|
||||
LearnedHint string `json:"learned_hint"`
|
||||
ToolSequence []string `json:"tool_sequence"`
|
||||
ExperienceAdded bool `json:"experience_added"`
|
||||
SkillAdded bool `json:"skill_added"`
|
||||
Consolidated bool `json:"consolidated"`
|
||||
}
|
||||
|
||||
@@ -107,6 +108,21 @@ func (e *EvolverEngine) Evolve(ctx context.Context, exec *TaskExecution) *Evolve
|
||||
go e.memorizeFact(ctx, exec) // 异步,不阻塞进化主流程
|
||||
}
|
||||
|
||||
// Step 6: PROCEDURAL —— 成功任务沉淀程序记忆(工具命令序列) // Logan.Li
|
||||
if exec.Success {
|
||||
if steps := extractSSHSteps(exec.ToolCalls); len(steps) >= 2 {
|
||||
skill := SkillEntry{
|
||||
Name: result.TaskType + "标准流程",
|
||||
When: result.TaskType,
|
||||
Steps: steps,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
if err := e.mem.AddSkill(skill); err == nil {
|
||||
result.SkillAdded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -194,6 +210,12 @@ func (e *EvolverEngine) GetContextForPrompt(query string) string {
|
||||
if hosts := e.mem.Hosts(); len(hosts) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 【已知主机】%s\n", strings.Join(hosts, ", ")))
|
||||
}
|
||||
if skills := e.mem.FindSkills(taskType, 2); len(skills) > 0 {
|
||||
sb.WriteString(" 【推荐流程】\n")
|
||||
for _, sk := range skills {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", sk.Name, strings.Join(sk.Steps, " → ")))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -258,6 +280,20 @@ func resultToolNames(calls []ToolCallRecord) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// extractSSHSteps 从工具调用记录提取 ssh_execute 命令序列 // Logan.Li
|
||||
func extractSSHSteps(calls []ToolCallRecord) []string {
|
||||
out := make([]string, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
if c.ToolName != "ssh_execute" {
|
||||
continue
|
||||
}
|
||||
if cmd, ok := c.Args["command"].(string); ok && strings.TrimSpace(cmd) != "" {
|
||||
out = append(out, strings.TrimSpace(cmd))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
out := make([]string, 0, len(in))
|
||||
|
||||
@@ -235,6 +235,21 @@ func (m *Memories) Hosts() []string {
|
||||
return readJSON(m.envPath, KnownHosts{}).Hosts
|
||||
}
|
||||
|
||||
// FindSkills 按任务类型查找程序记忆(运维流程,最多 n 条) // Logan.Li
|
||||
func (m *Memories) FindSkills(taskType string, n int) []SkillEntry {
|
||||
all := readJSONL[SkillEntry](m.sklPath)
|
||||
var matched []SkillEntry
|
||||
for _, s := range all {
|
||||
if taskType == "" || s.When == taskType {
|
||||
matched = append(matched, s)
|
||||
}
|
||||
}
|
||||
if len(matched) > n {
|
||||
matched = matched[len(matched)-n:]
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// CountStats 统计各层记忆条目数 // Logan.Li
|
||||
func (m *Memories) CountStats() map[string]int {
|
||||
return map[string]int{
|
||||
|
||||
@@ -42,6 +42,12 @@ type anthropicMessage struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// anthropicThinking 启用 Claude thinking 模式(Claude 3.7 Sonnet 支持) // Logan.Li
|
||||
type anthropicThinking struct {
|
||||
Type string `json:"type"`
|
||||
BudgetTokens int `json:"budget_tokens"`
|
||||
}
|
||||
|
||||
// anthropicRequest Anthropic /v1/messages 请求体 // Logan.Li
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -51,6 +57,7 @@ type anthropicRequest struct {
|
||||
Tools []anthropicTool `json:"tools,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Thinking *anthropicThinking `json:"thinking,omitempty"` // Claude 3.7 Sonnet 推理模式 // Logan.Li
|
||||
}
|
||||
|
||||
// anthropicTool Anthropic 工具定义(input_schema,对应 OpenAI 的 parameters) // Logan.Li
|
||||
@@ -157,6 +164,12 @@ func buildTools(tools []Tool) []anthropicTool {
|
||||
return out
|
||||
}
|
||||
|
||||
// isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li
|
||||
func isAnthropicThinkingModel(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7")
|
||||
}
|
||||
|
||||
// buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li
|
||||
func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]byte, error) {
|
||||
system, msgs := c.buildMessages(req)
|
||||
@@ -168,6 +181,19 @@ func (c *AnthropicClient) buildRequest(req *CompletionRequest, stream bool) ([]b
|
||||
if payload.MaxTokens <= 0 {
|
||||
payload.MaxTokens = 4096
|
||||
}
|
||||
// 启用 Claude thinking 模式时,temperature 必须固定为 1,max_tokens 需大于 budget_tokens // Logan.Li
|
||||
enabled := req.ReasoningEnabled || c.cfg.ReasoningEnabled
|
||||
if enabled && isAnthropicThinkingModel(payload.Model) {
|
||||
payload.Temperature = 1
|
||||
budget := payload.MaxTokens / 2
|
||||
if budget < 1024 {
|
||||
budget = 1024
|
||||
}
|
||||
if payload.MaxTokens <= budget {
|
||||
payload.MaxTokens = budget + 1024
|
||||
}
|
||||
payload.Thinking = &anthropicThinking{Type: "enabled", BudgetTokens: budget}
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
|
||||
@@ -17,13 +17,14 @@ import (
|
||||
|
||||
// Config LLM 客户端配置(endpoint 已由上层解析为完整 base url) // Logan.Li
|
||||
type Config struct {
|
||||
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
|
||||
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
|
||||
APIKey string // Bearer token
|
||||
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
|
||||
Temperature float64 // 采样温度
|
||||
MaxTokens int // 单次最大输出 token
|
||||
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
|
||||
Protocol string // "openai" | "anthropic",由 client.go 工厂决定具体实现
|
||||
Endpoint string // 完整 base url,如 https://api.deepseek.com/v1
|
||||
APIKey string // Bearer token
|
||||
Model string // 模型名,如 deepseek-chat / gpt-4o-mini
|
||||
Temperature float64 // 采样温度
|
||||
MaxTokens int // 单次最大输出 token
|
||||
ReasoningEnabled bool // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 等) // Logan.Li
|
||||
Timeout time.Duration // HTTP 超时(0 表示使用默认 180s)
|
||||
}
|
||||
|
||||
// OpenAIClient OpenAI 兼容客户端 // Logan.Li
|
||||
@@ -48,15 +49,45 @@ func NewOpenAIClient(cfg Config) Client {
|
||||
// Name 返回客户端名称 // Logan.Li
|
||||
func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" }
|
||||
|
||||
// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li
|
||||
func isOpenAIReasoningModel(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "o1") || strings.Contains(m, "o3")
|
||||
}
|
||||
|
||||
// isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li
|
||||
func isGenericReasoningModel(model string) bool {
|
||||
m := strings.ToLower(model)
|
||||
return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner")
|
||||
}
|
||||
|
||||
// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li
|
||||
func applyReasoningParams(payload *openaiRequest, enabled bool) {
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
if isOpenAIReasoningModel(payload.Model) {
|
||||
// OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li
|
||||
payload.ReasoningEffort = "medium"
|
||||
payload.Temperature = 0
|
||||
return
|
||||
}
|
||||
if isGenericReasoningModel(payload.Model) {
|
||||
payload.Reasoning = true
|
||||
}
|
||||
}
|
||||
|
||||
// openaiRequest OpenAI chat/completions 请求体 // Logan.Li
|
||||
type openaiRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true)
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li
|
||||
Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li
|
||||
}
|
||||
|
||||
// openaiResponse OpenAI chat/completions 响应体 // Logan.Li
|
||||
@@ -89,6 +120,7 @@ func (c *OpenAIClient) Complete(ctx context.Context, req *CompletionRequest) (*C
|
||||
if payload.MaxTokens <= 0 {
|
||||
payload.MaxTokens = 4096
|
||||
}
|
||||
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
@@ -151,6 +183,7 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel
|
||||
if payload.MaxTokens <= 0 {
|
||||
payload.MaxTokens = 4096
|
||||
}
|
||||
applyReasoningParams(&payload, req.ReasoningEnabled || c.cfg.ReasoningEnabled)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
|
||||
@@ -43,10 +43,11 @@ type ToolFunction struct {
|
||||
|
||||
// CompletionRequest 补全请求参数 // Logan.Li
|
||||
type CompletionRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
ReasoningEnabled bool `json:"reasoning_enabled,omitempty"` // 是否启用推理/Plan 模式(协议相关) // Logan.Li
|
||||
}
|
||||
|
||||
// CompletionResponse 补全响应(取首个 choice) // Logan.Li
|
||||
|
||||
@@ -153,6 +153,17 @@ func StringParam(args map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Close 关闭所有实现了 Close() 的工具(如 ssh_execute 长连接) // Logan.Li
|
||||
func (r *Registry) Close() {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, t := range r.tools {
|
||||
if c, ok := t.(interface{ Close() error }); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IntParam 取整型参数(兼容 int/float64/int64) // Logan.Li
|
||||
func IntParam(args map[string]interface{}, key string) int {
|
||||
if v, ok := args[key]; ok {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ssh_execute.go - 远程命令执行工具 // Logan.Li
|
||||
// 复用 aiops 既有 pkg/remoteexec(Go 原生 SSH),凭据由 controller 从 model.Server 构建后注入。
|
||||
// 任务级复用 PersistentClient 长连接,避免每条命令重建 SSH 握手。
|
||||
// 实现 DynamicRiskTool:依据具体命令动态评估风险,决定是否触发用户确认。
|
||||
package tools
|
||||
|
||||
@@ -14,16 +15,28 @@ import (
|
||||
|
||||
// SSHExecuteTool 远程命令执行工具 // Logan.Li
|
||||
type SSHExecuteTool struct {
|
||||
cfg remoteexec.SSHConfig // 目标主机连接配置(含凭据)
|
||||
timeout time.Duration // 默认命令超时
|
||||
client *remoteexec.PersistentClient // 任务级 SSH 长连接
|
||||
timeout time.Duration // 默认命令超时
|
||||
}
|
||||
|
||||
// NewSSHExecuteTool 创建远程执行工具 // Logan.Li
|
||||
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) *SSHExecuteTool {
|
||||
// NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li
|
||||
func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &SSHExecuteTool{cfg: cfg, timeout: timeout}
|
||||
client, err := remoteexec.NewPersistentClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH 长连接建立失败: %w", err)
|
||||
}
|
||||
return &SSHExecuteTool{client: client, timeout: timeout}, nil
|
||||
}
|
||||
|
||||
// Close 释放 SSH 长连接 // Logan.Li
|
||||
func (t *SSHExecuteTool) Close() error {
|
||||
if t.client == nil {
|
||||
return nil
|
||||
}
|
||||
return t.client.Close()
|
||||
}
|
||||
|
||||
// Name 工具名 // Logan.Li
|
||||
@@ -31,14 +44,9 @@ func (t *SSHExecuteTool) Name() string { return "ssh_execute" }
|
||||
|
||||
// Description 给 LLM 的工具说明 // Logan.Li
|
||||
func (t *SSHExecuteTool) Description() string {
|
||||
host := t.cfg.Host
|
||||
if host == "" {
|
||||
host = "目标主机"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"在远程服务器 %s(用户 %s)上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。"+
|
||||
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。"+
|
||||
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。", host, t.cfg.User)
|
||||
return "在远程目标服务器上执行 shell 命令并返回合并的 stdout+stderr 输出与退出码。" +
|
||||
"用于排障(查看日志/进程/磁盘/网络)、安装与配置软件、重启服务等。" +
|
||||
"高危命令(rm -rf /、mkfs、reboot 等)需用户确认。"
|
||||
}
|
||||
|
||||
// Parameters JSON Schema 参数定义 // Logan.Li
|
||||
@@ -79,13 +87,11 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
|
||||
timeout = time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
res, err := remoteexec.Exec(t.cfg, command, timeout)
|
||||
res, err := t.client.Exec(command, timeout)
|
||||
if err != nil {
|
||||
// 连接级失败(无法 SSH)
|
||||
return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
// 附带退出码,便于 agent 判断命令是否成功(排障关键信号)
|
||||
output := res.Output
|
||||
if !strings.HasSuffix(output, "\n") && output != "" {
|
||||
output += "\n"
|
||||
@@ -93,8 +99,8 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{
|
||||
output += fmt.Sprintf("[exit_code=%d]", res.ExitCode)
|
||||
|
||||
return &Result{
|
||||
Success: res.Success, // exit_code==0
|
||||
Success: res.Success,
|
||||
Output: output,
|
||||
Error: res.Error,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.LogEntry{},
|
||||
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
||||
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||
|
||||
@@ -5,7 +5,6 @@ package remoteexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -70,10 +69,7 @@ type UploadResult struct {
|
||||
// 返回合并的 stdout+stderr 输出、退出码和错误。
|
||||
func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
|
||||
start := time.Now()
|
||||
result := &ExecResult{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
}
|
||||
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
|
||||
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
@@ -83,70 +79,7 @@ func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, er
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 创建 session
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 设置超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// 在 goroutine 中执行,支持超时
|
||||
type execOut struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan execOut, 1)
|
||||
|
||||
go func() {
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
err := session.Run(command)
|
||||
|
||||
// 合并输出
|
||||
output := stdout.Bytes()
|
||||
if stderr.Len() > 0 {
|
||||
if len(output) > 0 {
|
||||
output = append(output, '\n')
|
||||
}
|
||||
output = append(output, stderr.Bytes()...)
|
||||
}
|
||||
ch <- execOut{output: output, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
|
||||
result.ExitCode = -1
|
||||
return result, fmt.Errorf("exec timeout after %v", timeout)
|
||||
case out := <-ch:
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Output = string(out.output)
|
||||
|
||||
if out.err != nil {
|
||||
if exitErr, ok := out.err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
result.Success = false
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
result.Error = out.err.Error()
|
||||
result.Success = false
|
||||
return result, out.err
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.Success = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return execWithClient(client, cfg, command, timeout)
|
||||
}
|
||||
|
||||
// BatchExec 在多台远程主机上并发执行相同命令。
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// persistent_client.go - 任务级 SSH 长连接,多次 Exec 复用同一 client // Logan.Li
|
||||
package remoteexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// PersistentClient 可复用的 SSH 长连接(任务生命周期内持有,Close 释放) // Logan.Li
|
||||
type PersistentClient struct {
|
||||
cfg SSHConfig
|
||||
client *ssh.Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPersistentClient 建立 SSH 长连接 // Logan.Li
|
||||
func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) {
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PersistentClient{cfg: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
// Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li
|
||||
func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
result, err := p.execLocked(command, timeout)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
// 连接级失败:重连后重试一次 // Logan.Li
|
||||
if p.client != nil {
|
||||
_ = p.client.Close()
|
||||
p.client = nil
|
||||
}
|
||||
client, connErr := connect(p.cfg)
|
||||
if connErr != nil {
|
||||
return result, err
|
||||
}
|
||||
p.client = client
|
||||
return p.execLocked(command, timeout)
|
||||
}
|
||||
|
||||
// execLocked 假定已持锁,在当前 client 上执行 // Logan.Li
|
||||
func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) {
|
||||
if p.client == nil {
|
||||
client, err := connect(p.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.client = client
|
||||
}
|
||||
return execWithClient(p.client, p.cfg, command, timeout)
|
||||
}
|
||||
|
||||
// Close 关闭长连接 // Logan.Li
|
||||
func (p *PersistentClient) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.client == nil {
|
||||
return nil
|
||||
}
|
||||
err := p.client.Close()
|
||||
p.client = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li
|
||||
func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
|
||||
start := time.Now()
|
||||
result := &ExecResult{Host: cfg.Host, Port: cfg.Port}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
type execOut struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan execOut, 1)
|
||||
|
||||
go func() {
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
runErr := session.Run(command)
|
||||
|
||||
output := stdout.Bytes()
|
||||
if stderr.Len() > 0 {
|
||||
if len(output) > 0 {
|
||||
output = append(output, '\n')
|
||||
}
|
||||
output = append(output, stderr.Bytes()...)
|
||||
}
|
||||
ch <- execOut{output: output, err: runErr}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
|
||||
result.ExitCode = -1
|
||||
return result, fmt.Errorf("exec timeout after %v", timeout)
|
||||
case out := <-ch:
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Output = string(out.output)
|
||||
|
||||
if out.err != nil {
|
||||
if exitErr, ok := out.err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
result.Success = false
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
result.Error = out.err.Error()
|
||||
result.Success = false
|
||||
return result, out.err
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.Success = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -803,6 +803,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
ts.DELETE("/:id", terminalSessionHandler.DeleteTerminalSession)
|
||||
ts.POST("/:id/kill", terminalSessionHandler.KillSession)
|
||||
ts.GET("/:id/download", terminalSessionHandler.DownloadSession)
|
||||
ts.POST("/:id/replay-view", terminalSessionHandler.LogReplayView)
|
||||
ts.GET("/:id/stream", terminalSessionHandler.StreamSession)
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-25
@@ -135,7 +135,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1957,7 +1956,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
|
||||
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -4798,7 +4796,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
|
||||
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.95.2"
|
||||
},
|
||||
@@ -4988,7 +4985,6 @@
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -4998,7 +4994,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -5009,7 +5004,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -5080,7 +5074,6 @@
|
||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.52.0",
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
@@ -5385,7 +5378,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5649,7 +5641,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6132,8 +6123,7 @@
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
@@ -6228,7 +6218,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -6717,7 +6706,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
@@ -6858,7 +6846,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7313,7 +7300,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7361,7 +7347,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7568,7 +7553,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7599,7 +7583,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7612,7 +7595,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
|
||||
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -8201,7 +8183,6 @@
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -8358,7 +8339,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8583,7 +8563,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -8699,8 +8678,7 @@
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
@@ -8747,7 +8725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -205,6 +205,8 @@ function App() {
|
||||
<Route path="ai-llm-config" element={<LazyPage><LLMConfigPage /></LazyPage>} />
|
||||
|
||||
{/* Audit — 各 Tab 独立路由,支持 URL 直链 // Logan.Li */}
|
||||
{/* /audit 父路径兜底:访问或点击父级时进入终端审计,避免命中 catch-all 跳首页 // Logan.Li */}
|
||||
<Route path="audit" element={<Navigate to="/audit/terminal" replace />} />
|
||||
<Route path="audit/terminal" element={<LazyPage><TerminalAuditTab /></LazyPage>} />
|
||||
<Route path="audit/operations" element={<LazyPage><AuditOperationsTab /></LazyPage>} />
|
||||
<Route path="audit/login-logs" element={<LazyPage><LoginLogsTab /></LazyPage>} />
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LLMConfigData {
|
||||
max_tokens: number;
|
||||
system_prompt: string;
|
||||
enabled: boolean;
|
||||
reasoning_enabled?: boolean; // 是否启用推理/Plan 模式 // Logan.Li
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
@@ -23,6 +24,14 @@ export interface ChatResult {
|
||||
tokens_used: number;
|
||||
}
|
||||
|
||||
export interface LLMTestResult {
|
||||
answer: string;
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
provider: string;
|
||||
reasoning_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
@@ -38,8 +47,8 @@ export const aiApi = {
|
||||
getConfig: () => http.get<LLMConfigData>('/aiops/llm-config'),
|
||||
// 保存 LLM 配置(api_key 若为脱敏值则后端保留原值)
|
||||
saveConfig: (data: LLMConfigData) => http.put<LLMConfigData>('/aiops/llm-config', data),
|
||||
// 测试 LLM 连接(发送 ping)
|
||||
testConnection: () => http.post('/aiops/llm-config/test'),
|
||||
// 测试 LLM 连接(询问模型身份,返回模型回答) // Logan.Li
|
||||
testConnection: () => http.post<LLMTestResult>('/aiops/llm-config/test'),
|
||||
// AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话)
|
||||
chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) =>
|
||||
http.post<ChatResult>('/aiops/chat', { messages, session_id: sessionId || '' }),
|
||||
|
||||
@@ -9,6 +9,10 @@ export const terminalSessionApi = {
|
||||
server_id?: number;
|
||||
username?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
has_record?: string | boolean;
|
||||
}) => http.get<ListResponse>('/terminal-sessions', params as Record<string, string | number | boolean | undefined>),
|
||||
get: async (id: number) => http.get(`/terminal-sessions/${id}`),
|
||||
getRecord: async (id: number) => http.get<string>(`/terminal-sessions/${id}/record`),
|
||||
@@ -16,6 +20,7 @@ export const terminalSessionApi = {
|
||||
download: async (id: number) => http.download(`/terminal-sessions/${id}/download`, `session-${id}.cast`),
|
||||
delete: async (id: number) => http.delete(`/terminal-sessions/${id}`),
|
||||
batchDelete: async (ids: number[]) => http.post('/terminal-sessions/batch-delete', { ids }),
|
||||
logReplayView: async (id: number) => http.post(`/terminal-sessions/${id}/replay-view`),
|
||||
};
|
||||
|
||||
export const sessionCommandApi = {
|
||||
@@ -24,6 +29,9 @@ export const sessionCommandApi = {
|
||||
page_size?: number;
|
||||
session_id?: number;
|
||||
username?: string;
|
||||
command?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}) => http.get<ListResponse>('/session-commands', params as Record<string, string | number | boolean | undefined>),
|
||||
};
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ export const serverApi = {
|
||||
if (creds) Object.assign(body, creds);
|
||||
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body);
|
||||
},
|
||||
batchInstallAgent: async (ids: number[], token: string) => {
|
||||
return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, { server_ids: ids });
|
||||
batchInstallAgent: async (ids: number[], token: string, credentials?: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }>) => {
|
||||
const body: Record<string, unknown> = { server_ids: ids };
|
||||
if (credentials) body.credentials = credentials;
|
||||
return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, body);
|
||||
},
|
||||
listFiles: async (serverId: number, path: string) => {
|
||||
return http.get(`/servers/${serverId}/files?path=${encodeURIComponent(path)}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useUserStore } from '@/store';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,30 +20,38 @@ interface AgentWizardProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 可选:从服务器列表传入的目标服务器信息 */
|
||||
server?: { id: number; hostname: string; ip: string; has_auth?: boolean; username?: string; os?: string } | null;
|
||||
/** SSH一键安装开始回调 */
|
||||
onStart?: (serverId: number) => void;
|
||||
/** SSH一键安装成功回调 */
|
||||
onInstalled?: (serverId: number) => void;
|
||||
/** SSH一键安装失败回调 */
|
||||
onFailed?: (serverId: number, message: string) => void;
|
||||
}
|
||||
|
||||
/** 自动检测地址:origin(通过 Nginx 80 代理)用于安装脚本下载,backend 8080 用于 agent 直连 */
|
||||
/** 自动检测地址:统一使用页面 origin。
|
||||
* 生产环境 Nginx 会把 /agent 和 /api 代理到后端;本地开发通过 Vite proxy 同样走 origin。
|
||||
* 不再 hardcode :8080,避免生产环境生成无法访问的地址。// Logan.Li
|
||||
*/
|
||||
function detectServerAddress() {
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return {
|
||||
origin: window.location.origin, // http://10.10.10.75 → Nginx
|
||||
backend: `${window.location.protocol}//${window.location.hostname}:8080`, // http://10.10.10.75:8080 → 后端直达
|
||||
};
|
||||
return window.location.origin;
|
||||
}
|
||||
return { origin: 'http://127.0.0.1', backend: 'http://127.0.0.1:8080' };
|
||||
return 'http://127.0.0.1:8080';
|
||||
}
|
||||
|
||||
export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed }: AgentWizardProps) {
|
||||
export function AgentWizard({ open, onOpenChange, server, onStart, onInstalled, onFailed }: AgentWizardProps) {
|
||||
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
|
||||
const { origin, backend } = detectServerAddress();
|
||||
const origin = detectServerAddress();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
const [sshInstalling, setSshInstalling] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('auto');
|
||||
// 有目标主机时默认展示 SSH 一键安装页,无需用户再点一次 tab // Logan.Li
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab(server ? 'ssh' : 'auto');
|
||||
}
|
||||
}, [open, server]);
|
||||
// 凭据输入(当服务器无认证凭据时显示)
|
||||
const [sshUsername, setSshUsername] = useState('root');
|
||||
const [sshPassword, setSshPassword] = useState('');
|
||||
@@ -52,8 +60,8 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
|
||||
const needsCreds = server && !server.has_auth;
|
||||
|
||||
// install.sh 通过 Nginx 代理下载(origin:80 → /agent/ → backend:8080)
|
||||
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${backend} --token ${token}`;
|
||||
// install.sh 与 Agent 上报均走页面 origin(Nginx/Vite proxy 统一转发到后端)// Logan.Li
|
||||
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${origin} --token ${token}`;
|
||||
|
||||
const handleCopy = () => {
|
||||
const textArea = document.createElement('textarea');
|
||||
@@ -130,7 +138,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
``,
|
||||
`[Service]`,
|
||||
`Type=simple`,
|
||||
`ExecStart=/usr/local/bin/aiops-agent --server ${backend} --token ${token}`,
|
||||
`ExecStart=/usr/local/bin/aiops-agent --server ${origin} --token ${token}`,
|
||||
`Restart=always`,
|
||||
`RestartSec=5`,
|
||||
``,
|
||||
@@ -151,7 +159,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
];
|
||||
|
||||
// SSH 一键安装(当从服务器列表传入 server 上下文时可用)
|
||||
// 如果服务器未配置凭据,将输入框中的凭据传给后端,首次连接成功后自动保存
|
||||
// 改为调用批量异步接口,单台也走后台 goroutine 执行,避免前端长时间等待 // Logan.Li
|
||||
const handleSshInstall = async () => {
|
||||
if (!server || !token) return;
|
||||
if (needsCreds && !sshPassword && !sshKey) {
|
||||
@@ -159,24 +167,24 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
return;
|
||||
}
|
||||
setSshInstalling(true);
|
||||
onStart?.(server.id);
|
||||
try {
|
||||
const creds = needsCreds ? {
|
||||
username: sshUsername,
|
||||
password: sshPassword,
|
||||
ssh_key: sshKey,
|
||||
auth_type: authType,
|
||||
} : undefined;
|
||||
const res: Record<string, unknown> = await serverApi.installAgent(server.id, token, creds);
|
||||
const installed = res?.installed || false;
|
||||
if (installed) {
|
||||
toast.success(`${server.hostname} Agent 安装成功`);
|
||||
onInstalled?.(server.id);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.warning(`${server.hostname}: Agent 安装命令已执行,但服务未检测到运行`);
|
||||
onInstalled?.(server.id);
|
||||
onOpenChange(false);
|
||||
}
|
||||
// 组装临时凭据(如有),key 为服务器 ID,供后端批量接口覆盖使用 // Logan.Li
|
||||
const credentials = needsCreds
|
||||
? {
|
||||
[server.id]: {
|
||||
username: sshUsername,
|
||||
password: sshPassword,
|
||||
ssh_key: sshKey,
|
||||
auth_type: authType,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
// 优先尝试异步批量接口,即使单台也后台执行
|
||||
await serverApi.batchInstallAgent([server.id], token, credentials);
|
||||
toast.success(`${server.hostname}: 已提交 Agent 安装任务,后台执行中…`);
|
||||
onInstalled?.(server.id);
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
const msg = e?.message || '安装失败';
|
||||
toast.error(`${server.hostname} ${msg}`);
|
||||
@@ -261,113 +269,111 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
<div className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
服务器地址已自动检测 — 安装脚本通过 Nginx 代理 ({origin}),Agent 直连后端 ({backend})。安装成功后将自动连接并开始上报指标。
|
||||
服务器地址已自动检测 — 安装脚本与 Agent 上报均使用 {origin}(通过 Nginx/Vite proxy 转发到后端)。安装成功后将自动连接并开始上报指标。
|
||||
</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* SSH 一键安装 — 无服务器时提示先选择 */}
|
||||
{!server && (
|
||||
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||
{/* SSH 一键安装 */}
|
||||
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||
{!server ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium mb-1">未选择目标主机</p>
|
||||
<p className="text-xs">请先在主机列表中勾选一台主机,再点击「安装 Agent」打开此页面</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
{/* SSH 一键安装 — 有服务器时显示 */}
|
||||
{server && (
|
||||
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Server className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{server.hostname}</p>
|
||||
<code className="text-xs text-slate-400">{server.ip}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 未配置凭据时显示输入框 */}
|
||||
{needsCreds && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-amber-300/80">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span>该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Server className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{server.hostname}</p>
|
||||
<code className="text-xs text-slate-400">{server.ip}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">用户名</Label>
|
||||
<Input
|
||||
value={sshUsername}
|
||||
onChange={e => setSshUsername(e.target.value)}
|
||||
placeholder="root"
|
||||
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 未配置凭据时显示输入框 */}
|
||||
{needsCreds && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-amber-300/80">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span>该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-1">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-400">密码</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">用户名</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={sshPassword}
|
||||
onChange={e => { setSshPassword(e.target.value); setAuthType('password'); }}
|
||||
placeholder="******"
|
||||
value={sshUsername}
|
||||
onChange={e => setSshUsername(e.target.value)}
|
||||
placeholder="root"
|
||||
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 px-1 pb-1">或</span>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setAuthType('key'); setSshPassword(''); }}
|
||||
className={`text-xs h-7 px-2 ${authType==='key'?'text-blue-400':'text-slate-500'}`}>
|
||||
密钥
|
||||
</Button>
|
||||
<div className="flex items-end gap-1">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-400">密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={sshPassword}
|
||||
onChange={e => { setSshPassword(e.target.value); setAuthType('password'); }}
|
||||
placeholder="******"
|
||||
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 px-1 pb-1">或</span>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setAuthType('key'); setSshPassword(''); }}
|
||||
className={`text-xs h-7 px-2 ${authType==='key'?'text-blue-400':'text-slate-500'}`}>
|
||||
密钥
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{authType === 'key' && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">SSH 私钥(PEM 格式)</Label>
|
||||
<textarea
|
||||
value={sshKey}
|
||||
onChange={e => setSshKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
rows={3}
|
||||
className="w-full mt-1 bg-slate-900 border border-slate-700 text-slate-200 font-mono text-xs p-2 rounded-md resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{authType === 'key' && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">SSH 私钥(PEM 格式)</Label>
|
||||
<textarea
|
||||
value={sshKey}
|
||||
onChange={e => setSshKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
rows={3}
|
||||
className="w-full mt-1 bg-slate-900 border border-slate-700 text-slate-200 font-mono text-xs p-2 rounded-md resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSshInstall}
|
||||
disabled={sshInstalling}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{sshInstalling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
SSH 连接中,正在安装...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
{needsCreds ? '连接并安装 Agent(凭据将自动保存)' : '一键安装 Agent'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
系统将通过 SSH 连接 {server.ip},执行安装脚本。请确保服务器 SSH 凭据已正确配置(编辑服务器可修改密码/密钥)。
|
||||
</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSshInstall}
|
||||
disabled={sshInstalling}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{sshInstalling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
SSH 连接中,正在安装...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
{needsCreds ? '连接并安装 Agent(凭据将自动保存)' : '一键安装 Agent'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
系统将通过 SSH 连接 {server.ip},执行安装脚本。请确保服务器 SSH 凭据已正确配置(编辑服务器可修改密码/密钥)。
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 手动安装 */}
|
||||
<TabsContent value="manual" className="mt-4 space-y-4">
|
||||
|
||||
@@ -36,6 +36,7 @@ const defaultConfig: LLMConfigData = {
|
||||
max_tokens: 2000,
|
||||
system_prompt: '',
|
||||
enabled: false,
|
||||
reasoning_enabled: false,
|
||||
};
|
||||
|
||||
// ============ 快捷操作平台数据加载器(注入真实运维上下文) // Logan.Li ============
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -14,22 +13,29 @@ import { serverApi, buildAgentCliWsUrl } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Send, Square, Terminal, ChevronDown, ChevronRight,
|
||||
Shield, Brain, Cpu, Database, Loader2, Bot, AlertTriangle, CheckCircle2, Lightbulb, Server,
|
||||
Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server,
|
||||
} from 'lucide-react';
|
||||
import AgentCLIHistory from './AgentCLIHistory';
|
||||
import { MessageView, type ChatItem } from './AgentCLIMessages';
|
||||
import type { AgentCliSession } from '@/api/aiops/agent-cli';
|
||||
|
||||
// 对话流条目模型 // Logan.Li
|
||||
interface ChatItem {
|
||||
id: number;
|
||||
kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info';
|
||||
// WS 事件类型(后端 wsEvent 对应) // Logan.Li
|
||||
interface WsEvent {
|
||||
type: string;
|
||||
round?: number;
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
command?: string;
|
||||
session_id?: number;
|
||||
thought?: string;
|
||||
tool_name?: string;
|
||||
tool_args?: Record<string, unknown>;
|
||||
risk?: string;
|
||||
output?: string;
|
||||
success?: boolean;
|
||||
duration?: string;
|
||||
pending?: boolean;
|
||||
tokens?: number;
|
||||
answer?: string;
|
||||
message?: string;
|
||||
confirm_id?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
// 四大核心模块说明(页面 tips) // Logan.Li
|
||||
@@ -44,23 +50,18 @@ const TIPS = [
|
||||
desc: '四层记忆(经验 / 环境 / 事实 / 程序):任务完成后异步学习经验,下次同类任务自动注入历史经验,越用越聪明;学习失败优雅降级,绝不影响主流程。' },
|
||||
];
|
||||
|
||||
// riskVariant 风险等级对应 badge 颜色 // Logan.Li
|
||||
function riskClass(risk?: string): string {
|
||||
switch ((risk || '').toLowerCase()) {
|
||||
case '安全': case 'safe': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
|
||||
case '低风险': case 'low': return 'bg-sky-500/15 text-sky-400 border-sky-500/30';
|
||||
case '中风险': case 'medium': return 'bg-amber-500/15 text-amber-400 border-amber-500/30';
|
||||
case '高风险': case 'high': return 'bg-orange-500/15 text-orange-400 border-orange-500/30';
|
||||
case '危险': case 'critical': return 'bg-red-500/15 text-red-400 border-red-500/30';
|
||||
default: return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
}
|
||||
const SAFETY_MODES = [
|
||||
{ value: 'strict', label: '严格', desc: '中风险及以上需确认' },
|
||||
{ value: 'balanced', label: '均衡', desc: '高风险及以上需确认' },
|
||||
{ value: 'permissive', label: '宽松', desc: '仅危险命令拦截' },
|
||||
] as const;
|
||||
|
||||
export default function AgentCLI() {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [servers, setServers] = useState<{ id: number; hostname: string; ip: string }[]>([]);
|
||||
const [serverId, setServerId] = useState<string>('');
|
||||
const [safetyMode, setSafetyMode] = useState<string>('balanced');
|
||||
const [items, setItems] = useState<ChatItem[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
@@ -68,6 +69,9 @@ export default function AgentCLI() {
|
||||
const [tokens, setTokens] = useState(0);
|
||||
const [tipsOpen, setTipsOpen] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [replayMode, setReplayMode] = useState(false);
|
||||
const [canContinue, setCanContinue] = useState(false);
|
||||
const [historyRefresh, setHistoryRefresh] = useState(0);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const idSeq = useRef(0);
|
||||
@@ -91,12 +95,13 @@ export default function AgentCLI() {
|
||||
setItems([]);
|
||||
setRound(0); setTokens(0); setRunning(false);
|
||||
setConnected(false);
|
||||
setReplayMode(false);
|
||||
setCanContinue(false);
|
||||
const ws = new WebSocket(buildAgentCliWsUrl(serverId));
|
||||
wsRef.current = ws;
|
||||
let everConnected = false; // 是否曾握手成功:区分"预检失败被拒"与"连上后断开" // Logan.Li
|
||||
let active = true; // 本轮连接是否仍活跃:切换主机/卸载后置 false,避免脏消息 // Logan.Li
|
||||
let everConnected = false;
|
||||
let active = true;
|
||||
|
||||
// 连接超时兜底:握手迟迟不成功则判定失败,避免"正在连接主机"永久转圈 // Logan.Li
|
||||
const connectTimer = window.setTimeout(() => {
|
||||
if (active && !everConnected && ws.readyState !== WebSocket.OPEN) {
|
||||
push({ kind: 'error', content: '连接主机超时:后端长时间未响应,请检查后端服务状态、SSH 凭据与大模型配置后重试。' });
|
||||
@@ -106,22 +111,19 @@ export default function AgentCLI() {
|
||||
|
||||
ws.onopen = () => { everConnected = true; setConnected(true); };
|
||||
ws.onclose = () => {
|
||||
if (!active) return; // 切换主机触发的关闭,忽略,不写脏消息
|
||||
if (!active) return;
|
||||
window.clearTimeout(connectTimer);
|
||||
setConnected(false);
|
||||
setRunning(false);
|
||||
// 从未握上手就被对端关闭(SSH 凭据预检失败 / 大模型未启用 / 网络中断):
|
||||
// 此时后端的 error 帧可能因 TCP 提前关闭而丢失,onclose 收不到具体原因。
|
||||
// 这里兜底给出明确提示,杜绝"正在连接主机"永久转圈、用户看不到原因 // Logan.Li
|
||||
if (!everConnected) {
|
||||
push({ kind: 'error', content: '主机连接被拒绝:常见为该主机未关联可用的 SSH 凭据(密码或密钥),或大模型未启用。请到「服务器管理」确认该主机凭据、到「大模型设置」确认已启用模型后重试。' });
|
||||
} else {
|
||||
} else if (!replayMode) {
|
||||
push({ kind: 'info', content: '连接已关闭' });
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { setConnected(false); };
|
||||
ws.onmessage = (e) => {
|
||||
try { handleEvent(JSON.parse(e.data)); } catch { /* 忽略非 JSON */ }
|
||||
try { handleEvent(JSON.parse(e.data) as WsEvent); } catch { /* 忽略非 JSON */ }
|
||||
};
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -138,36 +140,56 @@ export default function AgentCLI() {
|
||||
}, [items]);
|
||||
|
||||
// 处理后端事件 // Logan.Li
|
||||
const handleEvent = (ev: Record<string, unknown>) => {
|
||||
const type = ev.type as string;
|
||||
const handleEvent = (ev: WsEvent) => {
|
||||
const type = ev.type;
|
||||
switch (type) {
|
||||
case 'session':
|
||||
push({ kind: 'info', content: ev.message as string });
|
||||
push({ kind: 'info', content: ev.message || '' });
|
||||
break;
|
||||
case 'progress':
|
||||
if (ev.round) setRound(ev.round as number);
|
||||
if (ev.tokens) setTokens(ev.tokens as number);
|
||||
if (ev.round) setRound(ev.round);
|
||||
if (ev.tokens) setTokens(ev.tokens);
|
||||
break;
|
||||
case 'thought_delta':
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
|
||||
next[next.length - 1] = { ...last, content: (last.content || '') + (ev.thought || '') };
|
||||
} else {
|
||||
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '', streaming: true });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'thought':
|
||||
push({ kind: 'thought', round: ev.round as number, content: ev.thought as string });
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
|
||||
next[next.length - 1] = { ...last, content: ev.thought || last.content, streaming: false };
|
||||
} else {
|
||||
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '' });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'tool_call': {
|
||||
const args = (ev.tool_args || {}) as Record<string, unknown>;
|
||||
const args = ev.tool_args || {};
|
||||
push({
|
||||
kind: 'tool', round: ev.round as number, toolName: ev.tool_name as string,
|
||||
command: (args.command as string) || '', risk: ev.risk as string, pending: true,
|
||||
kind: 'tool', round: ev.round, toolName: ev.tool_name,
|
||||
command: String(args.command || ''), risk: ev.risk, pending: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool_result':
|
||||
// 配对:从末尾找最近的 pending 同名工具块填充输出 // Logan.Li
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
if (next[i].kind === 'tool' && next[i].pending && next[i].toolName === ev.tool_name) {
|
||||
next[i] = {
|
||||
...next[i], output: ev.output as string, success: ev.success as boolean,
|
||||
duration: ev.duration as string, pending: false,
|
||||
...next[i], output: ev.output, success: ev.success,
|
||||
duration: ev.duration, pending: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -179,19 +201,23 @@ export default function AgentCLI() {
|
||||
handleConfirm(ev);
|
||||
break;
|
||||
case 'done':
|
||||
push({ kind: 'done', content: ev.answer as string, round: ev.round as number });
|
||||
push({ kind: 'done', content: ev.answer || '', round: ev.round });
|
||||
setRunning(false);
|
||||
setCanContinue(true);
|
||||
setHistoryRefresh((k) => k + 1);
|
||||
break;
|
||||
case 'error':
|
||||
case 'aborted':
|
||||
push({ kind: type === 'aborted' ? 'info' : 'error', content: (ev.message as string) || (type === 'aborted' ? '任务已中断' : '发生错误') });
|
||||
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') });
|
||||
setRunning(false);
|
||||
setCanContinue(type !== 'aborted');
|
||||
setHistoryRefresh((k) => k + 1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 高危命令确认(后端驱动 → 弹框 → 回传) // Logan.Li
|
||||
const handleConfirm = async (ev: Record<string, unknown>) => {
|
||||
const handleConfirm = async (ev: WsEvent) => {
|
||||
const ok = await confirm({
|
||||
title: '⚠️ 确认执行高危操作',
|
||||
description: `工具:${ev.tool_name}\n风险:${ev.risk}\n命令:${ev.command || '(无)'}`,
|
||||
@@ -213,212 +239,171 @@ export default function AgentCLI() {
|
||||
return;
|
||||
}
|
||||
if (running) return;
|
||||
setReplayMode(false);
|
||||
const msgType = canContinue ? 'continue' : 'start';
|
||||
push({ kind: 'user', content: q });
|
||||
wsRef.current.send(JSON.stringify({ type: 'start', query: q }));
|
||||
wsRef.current.send(JSON.stringify({ type: msgType, query: q, safety_mode: safetyMode }));
|
||||
setInput('');
|
||||
setRunning(true);
|
||||
if (msgType === 'start') setCanContinue(false);
|
||||
};
|
||||
|
||||
// 中断任务 // Logan.Li
|
||||
const stop = () => wsRef.current?.send(JSON.stringify({ type: 'abort' }));
|
||||
|
||||
const handleReplay = (replayItems: ChatItem[], sess: AgentCliSession) => {
|
||||
setReplayMode(true);
|
||||
setItems([
|
||||
{ id: nextId(), kind: 'info', content: `回放历史任务:${sess.title}(${sess.status})` },
|
||||
...replayItems,
|
||||
]);
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
const handleNewTask = () => {
|
||||
setReplayMode(false);
|
||||
setCanContinue(false);
|
||||
setItems([]);
|
||||
setRound(0);
|
||||
setTokens(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-3 p-4">
|
||||
{/* 顶部工具栏:主机选择 + 状态 + 大模型设置入口 */}
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardContent className="p-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-slate-200 font-medium">
|
||||
<Bot className="w-5 h-5 text-blue-400" /> Agent CLI
|
||||
</div>
|
||||
<div className="w-px h-6 bg-slate-700" />
|
||||
<Server className="w-4 h-4 text-slate-400" />
|
||||
<Select value={serverId} onValueChange={setServerId}>
|
||||
<SelectTrigger className="w-64 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择目标主机" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 max-h-72">
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.hostname}({s.ip})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{connected && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5" />已连接
|
||||
</Badge>
|
||||
)}
|
||||
{running && (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-blue-500/30">
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens} tokens
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="h-full flex gap-3 p-4">
|
||||
<AgentCLIHistory
|
||||
serverId={serverId}
|
||||
onReplay={handleReplay}
|
||||
onNewTask={handleNewTask}
|
||||
refreshKey={historyRefresh}
|
||||
/>
|
||||
|
||||
{/* tips 面板:核心功能 / 逻辑 / 设计思路 */}
|
||||
<Card className="bg-slate-900/40 border-slate-800">
|
||||
<CardHeader className="py-2 px-3 cursor-pointer select-none" onClick={() => setTipsOpen((v) => !v)}>
|
||||
<div className="flex items-center gap-2 text-slate-300 text-sm">
|
||||
{tipsOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<Lightbulb className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-medium">核心能力与设计思路</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{tipsOpen && (
|
||||
<CardContent className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-2.5 p-2.5 rounded-lg bg-slate-800/40 border border-slate-800">
|
||||
<t.icon className={`w-5 h-5 mt-0.5 shrink-0 ${t.color}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{t.title}</div>
|
||||
<div className="text-xs text-slate-400 leading-relaxed mt-0.5">{t.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 对话流 */}
|
||||
<Card className="flex-1 min-h-0 bg-slate-900/40 border-slate-800 flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
{items.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-500 gap-3">
|
||||
<Terminal className="w-10 h-10 opacity-40" />
|
||||
{!serverId ? (
|
||||
<>
|
||||
<div className="text-sm">请先选择一台目标主机</div>
|
||||
{servers.length === 0 && (
|
||||
<div className="text-xs text-slate-600">暂无主机,请先前往「资产管理」添加服务器</div>
|
||||
)}
|
||||
</>
|
||||
) : !connected ? (
|
||||
<>
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-400" />
|
||||
<div className="text-sm">正在连接主机...</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm">描述你要执行的任务,例如「检查磁盘使用率并清理大文件」</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
||||
{/* 顶部工具栏 */}
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardContent className="p-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-slate-200 font-medium">
|
||||
<Bot className="w-5 h-5 text-blue-400" /> Agent CLI
|
||||
</div>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<MessageView key={it.id} item={it} />
|
||||
))}
|
||||
</div>
|
||||
{/* 输入区 */}
|
||||
<div className="border-t border-slate-800 p-3 flex items-end gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
placeholder={serverId ? '输入任务描述,回车发送(Shift+回车换行)' : '请先选择目标主机'}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500 max-h-32"
|
||||
/>
|
||||
{running ? (
|
||||
<Button variant="destructive" onClick={stop} className="bg-red-600 hover:bg-red-700">
|
||||
<Square className="w-4 h-4 mr-1" /> 停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={send} disabled={!input.trim()} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-4 h-4 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="w-px h-6 bg-slate-700" />
|
||||
<Server className="w-4 h-4 text-slate-400" />
|
||||
<Select value={serverId} onValueChange={setServerId}>
|
||||
<SelectTrigger className="w-56 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择目标主机" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 max-h-72">
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.hostname}({s.ip})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Shield className="w-4 h-4 text-slate-400" />
|
||||
<Select value={safetyMode} onValueChange={setSafetyMode}>
|
||||
<SelectTrigger className="w-28 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{SAFETY_MODES.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{connected && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5" />已连接
|
||||
</Badge>
|
||||
)}
|
||||
{replayMode && (
|
||||
<Badge className="bg-amber-500/15 text-amber-400 border-amber-500/30">回放模式</Badge>
|
||||
)}
|
||||
{canContinue && !running && !replayMode && (
|
||||
<Badge className="bg-violet-500/15 text-violet-400 border-violet-500/30">可追问</Badge>
|
||||
)}
|
||||
{running && (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-blue-500/30">
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />第 {round} 轮 · {tokens} tokens
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
// MessageView 单条消息渲染(按 kind 分流) // Logan.Li
|
||||
function MessageView({ item }: { item: ChatItem }) {
|
||||
if (item.kind === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg bg-blue-600/90 text-white px-3 py-2 text-sm whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'info') {
|
||||
return <div className="text-center text-xs text-slate-500 py-1">{item.content}</div>;
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
return <ThoughtBlock text={item.content || ''} round={item.round} />;
|
||||
}
|
||||
if (item.kind === 'tool') {
|
||||
return <ToolBlock item={item} />;
|
||||
}
|
||||
if (item.kind === 'done') {
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 p-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-100 whitespace-pre-wrap leading-relaxed">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// error
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-100 whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ThoughtBlock AI 思考(可折叠,灰底斜体) // Logan.Li
|
||||
function ThoughtBlock({ text, round }: { text: string; round?: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/40 border border-slate-800 p-2.5">
|
||||
<button className="flex items-center gap-1.5 text-xs text-slate-400 w-full text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
<Brain className="w-3.5 h-3.5 text-violet-400" />
|
||||
{round != null && <span className="text-slate-500">第 {round} 轮思考</span>}
|
||||
<span className="ml-1 text-slate-500 truncate">{!open && preview}</span>
|
||||
</button>
|
||||
{open && <div className="mt-1.5 text-sm text-slate-300 whitespace-pre-wrap leading-relaxed">{text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolBlock 工具调用块(命令 + 风险 + 可展开输出) // Logan.Li
|
||||
function ToolBlock({ item }: { item: ChatItem }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/40 border border-slate-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800/60">
|
||||
<button className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-400" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-400" />}
|
||||
<Terminal className="w-3.5 h-3.5 text-sky-400 shrink-0" />
|
||||
<span className="text-xs text-slate-400 shrink-0">{item.toolName}</span>
|
||||
{item.round != null && <span className="text-xs text-slate-600">· R{item.round}</span>}
|
||||
</button>
|
||||
{item.risk && <Badge className={`text-[10px] border ${riskClass(item.risk)}`}>{item.risk}</Badge>}
|
||||
{item.pending && <Loader2 className="w-3.5 h-3.5 text-blue-400 animate-spin" />}
|
||||
{!item.pending && item.success != null && (
|
||||
item.success
|
||||
? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
: <AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{item.command && (
|
||||
<pre className="text-xs text-emerald-300 bg-slate-950/60 rounded px-2 py-1.5 overflow-x-auto"><code>$ {item.command}</code></pre>
|
||||
{/* tips 面板 */}
|
||||
<Card className="bg-slate-900/40 border-slate-800">
|
||||
<CardHeader className="py-2 px-3 cursor-pointer select-none" onClick={() => setTipsOpen((v) => !v)}>
|
||||
<div className="flex items-center gap-2 text-slate-300 text-sm">
|
||||
{tipsOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
<Lightbulb className="w-4 h-4 text-yellow-400" />
|
||||
<span className="font-medium">核心能力与设计思路</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{tipsOpen && (
|
||||
<CardContent className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TIPS.map((t) => (
|
||||
<div key={t.title} className="flex gap-2.5 p-2.5 rounded-lg bg-slate-800/40 border border-slate-800">
|
||||
<t.icon className={`w-5 h-5 mt-0.5 shrink-0 ${t.color}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">{t.title}</div>
|
||||
<div className="text-xs text-slate-400 leading-relaxed mt-0.5">{t.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
{!item.pending && item.output != null && (
|
||||
<>
|
||||
{item.duration && <div className="text-[10px] text-slate-600">耗时 {item.duration}</div>}
|
||||
<pre className="text-xs text-slate-300 bg-slate-950/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 对话流 */}
|
||||
<Card className="flex-1 min-h-0 bg-slate-900/40 border-slate-800 flex flex-col">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
{items.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-500 gap-3">
|
||||
<Terminal className="w-10 h-10 opacity-40" />
|
||||
{!serverId ? (
|
||||
<>
|
||||
<div className="text-sm">请先选择一台目标主机</div>
|
||||
{servers.length === 0 && (
|
||||
<div className="text-xs text-slate-600">暂无主机,请先前往「资产管理」添加服务器</div>
|
||||
)}
|
||||
</>
|
||||
) : !connected ? (
|
||||
<>
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-400" />
|
||||
<div className="text-sm">正在连接主机...</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm">描述你要执行的任务,例如「检查磁盘使用率并清理大文件」</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<MessageView key={it.id} item={it} />
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-slate-800 p-3 flex items-end gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
placeholder={
|
||||
replayMode ? '回放模式,点击「新建」开始新任务'
|
||||
: canContinue ? '继续追问,回车发送(Shift+回车换行)'
|
||||
: serverId ? '输入任务描述,回车发送(Shift+回车换行)' : '请先选择目标主机'
|
||||
}
|
||||
rows={1}
|
||||
disabled={replayMode}
|
||||
className="flex-1 resize-none bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500 max-h-32 disabled:opacity-50"
|
||||
/>
|
||||
{running ? (
|
||||
<Button variant="destructive" onClick={stop} className="bg-red-600 hover:bg-red-700">
|
||||
<Square className="w-4 h-4 mr-1" /> 停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={send} disabled={!input.trim() || replayMode} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-4 h-4 mr-1" /> 执行
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// AgentCLIHistory.tsx - Agent CLI 会话历史侧栏(列表/回放/删除) // Logan.Li
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { agentCliApi, type AgentCliSession } from '@/api/aiops/agent-cli';
|
||||
import { messagesToChatItems, type ChatItem } from './AgentCLIMessages';
|
||||
import { toast } from 'sonner';
|
||||
import { History, Trash2, Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
onReplay: (items: ChatItem[], session: AgentCliSession) => void;
|
||||
onNewTask: () => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
// statusBadge 会话状态标签样式 // Logan.Li
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'done': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
|
||||
case 'running': return 'bg-blue-500/15 text-blue-400 border-blue-500/30';
|
||||
case 'aborted': return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
|
||||
default: return 'bg-red-500/15 text-red-400 border-red-500/30';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentCLIHistory({ serverId, onReplay, onNewTask, refreshKey }: Props) {
|
||||
const confirm = useConfirm();
|
||||
const [sessions, setSessions] = useState<AgentCliSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!serverId) {
|
||||
setSessions([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await agentCliApi.getSessions({
|
||||
server_id: Number(serverId),
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
});
|
||||
setSessions(res?.list || []);
|
||||
} catch {
|
||||
toast.error('加载历史会话失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => { loadSessions(); }, [loadSessions, refreshKey]);
|
||||
|
||||
const handleReplay = async (sess: AgentCliSession) => {
|
||||
try {
|
||||
const detail = await agentCliApi.getSession(sess.id);
|
||||
const items = messagesToChatItems(detail?.messages || []);
|
||||
setActiveId(sess.id);
|
||||
onReplay(items, sess);
|
||||
} catch {
|
||||
toast.error('加载会话详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, sess: AgentCliSession) => {
|
||||
e.stopPropagation();
|
||||
const ok = await confirm({
|
||||
title: '删除会话',
|
||||
description: `确定删除「${sess.title}」?此操作不可恢复。`,
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await agentCliApi.deleteSession(sess.id);
|
||||
if (activeId === sess.id) {
|
||||
setActiveId(null);
|
||||
onNewTask();
|
||||
}
|
||||
loadSessions();
|
||||
toast.success('已删除');
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 shrink-0 flex flex-col bg-slate-900/60 border border-slate-800 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-slate-800">
|
||||
<History className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm font-medium text-slate-200 flex-1">历史任务</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => { setActiveId(null); onNewTask(); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />新建
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{!serverId ? (
|
||||
<div className="p-4 text-xs text-slate-500 text-center">选择主机后查看历史</div>
|
||||
) : loading ? (
|
||||
<div className="p-4 flex justify-center"><Loader2 className="w-5 h-5 animate-spin text-slate-500" /></div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="p-4 text-xs text-slate-500 text-center">暂无历史任务</div>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-1">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => handleReplay(s)}
|
||||
className={`w-full text-left rounded-md px-2.5 py-2 transition-colors group ${
|
||||
activeId === s.id ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-slate-800/60 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-slate-200 truncate">{s.title}</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">
|
||||
{s.rounds} 轮 · {s.total_tokens || 0} tokens
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`text-[10px] border shrink-0 ${statusBadge(s.status)}`}>
|
||||
{s.status === 'done' ? '完成' : s.status === 'running' ? '进行' : s.status === 'aborted' ? '中断' : '失败'}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-500 hover:text-red-400 shrink-0"
|
||||
onClick={(e) => handleDelete(e, s)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// AgentCLIMessages.tsx - Agent CLI 对话流消息渲染子组件 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Terminal, ChevronDown, ChevronRight, Brain,
|
||||
AlertTriangle, CheckCircle2, Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { AgentCliMessage } from '@/api/aiops/agent-cli';
|
||||
|
||||
// 对话流条目模型 // Logan.Li
|
||||
export interface ChatItem {
|
||||
id: number;
|
||||
kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info';
|
||||
round?: number;
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
command?: string;
|
||||
risk?: string;
|
||||
output?: string;
|
||||
success?: boolean;
|
||||
duration?: string;
|
||||
pending?: boolean;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
// riskClass 风险等级对应 badge 颜色 // Logan.Li
|
||||
export function riskClass(risk?: string): string {
|
||||
switch ((risk || '').toLowerCase()) {
|
||||
case '安全': case 'safe': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
|
||||
case '低风险': case 'low': return 'bg-sky-500/15 text-sky-400 border-sky-500/30';
|
||||
case '中风险': case 'medium': return 'bg-amber-500/15 text-amber-400 border-amber-500/30';
|
||||
case '高风险': case 'high': return 'bg-orange-500/15 text-orange-400 border-orange-500/30';
|
||||
case '危险': case 'critical': return 'bg-red-500/15 text-red-400 border-red-500/30';
|
||||
default: return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
}
|
||||
|
||||
// messagesToChatItems 将 DB 消息转为对话流(供历史回放) // Logan.Li
|
||||
export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] {
|
||||
const items: ChatItem[] = [];
|
||||
let seq = 0;
|
||||
const nextId = () => ++seq;
|
||||
|
||||
for (const m of msgs) {
|
||||
switch (m.type) {
|
||||
case 'user':
|
||||
items.push({ id: nextId(), kind: 'user', content: m.content });
|
||||
break;
|
||||
case 'thought':
|
||||
items.push({ id: nextId(), kind: 'thought', round: m.round, content: m.content });
|
||||
break;
|
||||
case 'tool_call': {
|
||||
let command = '';
|
||||
try {
|
||||
const args = JSON.parse(m.tool_args || '{}') as Record<string, unknown>;
|
||||
command = String(args.command || '');
|
||||
} catch { /* 忽略解析失败 */ }
|
||||
items.push({
|
||||
id: nextId(), kind: 'tool', round: m.round,
|
||||
toolName: m.tool_name, command, risk: m.risk, pending: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool_result': {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
if (items[i].kind === 'tool' && items[i].pending && items[i].toolName === m.tool_name) {
|
||||
items[i] = {
|
||||
...items[i],
|
||||
output: m.content || m.tool_output,
|
||||
success: m.success,
|
||||
duration: m.duration,
|
||||
pending: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'done':
|
||||
items.push({ id: nextId(), kind: 'done', round: m.round, content: m.content });
|
||||
break;
|
||||
case 'error':
|
||||
items.push({ id: nextId(), kind: 'error', content: m.content });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// MessageView 单条消息渲染(按 kind 分流) // Logan.Li
|
||||
export function MessageView({ item }: { item: ChatItem }) {
|
||||
if (item.kind === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg bg-blue-600/90 text-white px-3 py-2 text-sm whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'info') {
|
||||
return <div className="text-center text-xs text-slate-500 py-1">{item.content}</div>;
|
||||
}
|
||||
if (item.kind === 'thought') {
|
||||
return <ThoughtBlock text={item.content || ''} round={item.round} streaming={item.streaming} />;
|
||||
}
|
||||
if (item.kind === 'tool') {
|
||||
return <ToolBlock item={item} />;
|
||||
}
|
||||
if (item.kind === 'done') {
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 p-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-100 whitespace-pre-wrap leading-relaxed">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-100 whitespace-pre-wrap">{item.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ThoughtBlock AI 思考(可折叠,灰底斜体,支持流式) // Logan.Li
|
||||
function ThoughtBlock({ text, round, streaming }: { text: string; round?: number; streaming?: boolean }) {
|
||||
const [open, setOpen] = useState(streaming || false);
|
||||
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/40 border border-slate-800 p-2.5">
|
||||
<button className="flex items-center gap-1.5 text-xs text-slate-400 w-full text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
<Brain className="w-3.5 h-3.5 text-violet-400" />
|
||||
{round != null && <span className="text-slate-500">第 {round} 轮思考</span>}
|
||||
{streaming && <Loader2 className="w-3 h-3 text-violet-400 animate-spin" />}
|
||||
<span className="ml-1 text-slate-500 truncate">{!open && preview}</span>
|
||||
</button>
|
||||
{open && <div className="mt-1.5 text-sm text-slate-300 whitespace-pre-wrap leading-relaxed">{text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolBlock 工具调用块(命令 + 风险 + 可展开输出) // Logan.Li
|
||||
function ToolBlock({ item }: { item: ChatItem }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/40 border border-slate-800 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-slate-800/60">
|
||||
<button className="flex items-center gap-1.5 flex-1 min-w-0 text-left" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-400" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-400" />}
|
||||
<Terminal className="w-3.5 h-3.5 text-sky-400 shrink-0" />
|
||||
<span className="text-xs text-slate-400 shrink-0">{item.toolName}</span>
|
||||
{item.round != null && <span className="text-xs text-slate-600">· R{item.round}</span>}
|
||||
</button>
|
||||
{item.risk && <Badge className={`text-[10px] border ${riskClass(item.risk)}`}>{item.risk}</Badge>}
|
||||
{item.pending && <Loader2 className="w-3.5 h-3.5 text-blue-400 animate-spin" />}
|
||||
{!item.pending && item.success != null && (
|
||||
item.success
|
||||
? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
: <AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{item.command && (
|
||||
<pre className="text-xs text-emerald-300 bg-slate-950/60 rounded px-2 py-1.5 overflow-x-auto"><code>$ {item.command}</code></pre>
|
||||
)}
|
||||
{!item.pending && item.output != null && (
|
||||
<>
|
||||
{item.duration && <div className="text-[10px] text-slate-600">耗时 {item.duration}</div>}
|
||||
<pre className="text-xs text-slate-300 bg-slate-950/40 rounded px-2 py-1.5 overflow-x-auto max-h-80 whitespace-pre-wrap">{item.output || '(无输出)'}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,31 +10,72 @@ import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Save, Wifi, KeyRound, Brain } from 'lucide-react';
|
||||
import { aiApi, type LLMConfigData } from '@/api';
|
||||
import {
|
||||
Loader2, Save, Wifi, KeyRound, Brain, Sparkles, MessageSquare, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { aiApi, type LLMConfigData, type LLMTestResult } from '@/api';
|
||||
|
||||
// 单个提供商配置:默认 endpoint 与候选模型列表(第一个为推荐/最新模型) // Logan.Li
|
||||
interface ProviderMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
// 提供商列表(后端按 provider 自动路由协议) // Logan.Li
|
||||
// Anthropic 协议(glm-coding / anthropic):端点不含 /v1,后端拼 /v1/messages。
|
||||
// OpenAI 协议(其余):端点含 /v1,后端拼 /chat/completions。
|
||||
const PROVIDERS = [
|
||||
{ id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1', models: 'gpt-4o-mini, gpt-4o, gpt-4.1' },
|
||||
{ id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', models: 'deepseek-v4-flash, deepseek-v4-pro' },
|
||||
{ id: 'glm', name: '智谱 GLM (通用)', endpoint: 'https://open.bigmodel.cn/api/paas/v4', models: 'glm-4.6, glm-4.5, glm-4-flash' },
|
||||
{ id: 'glm-coding', name: '智谱 GLM (编码套餐)', endpoint: 'https://open.bigmodel.cn/api/anthropic', models: 'GLM-5.2' },
|
||||
{ id: 'qwen', name: '通义千问 (DashScope)', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: 'qwen-plus, qwen-turbo, qwen-max' },
|
||||
{ id: 'moonshot', name: 'Moonshot (Kimi)', endpoint: 'https://api.moonshot.cn/v1', models: 'kimi-latest, moonshot-v1-8k, moonshot-v1-32k' },
|
||||
{ id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1', models: 'kimi-for-coding' },
|
||||
{ id: 'mimo', name: '小米 MiMo', endpoint: 'https://api.mimo.mi.com/v1', models: 'mimo-v2-flash, mimo-v2' },
|
||||
{ id: 'anthropic', name: 'Anthropic Claude', endpoint: 'https://api.anthropic.com', models: 'claude-sonnet-4-6, claude-opus-4-8, claude-haiku-4-5' },
|
||||
{ id: 'ollama', name: 'Ollama 本地', endpoint: 'http://localhost:11434/v1', models: 'qwen2.5:7b, llama3.2' },
|
||||
{ id: 'custom', name: '自定义 (OpenAI 兼容)', endpoint: '', models: '' },
|
||||
const PROVIDERS: ProviderMeta[] = [
|
||||
{
|
||||
id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'o3-mini', 'o1-mini'],
|
||||
},
|
||||
{
|
||||
id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-reasoner', 'deepseek-chat'],
|
||||
},
|
||||
{
|
||||
id: 'glm', name: '智谱 GLM (通用)', endpoint: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
models: ['glm-4.5', 'glm-4.6', 'glm-4-flash', 'glm-4-air', 'glm-4-plus'],
|
||||
},
|
||||
{
|
||||
id: 'glm-coding', name: '智谱 GLM (编码套餐)', endpoint: 'https://open.bigmodel.cn/api/anthropic',
|
||||
models: ['GLM-5.2', 'GLM-5.1'],
|
||||
},
|
||||
{
|
||||
id: 'qwen', name: '通义千问 (DashScope)', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-coder-plus'],
|
||||
},
|
||||
{
|
||||
id: 'moonshot', name: 'Moonshot (Kimi)', endpoint: 'https://api.moonshot.cn/v1',
|
||||
models: ['kimi-latest', 'moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
|
||||
},
|
||||
{
|
||||
id: 'kimi', name: 'Kimi 编码套餐', endpoint: 'https://api.kimi.com/coding/v1',
|
||||
models: ['kimi-for-coding', 'kimi-coding-latest'],
|
||||
},
|
||||
{
|
||||
id: 'mimo', name: '小米 MiMo', endpoint: 'https://api.mimo.mi.com/v1',
|
||||
models: ['mimo-v2', 'mimo-v2-flash'],
|
||||
},
|
||||
{
|
||||
id: 'anthropic', name: 'Anthropic Claude', endpoint: 'https://api.anthropic.com',
|
||||
models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5'],
|
||||
},
|
||||
{
|
||||
id: 'ollama', name: 'Ollama 本地', endpoint: 'http://localhost:11434/v1',
|
||||
models: ['qwen2.5:7b', 'llama3.2', 'deepseek-r1:7b'],
|
||||
},
|
||||
{
|
||||
id: 'custom', name: '自定义 (OpenAI 兼容)', endpoint: '', models: [] },
|
||||
];
|
||||
|
||||
// 生成参数(温度/max_tokens/系统提示词)已按 Claude Code 模式精简:UI 不再暴露,走内置默认。 // Logan.Li
|
||||
const defaultCfg: LLMConfigData = {
|
||||
provider: 'deepseek', model: 'deepseek-v4-flash', api_key: '',
|
||||
provider: 'deepseek', model: 'deepseek-v4-pro', api_key: '',
|
||||
endpoint: 'https://api.deepseek.com/v1', temperature: 0.7, max_tokens: 8192,
|
||||
system_prompt: '', enabled: false,
|
||||
system_prompt: '', enabled: false, reasoning_enabled: false,
|
||||
};
|
||||
|
||||
export default function LLMConfigPage() {
|
||||
@@ -42,11 +83,20 @@ export default function LLMConfigPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<LLMTestResult | null>(null);
|
||||
const [modelMode, setModelMode] = useState<'select' | 'input'>('select');
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setCfg({ ...defaultCfg, ...(await aiApi.getConfig()) }); }
|
||||
try {
|
||||
const saved = await aiApi.getConfig();
|
||||
const merged = { ...defaultCfg, ...saved };
|
||||
setCfg(merged);
|
||||
const prov = PROVIDERS.find((x) => x.id === merged.provider);
|
||||
const inList = prov ? prov.models.includes(merged.model) : false;
|
||||
setModelMode(inList || merged.provider === 'custom' ? 'select' : 'input');
|
||||
}
|
||||
catch { /* 首次无配置,使用默认 */ }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
@@ -58,13 +108,28 @@ export default function LLMConfigPage() {
|
||||
};
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try { await aiApi.testConnection(); toast.success('连接成功'); }
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await aiApi.testConnection();
|
||||
setTestResult(res);
|
||||
toast.success('连接成功');
|
||||
}
|
||||
catch { /* 拦截器已提示 */ }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
const onProvider = (p: string) => {
|
||||
const prov = PROVIDERS.find((x) => x.id === p);
|
||||
setCfg({ ...cfg, provider: p, endpoint: prov?.endpoint || cfg.endpoint });
|
||||
if (!prov) return;
|
||||
const model = prov.models[0] || cfg.model;
|
||||
setModelMode('select');
|
||||
setCfg({ ...cfg, provider: p, endpoint: prov.endpoint || cfg.endpoint, model });
|
||||
};
|
||||
const onModel = (m: string) => {
|
||||
if (m === '__manual') {
|
||||
setModelMode('input');
|
||||
return;
|
||||
}
|
||||
setCfg({ ...cfg, model: m });
|
||||
};
|
||||
const set = <K extends keyof LLMConfigData>(k: K, v: LLMConfigData[K]) => setCfg({ ...cfg, [k]: v });
|
||||
|
||||
@@ -72,6 +137,8 @@ export default function LLMConfigPage() {
|
||||
return <div className="flex items-center justify-center h-full text-slate-400"><Loader2 className="w-5 h-5 animate-spin mr-2" />加载配置...</div>;
|
||||
}
|
||||
const prov = PROVIDERS.find((x) => x.id === cfg.provider);
|
||||
const models = prov?.models ?? [];
|
||||
const isCustom = cfg.provider === 'custom';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
||||
@@ -96,8 +163,20 @@ export default function LLMConfigPage() {
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">模型</Label>
|
||||
<Input value={cfg.model} onChange={(e) => set('model', e.target.value)}
|
||||
placeholder={prov?.models || '模型名'} className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
{isCustom || modelMode === 'input' ? (
|
||||
<Input value={cfg.model} onChange={(e) => set('model', e.target.value)}
|
||||
placeholder={isCustom ? '自定义模型名' : '手动输入模型名'} className="bg-slate-800 border-slate-700 text-slate-200" />
|
||||
) : (
|
||||
<Select value={cfg.model} onValueChange={onModel}>
|
||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||||
<SelectItem value="__manual">手动输入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -113,7 +192,22 @@ export default function LLMConfigPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardHeader><CardTitle className="text-slate-200 text-base flex items-center gap-2"><Sparkles className="w-4 h-4 text-amber-400" />推理 / Plan 模式</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm text-slate-300">启用推理模式</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
适用于 OpenAI o-series / DeepSeek R1 / Claude 3.7 Sonnet 等推理模型;普通模型开启后可能报错。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={cfg.reasoning_enabled ?? false} onCheckedChange={(v) => set('reasoning_enabled', v)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
|
||||
<span>启用 LLM 服务</span>
|
||||
@@ -127,6 +221,31 @@ export default function LLMConfigPage() {
|
||||
</Button>
|
||||
{!cfg.enabled && <span className="text-xs text-slate-500">请先保存并启用后再测试</span>}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<Card className="bg-slate-900/60 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-200 text-base flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-green-400" />
|
||||
测试结果
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-sm text-slate-300 whitespace-pre-wrap leading-relaxed">{testResult.answer || '(模型未返回内容)'}</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<span className="bg-slate-800 px-2 py-1 rounded">模型: {testResult.model || '未知'}</span>
|
||||
<span className="bg-slate-800 px-2 py-1 rounded">提供商: {testResult.provider}</span>
|
||||
<span className="bg-slate-800 px-2 py-1 rounded">消耗 Token: {testResult.tokens_used ?? '-'}</span>
|
||||
{testResult.reasoning_enabled && <span className="bg-amber-900/40 text-amber-400 px-2 py-1 rounded">推理模式已开启</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>测试时会向当前模型发送「你是什么模型?请简短回答。」,并在页面上方展示模型返回内容,用于确认接入的是目标模型。</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,11 @@ export function Dashboard() {
|
||||
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
|
||||
avgMemory={Number(serverStats?.avg_memory ?? 0)}
|
||||
avgDisk={Number(serverStats?.avg_disk ?? 0)}
|
||||
totalCpu={Number(serverStats?.total_cpu ?? 0)}
|
||||
totalMemory={Number(serverStats?.total_memory ?? 0)}
|
||||
usedMemory={Number(serverStats?.used_memory ?? 0)}
|
||||
totalDisk={Number(serverStats?.total_disk ?? 0)}
|
||||
usedDisk={Number(serverStats?.used_disk ?? 0)}
|
||||
metrics={metrics}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -280,11 +280,21 @@ export function ResourceUsageCard({
|
||||
avgCpu,
|
||||
avgMemory,
|
||||
avgDisk,
|
||||
totalCpu,
|
||||
totalMemory,
|
||||
usedMemory,
|
||||
totalDisk,
|
||||
usedDisk,
|
||||
metrics,
|
||||
}: {
|
||||
avgCpu: number;
|
||||
avgMemory: number;
|
||||
avgDisk: number;
|
||||
totalCpu: number;
|
||||
totalMemory: number;
|
||||
usedMemory: number;
|
||||
totalDisk: number;
|
||||
usedDisk: number;
|
||||
metrics?: JsonData[];
|
||||
}) {
|
||||
const realData = buildMetricsFromReal(metrics ?? [], avgDisk);
|
||||
@@ -314,6 +324,24 @@ export function ResourceUsageCard({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{/* 总资源概览:在百分比趋势旁补充具体容量,避免“只看到百分比” // Logan.Li */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<Cpu className="h-3.5 w-3.5 text-[var(--dg-blue)]" />
|
||||
<span className="text-[var(--dg-text-secondary)]">CPU</span>
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{totalCpu} 核</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<MemoryStick className="h-3.5 w-3.5 text-[var(--dg-green)]" />
|
||||
<span className="text-[var(--dg-text-secondary)]">内存</span>
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{usedMemory} / {totalMemory} GB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<HardDrive className="h-3.5 w-3.5 text-[var(--dg-purple)]" />
|
||||
<span className="text-[var(--dg-text-secondary)]">磁盘</span>
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{usedDisk} / {totalDisk} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[280px]">
|
||||
|
||||
@@ -147,6 +147,8 @@ export function WorkspaceTermDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
// 禁用 Radix Dialog 默认的 ESC 关闭:工作区终端会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
|
||||
@@ -74,30 +74,30 @@ export default function ClusterOverviewPage() {
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const getNodeRole = (node: JsonData) => {
|
||||
if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-[var(--dg-blue)]/20 text-[var(--dg-blue)] border-[var(--dg-blue)]/30';
|
||||
return 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)] border-[var(--dg-text-muted)]/30';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<Link to="/kubernetes" className="hover:text-[var(--dg-text-secondary)] transition-colors">集群</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-slate-300">{clusterCode}</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">{clusterCode}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">{clusterCode}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{clusterCode}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">
|
||||
集群概览
|
||||
{clusterInfo?.version ? ` — Kubernetes ${clusterInfo.version}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400"
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||
onClick={loadData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -106,27 +106,27 @@ export default function ClusterOverviewPage() {
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><p className="text-sm text-slate-500">节点</p><p className="text-2xl font-bold text-blue-400">{nodes.length}</p></div>
|
||||
<Cpu className="h-8 w-8 opacity-30 text-blue-400" />
|
||||
<div><p className="text-sm text-[var(--dg-text-muted)]">节点</p><p className="text-2xl font-bold text-[var(--dg-blue)]">{nodes.length}</p></div>
|
||||
<Cpu className="h-8 w-8 opacity-30 text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><p className="text-sm text-slate-500">命名空间</p><p className="text-2xl font-bold text-emerald-400">{namespaces.length}</p></div>
|
||||
<Boxes className="h-8 w-8 opacity-30 text-emerald-400" />
|
||||
<div><p className="text-sm text-[var(--dg-text-muted)]">命名空间</p><p className="text-2xl font-bold text-[var(--dg-green)]">{namespaces.length}</p></div>
|
||||
<Boxes className="h-8 w-8 opacity-30 text-[var(--dg-green)]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><p className="text-sm text-slate-500">版本</p><p className="text-lg font-bold text-slate-300">{clusterInfo?.version || '-'}</p></div>
|
||||
<Server className="h-8 w-8 opacity-30 text-slate-400" />
|
||||
<div><p className="text-sm text-[var(--dg-text-muted)]">版本</p><p className="text-lg font-bold text-[var(--dg-text-secondary)]">{clusterInfo?.version || '-'}</p></div>
|
||||
<Server className="h-8 w-8 opacity-30 text-[var(--dg-text-secondary)]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -134,29 +134,29 @@ export default function ClusterOverviewPage() {
|
||||
|
||||
{/* Nodes table */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-3 flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-400" />节点列表
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-[var(--dg-blue)]" />节点列表
|
||||
</h2>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</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">OS</TableHead>
|
||||
<TableHead className="text-slate-400">Kubelet</TableHead>
|
||||
<TableHead className="text-slate-400">CPU</TableHead>
|
||||
<TableHead className="text-slate-400">内存</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">角色</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">IP</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">OS</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">Kubelet</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">CPU</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">内存</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : nodes.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow>
|
||||
) : (
|
||||
nodes.map((n: JsonData) => {
|
||||
const cpuCores = n.cpu || 0;
|
||||
@@ -167,38 +167,38 @@ export default function ClusterOverviewPage() {
|
||||
// Normalize to MiB
|
||||
const memUsageNorm = n.mem_usage_mi?.includes('Ki') ? memUsageMi / 1024 : n.mem_usage_mi?.includes('Gi') ? memUsageMi * 1024 : memUsageMi;
|
||||
const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0;
|
||||
const color = (pct: number) => pct > 80 ? 'bg-red-500' : pct > 60 ? 'bg-yellow-500' : 'bg-emerald-500';
|
||||
const color = (pct: number) => pct > 80 ? 'bg-[var(--dg-red)]' : pct > 60 ? 'bg-[var(--dg-orange)]' : 'bg-[var(--dg-green)]';
|
||||
return (
|
||||
<TableRow key={n.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
|
||||
<TableRow key={n.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer"
|
||||
onClick={() => navigate(`/kubernetes/${clusterCode}/nodes/${n.name}`)}>
|
||||
<TableCell className="font-medium text-blue-400 hover:text-blue-300">{n.name}</TableCell>
|
||||
<TableCell className="font-medium text-[var(--dg-blue)] hover:text-[var(--dg-blue)]">{n.name}</TableCell>
|
||||
<TableCell><Badge className={getNodeRole(n)}>{n.role || 'worker'}</Badge></TableCell>
|
||||
<TableCell><Badge className={n.status === 'Ready' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>{n.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{n.ip || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400 max-w-[200px] truncate">{n.os || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{n.kubelet_version || '-'}</TableCell>
|
||||
<TableCell><Badge className={n.status === 'Ready' ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}>{n.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{n.ip || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] max-w-[200px] truncate">{n.os || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{n.kubelet_version || '-'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{n.cpu_usage_m ? (
|
||||
<div className="flex items-center gap-1.5 min-w-[80px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color(cpuPct)}`} style={{ width: `${Math.min(cpuPct, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-400">{cpuPct.toFixed(0)}%</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">{cpuPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">{cpuCores} 核</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{cpuCores} 核</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{n.mem_usage_mi ? (
|
||||
<div className="flex items-center gap-1.5 min-w-[80px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color(memPct)}`} style={{ width: `${Math.min(memPct, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-400">{memPct.toFixed(0)}%</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">{memPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">{memMb}Mi</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{memMb}Mi</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -214,10 +214,10 @@ export default function ClusterOverviewPage() {
|
||||
{/* Namespace list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||
<Boxes className="h-4 w-4 text-emerald-400" />命名空间
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Boxes className="h-4 w-4 text-[var(--dg-green)]" />命名空间
|
||||
</h2>
|
||||
<Link to={`/kubernetes/${clusterCode}/namespaces`} className="text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1">
|
||||
<Link to={`/kubernetes/${clusterCode}/namespaces`} className="text-sm text-[var(--dg-blue)] hover:text-[var(--dg-blue)] transition-colors flex items-center gap-1">
|
||||
管理全部 <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -227,14 +227,14 @@ export default function ClusterOverviewPage() {
|
||||
return (
|
||||
<Link key={ns}
|
||||
to={`/kubernetes/${clusterCode}/namespace/${ns}`}
|
||||
className="bg-slate-900 border border-slate-800 rounded-lg p-4 hover:border-slate-600 hover:bg-slate-800/50 transition-all group"
|
||||
className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-4 hover:border-[var(--dg-border-hover)] hover:bg-[var(--dg-bg-tertiary)]/50 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-slate-500 group-hover:text-slate-300 transition-colors" />
|
||||
<span className="text-sm font-medium text-slate-300 group-hover:text-slate-100 transition-colors">{ns}</span>
|
||||
<Icon className="h-5 w-5 text-[var(--dg-text-muted)] group-hover:text-[var(--dg-text-secondary)] transition-colors" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-secondary)] group-hover:text-[var(--dg-text-primary)] transition-colors">{ns}</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
||||
<ChevronRight className="h-4 w-4 text-[var(--dg-text-muted)] group-hover:text-[var(--dg-text-secondary)] transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -124,7 +124,7 @@ export function EventTable({
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={isWarning
|
||||
? 'text-[var(--dg-warning)] border-[var(--dg-warning)]/30 text-[10px]'
|
||||
? 'text-[var(--dg-orange)] border-[var(--dg-orange)]/30 text-[10px]'
|
||||
: 'text-[var(--dg-text-muted)] border-[var(--dg-border)] text-[10px]'}
|
||||
>
|
||||
{e.type}
|
||||
|
||||
@@ -48,7 +48,7 @@ function RankPanel({
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${active ? 'bg-[var(--dg-accent)]' : 'bg-[var(--dg-text-muted)]/50'}`}
|
||||
className={`h-full rounded-full ${active ? 'bg-[var(--dg-blue)]' : 'bg-[var(--dg-text-muted)]/50'}`}
|
||||
style={{ width: `${(item.count / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -254,17 +254,17 @@ export default function EventsPage() {
|
||||
)}
|
||||
|
||||
{summary?.recent_warnings?.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] border-l-2 border-l-[var(--dg-warning)]">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] border-l-2 border-l-[var(--dg-orange)]">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] mb-3 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--dg-warning)]" />
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--dg-orange)]" />
|
||||
最新告警 ({summary.recent_warnings.length})
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{summary.recent_warnings.map((w: JsonData, i: number) => (
|
||||
<div key={i} className="text-xs py-1.5 px-2 rounded hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="shrink-0 font-mono text-[var(--dg-warning)]">{w.reason}</span>
|
||||
<span className="shrink-0 font-mono text-[var(--dg-orange)]">{w.reason}</span>
|
||||
<span className="text-[var(--dg-text-muted)] font-mono truncate">{w.object}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">{w.namespace}</span>
|
||||
{w.source && <span className="text-[var(--dg-text-muted)] font-mono">{w.source}</span>}
|
||||
|
||||
@@ -162,6 +162,8 @@ export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }:
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
// 禁用 Radix Dialog 默认的 ESC 关闭:kubectl 终端会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
|
||||
@@ -98,14 +98,14 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
}, [cluster?.code]);
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cloud className="h-6 w-6 text-blue-400" />
|
||||
<Cloud className="h-6 w-6 text-[var(--dg-blue)]" />
|
||||
<div>
|
||||
<CardTitle className="text-slate-100">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">
|
||||
<CardTitle className="text-[var(--dg-text-primary)]">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-[var(--dg-text-muted)]">
|
||||
{cluster.version} · {cluster.node_count || 0} {t('k8s.cluster.nodeCount')} · {t('k8s.cluster.created')}{' '}
|
||||
{cluster.created_at ? new Date(cluster.created_at).toLocaleDateString() : '-'}
|
||||
</CardDescription>
|
||||
@@ -114,7 +114,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-slate-100"
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
@@ -124,40 +124,40 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700 flex-wrap h-auto">
|
||||
<TabsTrigger value="overview" className="data-[state=active]:bg-slate-700">
|
||||
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex-wrap h-auto">
|
||||
<TabsTrigger value="overview" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.overview')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="nodes" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.nodes')} ({nodes.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.pods')} ({pods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="deployments" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.deployments')} ({deployments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="services" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.services')} ({services.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="ingresses" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.ingresses')} ({ingresses.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="events" className="data-[state=active]:bg-[var(--dg-bg-active)] data-[state=active]:text-[var(--dg-text-primary)]">
|
||||
{t('k8s.tab.events')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<Card className="bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<CardTitle className="text-sm text-slate-400">{t('k8s.cluster.resourceTrend')}</CardTitle>
|
||||
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">{t('k8s.cluster.resourceTrend')}</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-slate-400 hover:text-slate-200"
|
||||
className="h-6 w-6 p-0 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => cluster?.id && loadClusterDetail(cluster.id)}
|
||||
title={t('k8s.cluster.refreshMetrics')}
|
||||
>
|
||||
@@ -171,77 +171,77 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
<AreaChart data={trendData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
|
||||
<stop offset="5%" stopColor="var(--dg-blue)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--dg-blue)" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0.05} />
|
||||
<stop offset="5%" stopColor="var(--dg-green)" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="var(--dg-green)" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke="#1e293b" />
|
||||
<XAxis dataKey="time" stroke="#475569" fontSize={11} tickLine={false} />
|
||||
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} unit="%" tickLine={false} />
|
||||
<CartesianGrid strokeDasharray="2 2" stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="time" stroke="var(--chart-axis)" fontSize={11} tickLine={false} />
|
||||
<YAxis stroke="var(--chart-axis)" fontSize={11} domain={[0, 100]} unit="%" tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #1e293b',
|
||||
backgroundColor: 'var(--chart-tooltip-bg)',
|
||||
border: '1px solid var(--chart-tooltip-border)',
|
||||
borderRadius: '6px',
|
||||
color: '#f8fafc',
|
||||
color: 'var(--dg-text-primary)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
stroke="var(--dg-blue)"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorCpu)"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#3b82f6' }}
|
||||
activeDot={{ r: 3, fill: 'var(--dg-blue)' }}
|
||||
name={t('k8s.label.cpuPercent')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#10b981"
|
||||
stroke="var(--dg-green)"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorMemory)"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, fill: '#10b981' }}
|
||||
activeDot={{ r: 3, fill: 'var(--dg-green)' }}
|
||||
name={t('k8s.label.memoryPercent')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-center gap-6 mt-2 text-xs text-slate-400">
|
||||
<span>{t('k8s.label.cpu')}: <span className="text-blue-400 font-bold">{clusterMetrics.cpuPct.toFixed(1)}%</span></span>
|
||||
<span>{t('k8s.label.memory')}: <span className="text-emerald-400 font-bold">{clusterMetrics.memPct.toFixed(1)}%</span></span>
|
||||
<div className="flex justify-center gap-6 mt-2 text-xs text-[var(--dg-text-secondary)]">
|
||||
<span>{t('k8s.label.cpu')}: <span className="text-[var(--dg-blue)] font-bold">{clusterMetrics.cpuPct.toFixed(1)}%</span></span>
|
||||
<span>{t('k8s.label.memory')}: <span className="text-[var(--dg-green)] font-bold">{clusterMetrics.memPct.toFixed(1)}%</span></span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<Card className="bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">{t('k8s.cluster.resourceDistribution')}</CardTitle>
|
||||
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">{t('k8s.cluster.resourceDistribution')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-blue-400">{cluster.pod_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.pods')}</div>
|
||||
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="text-3xl font-bold text-[var(--dg-blue)]">{cluster.pod_count || 0}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.pods')}</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-emerald-400">{cluster.service_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.services')}</div>
|
||||
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="text-3xl font-bold text-[var(--dg-green)]">{cluster.service_count || 0}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.services')}</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-purple-400">{cluster.deployment_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.deployments')}</div>
|
||||
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="text-3xl font-bold text-[var(--dg-purple)]">{cluster.deployment_count || 0}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.deployments')}</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-amber-400">{cluster.node_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.nodes')}</div>
|
||||
<div className="text-center p-4 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="text-3xl font-bold text-[var(--dg-orange)]">{cluster.node_count || 0}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)] mt-1">{t('k8s.tab.nodes')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -250,35 +250,35 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nodes" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.role')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('common.status')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.cpu')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.memory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.tab.pods')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.os')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.role')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.cpu')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.memory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.tab.pods')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.os')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{nodes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.nodes')}</td>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.nodes')}</td>
|
||||
</tr>
|
||||
) : (
|
||||
nodes.map((node) => (
|
||||
<tr key={node.id || node.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
|
||||
<tr key={node.id || node.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{node.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
node.role === 'master'
|
||||
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
|
||||
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
? 'border-[var(--dg-purple)]/30 text-[var(--dg-purple)] bg-[var(--dg-purple)]/10'
|
||||
: 'border-[var(--dg-blue)]/30 text-[var(--dg-blue)] bg-[var(--dg-blue)]/10'
|
||||
}
|
||||
>
|
||||
{node.role === 'master' ? 'Master' : 'Worker'}
|
||||
@@ -289,21 +289,21 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
variant="outline"
|
||||
className={
|
||||
(node.status || '').toLowerCase() === 'ready'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
|
||||
: 'border-[var(--dg-red)]/30 text-[var(--dg-red)] bg-[var(--dg-red)]/10'
|
||||
}
|
||||
>
|
||||
{(node.status || '').toLowerCase() === 'ready' ? 'Ready' : 'NotReady'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
{node.cpu_usage_m || '0m'} / {node.cpu}{t('k8s.label.cores')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
{node.mem_usage_mi || '0Mi'} / {node.memory_mb ? (node.memory_mb / 1024).toFixed(0) + 'GB' : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{node.max_pods ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{node.max_pods ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{node.os}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -313,30 +313,30 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pods" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('common.status')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.restartCount')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.cpu')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.memory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.node')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.restartCount')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.cpu')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.memory')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.node')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{pods.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.pods')}</td>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.pods')}</td>
|
||||
</tr>
|
||||
) : (
|
||||
pods.map((pod) => (
|
||||
<tr key={pod.id || pod.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
|
||||
<tr key={pod.id || pod.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{pod.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{pod.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
@@ -345,19 +345,19 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
variant="outline"
|
||||
className={
|
||||
(pod.status || '').toLowerCase() === 'running'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
|
||||
: (pod.status || '').toLowerCase() === 'pending'
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
? 'border-[var(--dg-orange)]/30 text-[var(--dg-orange)] bg-[var(--dg-orange)]/10'
|
||||
: 'border-[var(--dg-red)]/30 text-[var(--dg-red)] bg-[var(--dg-red)]/10'
|
||||
}
|
||||
>
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.restart_count ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.cpu_usage_m || '0m'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.mem_usage_mi || '0Mi'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{pod.node_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.restart_count ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.cpu_usage_m || '0m'}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.mem_usage_mi || '0Mi'}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{pod.node_name}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -367,40 +367,40 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deployments" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.replicas')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.availableReplicas')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.image')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.createdAt')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.replicas')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.availableReplicas')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.image')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.createdAt')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{deployments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.deployments')}</td>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.deployments')}</td>
|
||||
</tr>
|
||||
) : (
|
||||
deployments.map((deployment) => (
|
||||
<tr key={deployment.id || deployment.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
|
||||
<tr key={deployment.id || deployment.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{deployment.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{deployment.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{deployment.replicas}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
<span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-[var(--dg-green)]' : 'text-[var(--dg-orange)]'}`}>
|
||||
{deployment.available_replicas ?? deployment.availableReplicas} / {deployment.replicas}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{deployment.created_at || deployment.createdAt}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)] truncate max-w-[200px]">{deployment.image}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{deployment.created_at || deployment.createdAt}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -410,29 +410,29 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="services" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('common.type')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">ClusterIP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.ports')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.selector')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.type')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">ClusterIP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.ports')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.selector')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{services.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.services')}</td>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.services')}</td>
|
||||
</tr>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<tr key={service.id || service.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
|
||||
<tr key={service.id || service.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{service.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{service.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
@@ -441,18 +441,18 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
variant="outline"
|
||||
className={
|
||||
service.type === 'ClusterIP'
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
? 'border-[var(--dg-blue)]/30 text-[var(--dg-blue)] bg-[var(--dg-blue)]/10'
|
||||
: service.type === 'NodePort'
|
||||
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
? 'border-[var(--dg-purple)]/30 text-[var(--dg-purple)] bg-[var(--dg-purple)]/10'
|
||||
: 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
|
||||
}
|
||||
>
|
||||
{service.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.cluster_ip ?? service.clusterIP}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{service.cluster_ip ?? service.clusterIP}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{service.ports}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{service.selector}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -462,40 +462,40 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ingresses" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.hosts')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.backend')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.tls')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.name')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.hosts')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.backend')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('k8s.column.tls')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{ingresses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.ingresses')}</td>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-[var(--dg-text-muted)]">{t('k8s.empty.ingresses')}</td>
|
||||
</tr>
|
||||
) : (
|
||||
ingresses.map((ingress) => (
|
||||
<tr key={ingress.id || ingress.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
|
||||
<tr key={ingress.id || ingress.name} className="hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-primary)]">{ingress.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{ingress.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{ingress.hosts}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{ingress.backend}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{ingress.hosts}</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--dg-text-secondary)]">{ingress.backend}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
ingress.tls
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-700 text-slate-400'
|
||||
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
|
||||
: 'border-[var(--dg-border)] text-[var(--dg-text-secondary)]'
|
||||
}
|
||||
>
|
||||
{ingress.tls ? t('k8s.label.enabled') : t('k8s.label.disabled')}
|
||||
@@ -520,21 +520,21 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
|
||||
].map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)]/50"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.type === 'Normal'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
? 'border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10'
|
||||
: 'border-[var(--dg-orange)]/30 text-[var(--dg-orange)] bg-[var(--dg-orange)]/10'
|
||||
}
|
||||
>
|
||||
{event.type}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-300 min-w-[120px]">{event.reason}</span>
|
||||
<span className="text-sm text-slate-400 flex-1">{event.message}</span>
|
||||
<span className="text-xs text-slate-500">{event.time}</span>
|
||||
<span className="text-sm font-medium text-[var(--dg-text-secondary)] min-w-[120px]">{event.reason}</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] flex-1">{event.message}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{event.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -135,21 +135,21 @@ export function KubernetesClusters() {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
健康
|
||||
</Badge>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-orange)]/30 text-[var(--dg-orange)] bg-[var(--dg-orange)]/10">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
警告
|
||||
</Badge>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-red)]/30 text-[var(--dg-red)] bg-[var(--dg-red)]/10">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
异常
|
||||
</Badge>
|
||||
@@ -166,19 +166,19 @@ export function KubernetesClusters() {
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">集群列表</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">集群列表</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理Kubernetes集群资源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={loadData}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => { setEditingCluster(null); setDialogOpen(true); }}>
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white" onClick={() => { setEditingCluster(null); setDialogOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加集群
|
||||
</Button>
|
||||
@@ -188,12 +188,12 @@ export function KubernetesClusters() {
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input
|
||||
placeholder="搜索集群名称或版本..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,15 +201,15 @@ export function KubernetesClusters() {
|
||||
{/* Clusters Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{loading ? (
|
||||
<Card className="bg-slate-900 border-slate-800 col-span-full">
|
||||
<CardContent className="p-12 text-center text-slate-500">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] col-span-full">
|
||||
<CardContent className="p-12 text-center text-[var(--dg-text-muted)]">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredClusters.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-800 col-span-full">
|
||||
<CardContent className="p-12 text-center text-slate-500">暂无集群数据</CardContent>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] col-span-full">
|
||||
<CardContent className="p-12 text-center text-[var(--dg-text-muted)]">暂无集群数据</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredClusters.map((cluster) => {
|
||||
@@ -220,22 +220,22 @@ export function KubernetesClusters() {
|
||||
<Card
|
||||
key={cluster.id}
|
||||
className={cn(
|
||||
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] cursor-pointer transition-all',
|
||||
selectedCluster === cluster.id
|
||||
? 'border-blue-500 ring-1 ring-blue-500/30'
|
||||
: 'hover:border-slate-700'
|
||||
? 'border-[var(--dg-blue)] ring-1 ring-[var(--dg-blue)]/30'
|
||||
: 'hover:border-[var(--dg-border-hover)]'
|
||||
)}
|
||||
onClick={() => setSelectedCluster(cluster.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-blue)]/10 flex items-center justify-center">
|
||||
<Cloud className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-[var(--dg-text-muted)]">{cluster.version}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -245,21 +245,21 @@ export function KubernetesClusters() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border-hover)]" />
|
||||
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-slate-300 focus:bg-slate-700 focus:text-slate-100"
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingCluster(cluster);
|
||||
@@ -270,7 +270,7 @@ export function KubernetesClusters() {
|
||||
配置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-cyan-400 focus:bg-slate-700 focus:text-cyan-300"
|
||||
className="text-[var(--dg-cyan)] focus:bg-[var(--dg-bg-hover)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSync(cluster.id);
|
||||
@@ -280,7 +280,7 @@ export function KubernetesClusters() {
|
||||
同步
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-emerald-400 focus:bg-slate-700 focus:text-emerald-300"
|
||||
className="text-[var(--dg-green)] focus:bg-[var(--dg-bg-hover)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setKubectlCluster({ id: cluster.id, name: cluster.name });
|
||||
@@ -291,7 +291,7 @@ export function KubernetesClusters() {
|
||||
kubectl
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-blue-400 focus:bg-slate-700 focus:text-blue-300"
|
||||
className="text-[var(--dg-blue)] focus:bg-[var(--dg-bg-hover)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExportCluster(cluster);
|
||||
@@ -300,9 +300,9 @@ export function KubernetesClusters() {
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出资源
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border-hover)]" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
|
||||
className="text-[var(--dg-red)] focus:bg-[var(--dg-bg-hover)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(cluster.id);
|
||||
@@ -318,55 +318,55 @@ export function KubernetesClusters() {
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.ingress_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Ingress</div>
|
||||
<div className="text-center p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
|
||||
<div className="text-lg font-semibold text-[var(--dg-text-primary)]">{cluster.ingress_count || 0}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">Ingress</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">SVC</div>
|
||||
<div className="text-center p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
|
||||
<div className="text-lg font-semibold text-[var(--dg-text-primary)]">{cluster.service_count || 0}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">SVC</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Deployment</div>
|
||||
<div className="text-center p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
|
||||
<div className="text-lg font-semibold text-[var(--dg-text-primary)]">{cluster.deployment_count || 0}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">Deployment</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Pod</div>
|
||||
<div className="text-center p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
|
||||
<div className="text-lg font-semibold text-[var(--dg-text-primary)]">{cluster.pod_count || 0}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">Pod</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{hasMetrics ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">CPU</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-10">CPU</span>
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
cpuUsage > 80 ? 'bg-[var(--dg-red)]' : cpuUsage > 60 ? 'bg-[var(--dg-orange)]' : 'bg-[var(--dg-green)]'
|
||||
)}
|
||||
style={{ width: `${Math.min(cpuUsage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{cpuUsage.toFixed(1)}%</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] w-10 text-right">{cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">内存</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-10">内存</span>
|
||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
memoryUsage > 80 ? 'bg-[var(--dg-red)]' : memoryUsage > 60 ? 'bg-[var(--dg-orange)]' : 'bg-[var(--dg-green)]'
|
||||
)}
|
||||
style={{ width: `${Math.min(memoryUsage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{memoryUsage.toFixed(1)}%</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] w-10 text-right">{memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-600 text-center">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] text-center">
|
||||
Ingress {cluster.ingress_count || 0} · SVC {cluster.service_count || 0} · Deployment {cluster.deployment_count || 0} · Pod {cluster.pod_count || 0} · 资源使用率待采集
|
||||
</div>
|
||||
)}
|
||||
@@ -410,13 +410,13 @@ export function KubernetesClusters() {
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onClick={() => { if (!exporting) setExportCluster(null); }}
|
||||
>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-[420px] shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-5 w-[420px] shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold text-[var(--dg-text-primary)] mb-1">导出集群资源</h3>
|
||||
{exporting ? (
|
||||
<div className="flex flex-col items-center py-6 gap-3">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-blue-400" />
|
||||
<p className="text-sm text-slate-200">正在导出 {exportCluster.name}</p>
|
||||
<p className="text-xs text-slate-500 text-center leading-relaxed px-2">
|
||||
<Loader2 className="h-9 w-9 animate-spin text-[var(--dg-blue)]" />
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">正在导出 {exportCluster.name}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center leading-relaxed px-2">
|
||||
正在拉取所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 并打包为 tar.gz
|
||||
<br />
|
||||
资源较多时可能需要 1~3 分钟,请稍候勿关闭页面
|
||||
@@ -424,13 +424,13 @@ export function KubernetesClusters() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-slate-400 mb-4 leading-relaxed">
|
||||
导出 <span className="text-slate-300">{exportCluster.name}</span> 所有命名空间的
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mb-4 leading-relaxed">
|
||||
导出 <span className="text-[var(--dg-text-primary)]">{exportCluster.name}</span> 所有命名空间的
|
||||
Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport}>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setExportCluster(null)}>取消</Button>
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white" onClick={handleExport}>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
确认导出
|
||||
</Button>
|
||||
|
||||
@@ -50,33 +50,33 @@ export function KubernetesNodes() {
|
||||
|
||||
const getRoleBadge = (role: string) =>
|
||||
role === 'master'
|
||||
? <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">Master</Badge>
|
||||
: <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">Worker</Badge>;
|
||||
? <Badge className="bg-[var(--dg-blue)]/20 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">Master</Badge>
|
||||
: <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">Worker</Badge>;
|
||||
|
||||
const getStatusBadge = (status: string) =>
|
||||
status === 'Ready'
|
||||
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">{status}</Badge>
|
||||
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">{status}</Badge>
|
||||
: <Badge variant="destructive">{status || 'Unknown'}</Badge>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">节点管理</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Kubernetes 集群工作节点状态与资源</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">节点管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">Kubernetes 集群工作节点状态与资源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedCluster} onValueChange={setSelectedCluster}>
|
||||
<SelectTrigger className="w-48 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectTrigger className="w-48 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.code || c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadNodes} disabled={loading}>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={loadNodes} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -84,14 +84,14 @@ export function KubernetesNodes() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input placeholder="搜索节点..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input placeholder="搜索节点..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="master">Master</SelectItem>
|
||||
<SelectItem value="worker">Worker</SelectItem>
|
||||
@@ -99,54 +99,54 @@ export function KubernetesNodes() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<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">Pods</TableHead>
|
||||
<TableHead className="text-slate-400">Kubelet</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">节点名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">IP</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">角色</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">Pods</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">Kubelet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Server className="h-8 w-8 mx-auto mb-2 opacity-50" />{selectedCluster ? '暂无节点数据' : '请选择集群'}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-[var(--dg-text-muted)] py-12"><Server className="h-8 w-8 mx-auto mb-2 opacity-50" />{selectedCluster ? '暂无节点数据' : '请选择集群'}</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<TableRow key={n.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{n.name}</TableCell>
|
||||
<TableCell><code className="text-sm text-slate-300 bg-slate-800 px-1.5 py-0.5 rounded">{n.ip || '-'}</code></TableCell>
|
||||
<TableRow key={n.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{n.name}</TableCell>
|
||||
<TableCell><code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 rounded">{n.ip || '-'}</code></TableCell>
|
||||
<TableCell>{getRoleBadge(n.role)}</TableCell>
|
||||
<TableCell>{getStatusBadge(n.status)}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{n.cpu ? `${n.cpu}核` : '-'} / {n.memory ? `${Math.round(n.memory / 1024)}GB` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const cpu = n.cpu_usage || 0;
|
||||
const mem = n.memory_usage || 0;
|
||||
const bar = (val: number) => val >= 85 ? 'bg-red-500' : val >= 60 ? 'bg-yellow-500' : 'bg-emerald-500';
|
||||
const txt = (val: number) => val >= 85 ? 'text-red-400' : val >= 60 ? 'text-yellow-400' : 'text-emerald-400';
|
||||
const bar = (val: number) => val >= 85 ? 'bg-[var(--dg-red)]' : val >= 60 ? 'bg-[var(--dg-orange)]' : 'bg-[var(--dg-green)]';
|
||||
const txt = (val: number) => val >= 85 ? 'text-[var(--dg-red)]' : val >= 60 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-green)]';
|
||||
return (
|
||||
<div className="flex flex-col gap-1 min-w-[120px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-7">CPU</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-7">CPU</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bar(cpu)}`} style={{ width: `${Math.min(cpu, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs w-9 text-right ${txt(cpu)}`}>{cpu.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-7">Mem</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] w-7">Mem</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bar(mem)}`} style={{ width: `${Math.min(mem, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs w-9 text-right ${txt(mem)}`}>{mem.toFixed(0)}%</span>
|
||||
@@ -155,8 +155,8 @@ export function KubernetesNodes() {
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{n.pod_count || 0} / {n.max_pods || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{n.kubelet_version || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{n.pod_count || 0} / {n.max_pods || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{n.kubelet_version || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -108,31 +108,31 @@ export default function NamespaceManagementPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<Link to="/kubernetes" className="hover:text-[var(--dg-text-secondary)] transition-colors">集群</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link>
|
||||
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-[var(--dg-text-secondary)] transition-colors">{clusterCode}</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-slate-300">命名空间管理</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">命名空间管理</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100 flex items-center gap-2">
|
||||
<Boxes className="h-6 w-6 text-emerald-400" />
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Boxes className="h-6 w-6 text-[var(--dg-green)]" />
|
||||
命名空间管理
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">命名空间资源配额与生命周期管理</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">命名空间资源配额与生命周期管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400"
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||
onClick={loadData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90">
|
||||
<Plus className="h-4 w-4 mr-1" />创建命名空间
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -149,7 +149,7 @@ export default function NamespaceManagementPage() {
|
||||
placeholder="例如: my-app"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
名称需符合 DNS-1123 标准(小写字母/数字/连字符)
|
||||
</p>
|
||||
</div>
|
||||
@@ -168,34 +168,34 @@ export default function NamespaceManagementPage() {
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">命名空间</p><p className="text-xl font-bold text-emerald-400">{namespaces.length}</p></div>
|
||||
<Boxes className="h-6 w-6 opacity-30 text-emerald-400" />
|
||||
<div><p className="text-xs text-[var(--dg-text-muted)]">命名空间</p><p className="text-xl font-bold text-[var(--dg-green)]">{namespaces.length}</p></div>
|
||||
<Boxes className="h-6 w-6 opacity-30 text-[var(--dg-green)]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">Deployments</p><p className="text-xl font-bold text-blue-400">{totalDeploys}</p></div>
|
||||
<Container className="h-6 w-6 opacity-30 text-blue-400" />
|
||||
<div><p className="text-xs text-[var(--dg-text-muted)]">Deployments</p><p className="text-xl font-bold text-[var(--dg-blue)]">{totalDeploys}</p></div>
|
||||
<Container className="h-6 w-6 opacity-30 text-[var(--dg-blue)]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">Pods</p><p className="text-xl font-bold text-purple-400">{totalPods}</p></div>
|
||||
<Activity className="h-6 w-6 opacity-30 text-purple-400" />
|
||||
<div><p className="text-xs text-[var(--dg-text-muted)]">Pods</p><p className="text-xl font-bold text-[var(--dg-purple)]">{totalPods}</p></div>
|
||||
<Activity className="h-6 w-6 opacity-30 text-[var(--dg-purple)]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">Services</p><p className="text-xl font-bold text-cyan-400">{totalSvcs}</p></div>
|
||||
<Network className="h-6 w-6 opacity-30 text-cyan-400" />
|
||||
<div><p className="text-xs text-[var(--dg-text-muted)]">Services</p><p className="text-xl font-bold text-[var(--dg-cyan)]">{totalSvcs}</p></div>
|
||||
<Network className="h-6 w-6 opacity-30 text-[var(--dg-cyan)]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div><p className="text-xs text-slate-500">配额限制</p><p className="text-xl font-bold text-amber-400">{quotaCount}</p></div>
|
||||
<Shield className="h-6 w-6 opacity-30 text-amber-400" />
|
||||
<div><p className="text-xs text-[var(--dg-text-muted)]">配额限制</p><p className="text-xl font-bold text-[var(--dg-orange)]">{quotaCount}</p></div>
|
||||
<Shield className="h-6 w-6 opacity-30 text-[var(--dg-orange)]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -206,89 +206,89 @@ export default function NamespaceManagementPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索命名空间..."
|
||||
className="max-w-xs bg-slate-900 border-slate-700"
|
||||
className="max-w-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Namespace table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">年龄</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">Deploy</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">Pods</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">Svc</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">配额</TableHead>
|
||||
<TableHead className="text-slate-400">标签</TableHead>
|
||||
<TableHead className="text-slate-400 text-center">操作</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">年龄</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-center">Deploy</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-center">Pods</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-center">Svc</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-center">配额</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-12">
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
<Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无命名空间
|
||||
</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((ns) => {
|
||||
const isSystem = SYSTEM_NAMESPACES.has(ns.name);
|
||||
return (
|
||||
<TableRow key={ns.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableRow key={ns.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium">
|
||||
<button
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline text-left"
|
||||
className="text-[var(--dg-blue)] hover:text-[var(--dg-blue)] hover:underline text-left"
|
||||
onClick={() => navigate(`/kubernetes/${clusterCode}/namespace/${ns.name}`)}
|
||||
>
|
||||
{ns.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={ns.status === 'Active' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>
|
||||
<Badge className={ns.status === 'Active' ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}>
|
||||
{ns.status === 'Active' ? <CheckCircle2 className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||
{ns.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{ns.age}</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-300">{ns.resource_counts?.deployments ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-300">{ns.resource_counts?.pods ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm text-slate-300">{ns.resource_counts?.services ?? 0}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{ns.age}</TableCell>
|
||||
<TableCell className="text-center text-sm text-[var(--dg-text-secondary)]">{ns.resource_counts?.deployments ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm text-[var(--dg-text-secondary)]">{ns.resource_counts?.pods ?? 0}</TableCell>
|
||||
<TableCell className="text-center text-sm text-[var(--dg-text-secondary)]">{ns.resource_counts?.services ?? 0}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{ns.has_resource_quota ? (
|
||||
<Badge className="bg-amber-500/20 text-amber-400" title={JSON.stringify(ns.resource_quotas?.[0]?.status)}>
|
||||
<Badge className="bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]" title={JSON.stringify(ns.resource_quotas?.[0]?.status)}>
|
||||
<Shield className="h-3 w-3 mr-1" />有
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">-</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{ns.labels && Object.keys(ns.labels).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 max-w-[200px]">
|
||||
{Object.entries(ns.labels).slice(0, 3).map(([k, v]) => (
|
||||
<span key={k} className="px-1.5 py-0.5 bg-slate-800 rounded text-slate-400">{k}={v}</span>
|
||||
<span key={k} className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] rounded text-[var(--dg-text-secondary)]">{k}={v}</span>
|
||||
))}
|
||||
{Object.keys(ns.labels).length > 3 && (
|
||||
<span className="text-slate-600">+{Object.keys(ns.labels).length - 3}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">+{Object.keys(ns.labels).length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-600">-</span>
|
||||
<span className="text-[var(--dg-text-muted)]">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSystem ? (
|
||||
<span className="text-xs text-slate-600">系统</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">系统</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
className="h-7 w-7 text-[var(--dg-red)] hover:text-[var(--dg-red)] hover:bg-[var(--dg-red)]/10"
|
||||
onClick={() => setDeleteTarget(ns.name)}
|
||||
title="删除命名空间"
|
||||
>
|
||||
@@ -309,16 +309,16 @@ export default function NamespaceManagementPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-400 flex items-center gap-2">
|
||||
<DialogTitle className="text-[var(--dg-red)] flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />删除命名空间
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
|
||||
<p className="text-sm text-red-300">
|
||||
<div className="bg-[var(--dg-red)]/10 border border-[var(--dg-red)]/30 rounded-lg p-3">
|
||||
<p className="text-sm text-[var(--dg-red)]">
|
||||
⚠️ 即将删除命名空间 <span className="font-bold">{deleteTarget}</span> 及其中所有资源。
|
||||
</p>
|
||||
<p className="text-xs text-red-400/70 mt-1">
|
||||
<p className="text-xs text-[var(--dg-red)]/70 mt-1">
|
||||
此操作不可逆,所有 Deployment/Pod/Service/ConfigMap 等资源将被永久删除。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -65,22 +65,22 @@ export default function NamespaceOverviewPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<Link to="/kubernetes" className="hover:text-[var(--dg-text-primary)] transition-colors">集群</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link>
|
||||
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-[var(--dg-text-primary)] transition-colors">{clusterCode}</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-slate-300">{namespace}</span>
|
||||
<span className="text-[var(--dg-text-primary)]">{namespace}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100 flex items-center gap-2">
|
||||
<Cloud className="h-6 w-6 text-blue-400" />
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Cloud className="h-6 w-6 text-[var(--dg-blue)]" />
|
||||
{namespace}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">命名空间概览</p>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">命名空间概览</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,15 +89,15 @@ export default function NamespaceOverviewPage() {
|
||||
{RESOURCE_LINKS.map((r) => (
|
||||
<Link key={r.type}
|
||||
to={`/kubernetes/${clusterCode}/namespace/${namespace}/${r.type}`}
|
||||
className="bg-slate-900 border border-slate-800 rounded-lg p-4 hover:border-slate-600 hover:bg-slate-800/50 transition-all group"
|
||||
className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-4 hover:border-[var(--dg-border-hover)] hover:bg-[var(--dg-bg-tertiary)]/50 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<r.icon className="h-5 w-5 text-slate-500 group-hover:text-blue-400 transition-colors" />
|
||||
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
||||
<r.icon className="h-5 w-5 text-[var(--dg-text-muted)] group-hover:text-[var(--dg-blue)] transition-colors" />
|
||||
<ChevronRight className="h-4 w-4 text-[var(--dg-text-muted)] group-hover:text-[var(--dg-text-secondary)] transition-colors" />
|
||||
</div>
|
||||
<div className="text-lg font-bold text-slate-100">{loading ? '-' : (counts[r.type] ?? 0)}</div>
|
||||
<div className="text-sm text-slate-500">{r.label}</div>
|
||||
<div className="text-xs text-slate-600 mt-1">{r.desc}</div>
|
||||
<div className="text-lg font-bold text-[var(--dg-text-primary)]">{loading ? '-' : (counts[r.type] ?? 0)}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)]">{r.label}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{r.desc}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,9 @@ const BULK_EXPORT_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps'
|
||||
|
||||
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300" title="可视化编辑"
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-green)] hover:text-[var(--dg-green)]" title="可视化编辑"
|
||||
onClick={() => opts.onEdit?.(name)}><Pencil className="h-3.5 w-3.5 mr-1" />编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-slate-400 hover:text-slate-300" title="YAML"
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-secondary)]" title="YAML"
|
||||
onClick={() => opts.onYaml?.(name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -60,24 +60,24 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
|
||||
columns: ['名称', '命名空间', '副本', '镜像'],
|
||||
renderRow: (d, opts) => (
|
||||
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
|
||||
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
|
||||
<TableRow key={d.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{d.namespace}</Badge></TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const ready = d.ready_replicas || 0; const total = d.replicas || 0;
|
||||
const pct = total > 0 ? (ready / total) * 100 : 0; const done = ready === total;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[100px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${done ? 'bg-emerald-500' : 'bg-yellow-500'}`} style={{ width: `${pct}%` }} />
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-bg-tertiary)]/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${done ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-orange)]'}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs ${done ? 'text-emerald-400' : 'text-yellow-400'}`}>{ready}/{total}</span>
|
||||
<span className={`text-xs ${done ? 'text-[var(--dg-green)]' : 'text-[var(--dg-orange)]'}`}>{ready}/{total}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
},
|
||||
@@ -85,31 +85,31 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'Pods', subtitle: 'Pod 实例运行状态', icon: Container,
|
||||
columns: ['名称', '命名空间', '状态', '节点', 'CPU', '内存', '重启', '镜像', '操作'],
|
||||
renderRow: (p, opts) => {
|
||||
const statusMap: Record<string, string> = { Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', Failed: 'bg-red-500/20 text-red-400 border-red-500/30' };
|
||||
const statusMap: Record<string, string> = { Running: 'bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30', Pending: 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)] border-[var(--dg-orange)]/30', Failed: 'bg-[var(--dg-red)]/20 text-[var(--dg-red)] border-[var(--dg-red)]/30' };
|
||||
return (
|
||||
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200 cursor-pointer hover:text-[var(--dg-blue)]" onClick={() => opts.onPodDetail?.(p)}>
|
||||
<TableRow key={p.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)] cursor-pointer hover:text-[var(--dg-blue)]" onClick={() => opts.onPodDetail?.(p)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Eye className="h-3.5 w-3.5 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={statusMap[p.status] || 'bg-slate-500/20 text-slate-400'}>{p.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
|
||||
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={statusMap[p.status] || 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)]'}>{p.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-cyan)]">{p.cpu_usage_m || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-orange)]">{p.mem_usage_mi || '-'}</TableCell>
|
||||
<TableCell className={(p.restart_count > 0 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-text-muted)]')}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-green)]" title="终端"
|
||||
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-orange)]" title="文件"
|
||||
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-blue-400" title="YAML"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-blue)]" title="YAML"
|
||||
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-red-400 hover:text-red-300" title="删除"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-red)] hover:text-[var(--dg-red)]" title="删除"
|
||||
onClick={() => opts.onDeletePod?.(p)}><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -121,12 +121,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'Services', subtitle: 'Service 网络服务', icon: Network,
|
||||
columns: ['名称', '命名空间', '类型', 'Cluster IP', '端口', '操作'],
|
||||
renderRow: (s, opts) => (
|
||||
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
|
||||
<TableRow key={s.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{s.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className="bg-[var(--dg-blue)]/20 text-[var(--dg-blue)] border-[var(--dg-blue)]/30">{s.type || '-'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.cluster_ip || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.ports || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -137,11 +137,11 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
renderRow: (i, opts) => {
|
||||
const hosts = i.hosts || [];
|
||||
return (
|
||||
<TableRow key={i.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{i.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
|
||||
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
|
||||
<TableRow key={i.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{i.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{i.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
|
||||
<TableCell><Badge className={i.tls ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)]'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={i.name} opts={opts} /></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -151,10 +151,10 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'ConfigMaps', subtitle: '配置字典', icon: FileText,
|
||||
columns: ['名称', '命名空间', '键数量', '操作'],
|
||||
renderRow: (c, opts) => (
|
||||
<TableRow key={c.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{c.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{c.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{c.key_count || '-'}</TableCell>
|
||||
<TableRow key={c.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{c.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{c.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{c.key_count || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={c.name} opts={opts} /></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -163,10 +163,10 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'Secrets', subtitle: '密钥管理(仅名称与类型)', icon: Shield,
|
||||
columns: ['名称', '命名空间', '类型', '操作'],
|
||||
renderRow: (s, opts) => (
|
||||
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.type || '-'}</TableCell>
|
||||
<TableRow key={s.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{s.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.type || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -175,13 +175,13 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'HPAs', subtitle: '水平自动伸缩', icon: Activity,
|
||||
columns: ['名称', '命名空间', '目标', '最小/最大', 'CPU%', 'Mem%'],
|
||||
renderRow: (h, _opts) => (
|
||||
<TableRow key={h.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{h.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{h.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{h.target_kind}/{h.target_name}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{h.min_replicas}/{h.max_replicas}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{h.cpu_percent ?? '-'}%</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{h.mem_percent ?? '-'}%</TableCell>
|
||||
<TableRow key={h.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{h.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{h.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.target_kind}/{h.target_name}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.min_replicas}/{h.max_replicas}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.cpu_percent ?? '-'}%</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{h.mem_percent ?? '-'}%</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
},
|
||||
@@ -189,12 +189,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'CronJobs', subtitle: '定时任务', icon: Clock,
|
||||
columns: ['名称', '命名空间', '调度', '状态', '上次执行'],
|
||||
renderRow: (cj) => (
|
||||
<TableRow key={cj.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{cj.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{cj.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm font-mono text-slate-400">{cj.schedule || '-'}</TableCell>
|
||||
<TableCell><Badge className={cj.suspend ? 'bg-yellow-500/20 text-yellow-400' : 'bg-emerald-500/20 text-emerald-400'}>{cj.suspend ? '已暂停' : '运行中'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{cj.last_schedule || '-'}</TableCell>
|
||||
<TableRow key={cj.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cj.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{cj.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{cj.schedule || '-'}</TableCell>
|
||||
<TableCell><Badge className={cj.suspend ? 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]' : 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]'}>{cj.suspend ? '已暂停' : '运行中'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{cj.last_schedule || '-'}</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
},
|
||||
@@ -202,12 +202,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: '存储卷声明', subtitle: 'PersistentVolumeClaim', icon: Boxes,
|
||||
columns: ['名称', '命名空间', '状态', '大小', 'StorageClass', '操作'],
|
||||
renderRow: (p, opts) => (
|
||||
<TableRow key={p.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
|
||||
<TableRow key={p.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{p.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={p.phase === 'Bound' ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]'}>{p.phase || '-'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{p.size || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{p.storage_class || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}><VisualActions name={p.name} opts={opts} /></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
@@ -216,12 +216,12 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'StatefulSets', subtitle: '有状态应用', icon: Boxes,
|
||||
columns: ['名称', '命名空间', '副本', '就绪', '镜像'],
|
||||
renderRow: (s, opts) => (
|
||||
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(s)}>
|
||||
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.replicas || 0}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{s.ready || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{s.image || '-'}</TableCell>
|
||||
<TableRow key={s.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer" onClick={() => opts.onDetail?.(s)}>
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{s.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{s.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.replicas || 0}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{s.ready || 0}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{s.image || '-'}</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
},
|
||||
@@ -229,13 +229,13 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'DaemonSets', subtitle: '守护进程集', icon: Boxes,
|
||||
columns: ['名称', '命名空间', '期望', '当前', '就绪', '镜像'],
|
||||
renderRow: (d, opts) => (
|
||||
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
|
||||
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{d.desired || 0}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{d.current || 0}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{d.ready || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
<TableRow key={d.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{d.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{d.desired || 0}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{d.current || 0}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{d.ready || 0}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
},
|
||||
@@ -243,16 +243,16 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
title: 'ServiceAccounts', subtitle: '服务账号(RBAC 身份标识)', icon: Shield,
|
||||
columns: ['名称', '命名空间', 'Secret数', '镜像拉取凭证', '创建时间', '操作'],
|
||||
renderRow: (sa, opts) => (
|
||||
<TableRow key={sa.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{sa.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{sa.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{sa.secret_count ?? 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">
|
||||
<TableRow key={sa.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{sa.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{sa.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{sa.secret_count ?? 0}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate">
|
||||
{sa.image_pull_secrets && sa.image_pull_secrets.length > 0 ? sa.image_pull_secrets.join(', ') : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{sa.age || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{sa.age || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" title="查看YAML"
|
||||
onClick={() => opts.onYaml?.(sa.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -267,22 +267,22 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
const verbsStr = verbs.length > 4 ? verbs.slice(0, 4).join(', ') + ` +${verbs.length - 4}` : verbs.join(', ');
|
||||
const resStr = resources.length > 4 ? resources.slice(0, 4).join(', ') + ` +${resources.length - 4}` : resources.join(', ');
|
||||
return (
|
||||
<TableRow key={r.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{r.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{r.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{r.rule_count ?? 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[280px]">
|
||||
<TableRow key={r.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{r.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{r.namespace}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{r.rule_count ?? 0}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[280px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-cyan-400 truncate" title={verbs.join(', ')}>verbs: {verbsStr || '-'}</span>
|
||||
<span className="text-amber-400 truncate" title={resources.join(', ')}>resources: {resStr || '-'}</span>
|
||||
<span className="text-[var(--dg-cyan)] truncate" title={verbs.join(', ')}>verbs: {verbsStr || '-'}</span>
|
||||
<span className="text-[var(--dg-orange)] truncate" title={resources.join(', ')}>resources: {resStr || '-'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{r.age || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{r.age || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" title="查看YAML"
|
||||
onClick={() => opts.onYaml?.(r.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑"
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-green)] hover:text-[var(--dg-green)]" title="编辑"
|
||||
onClick={() => opts.onEdit?.(r.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -297,23 +297,23 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
const subjects = rb.subjects || [];
|
||||
const subjStr = subjects.length > 3 ? subjects.slice(0, 3).join(', ') + ` +${subjects.length - 3}` : subjects.join(', ');
|
||||
return (
|
||||
<TableRow key={rb.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{rb.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{rb.namespace}</Badge></TableCell>
|
||||
<TableRow key={rb.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{rb.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{rb.namespace}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30">
|
||||
<Badge className="bg-[var(--dg-purple)]/20 text-[var(--dg-purple)] border-[var(--dg-purple)]/30">
|
||||
{rb.role_ref_kind || '-'}/{rb.role_ref_name || '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate" title={subjects.join(', ')}>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] max-w-[200px] truncate" title={subjects.join(', ')}>
|
||||
{subjStr || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{rb.age || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{rb.age || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" title="查看YAML"
|
||||
onClick={() => opts.onYaml?.(rb.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑"
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-green)] hover:text-[var(--dg-green)]" title="编辑"
|
||||
onClick={() => opts.onEdit?.(rb.name)}><Pencil className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -461,7 +461,7 @@ export default function NamespaceResourcePage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return <div className="text-slate-400 p-8 text-center">未知资源类型: {type}</div>;
|
||||
if (!config) return <div className="text-[var(--dg-text-secondary)] p-8 text-center">未知资源类型: {type}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -495,50 +495,50 @@ export default function NamespaceResourcePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
|
||||
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link></>}
|
||||
{namespace && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}/namespace/${namespace}`} className="hover:text-slate-300 transition-colors">{namespace}</Link></>}
|
||||
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">{config.title}</span>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||||
<Link to="/kubernetes" className="hover:text-[var(--dg-text-secondary)] transition-colors">集群</Link>
|
||||
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-[var(--dg-text-secondary)] transition-colors">{clusterCode}</Link></>}
|
||||
{namespace && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}/namespace/${namespace}`} className="hover:text-[var(--dg-text-secondary)] transition-colors">{namespace}</Link></>}
|
||||
<ChevronRight className="h-3 w-3" /><span className="text-[var(--dg-text-secondary)]">{config.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">{config.title}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">{config.subtitle}</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{config.title}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">{config.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={clusterCode || ''} onValueChange={(v) => navigate(`/kubernetes/${v}/namespace/${namespace || 'default'}/${type}`)}>
|
||||
<SelectTrigger className="w-48 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="选择集群" /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">{clusters.map((c) => <SelectItem key={c.id} value={c.code || String(c.id)}>{c.name}</SelectItem>)}</SelectContent>
|
||||
<SelectTrigger className="w-48 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue placeholder="选择集群" /></SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">{clusters.map((c) => <SelectItem key={c.id} value={c.code || String(c.id)}>{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={namespace || ''} onValueChange={(v) => navigate(`/kubernetes/${clusterCode}/namespace/${v}/${type}`)}>
|
||||
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="命名空间" /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 max-h-[300px]">{namespaces.map((ns) => <SelectItem key={ns} value={ns}>{ns}</SelectItem>)}</SelectContent>
|
||||
<SelectTrigger className="w-40 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue placeholder="命名空间" /></SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] max-h-[300px]">{namespaces.map((ns) => <SelectItem key={ns} value={ns}>{ns}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadData} disabled={loading}>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={loadData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
{BULK_EXPORT_TYPES.includes(type) && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
|
||||
<Button variant="outline" size="sm" className="h-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs" onClick={handleExportCSV} title="导出CSV">
|
||||
<Download className="h-3 w-3 mr-1" />CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
|
||||
<Button variant="outline" size="sm" className="h-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs" onClick={handleExportJSON} title="导出JSON">
|
||||
<Download className="h-3 w-3 mr-1" />JSON
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{EXPORT_YAML_TYPES.includes(type) && (
|
||||
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportYaml} title="导出命名空间 YAML(tar.gz)">
|
||||
<Button variant="outline" size="sm" className="h-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs" onClick={handleExportYaml} title="导出命名空间 YAML(tar.gz)">
|
||||
<Download className="h-3 w-3 mr-1" />导出YAML
|
||||
</Button>
|
||||
)}
|
||||
{(VISUAL_RESOURCE_TYPES.includes(type) || DEPLOYMENT_LIST_TYPES.includes(type)) && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
|
||||
<Button variant="outline" size="sm" className="h-9 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs" onClick={() => setShowYamlImport(true)}>
|
||||
<Upload className="h-3 w-3 mr-1" />导入
|
||||
</Button>
|
||||
<Button size="sm" className="h-9 text-xs bg-blue-600 hover:bg-blue-700" onClick={handleCreate}>
|
||||
<Button size="sm" className="h-9 text-xs bg-[var(--dg-blue)] hover:opacity-90" onClick={handleCreate}>
|
||||
<Plus className="h-3 w-3 mr-1" />新建
|
||||
</Button>
|
||||
</>
|
||||
@@ -547,19 +547,19 @@ export default function NamespaceResourcePage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-56 flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input placeholder="搜索..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input placeholder="搜索..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">{config.columns.map((col) => <TableHead key={col} className="text-slate-400">{col}</TableHead>)}</TableRow></TableHeader>
|
||||
<TableHeader><TableRow className="border-[var(--dg-border)] hover:bg-transparent">{config.columns.map((col) => <TableHead key={col} className="text-[var(--dg-text-secondary)]">{col}</TableHead>)}</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p), onDeletePod: handleDeletePod, onPodDetail: ( p: any) => setPodDetail(p) }))
|
||||
)}
|
||||
@@ -606,7 +606,7 @@ export default function NamespaceResourcePage() {
|
||||
const labels = p.labels || p.metadata?.labels || {};
|
||||
const annotations = p.annotations || p.metadata?.annotations || {};
|
||||
const created = p.creationTimestamp || p.created_at || p.create_time;
|
||||
const statusMap: Record<string, string> = { Running: 'bg-emerald-500/20 text-emerald-400', Pending: 'bg-yellow-500/20 text-yellow-400', Failed: 'bg-red-500/20 text-red-400' };
|
||||
const statusMap: Record<string, string> = { Running: 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]', Pending: 'bg-[var(--dg-orange)]/20 text-[var(--dg-orange)]', Failed: 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]' };
|
||||
const st = p.status || 'Running';
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
|
||||
@@ -620,7 +620,7 @@ export default function NamespaceResourcePage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">状态</span>
|
||||
<div><Badge className={statusMap[st] || 'bg-slate-500/20 text-slate-400'}>{st}</Badge></div>
|
||||
<div><Badge className={statusMap[st] || 'bg-[var(--dg-text-muted)]/20 text-[var(--dg-text-secondary)]'}>{st}</Badge></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">创建时间</span>
|
||||
@@ -689,7 +689,7 @@ export default function NamespaceResourcePage() {
|
||||
<TableRow key={idx} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-xs text-[var(--dg-text-primary)] font-medium">{type}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={isTrue ? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25' : isFalse ? 'bg-red-500/15 text-red-400 border-red-500/25' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}>{st}</Badge>
|
||||
<Badge className={isTrue ? 'bg-[var(--dg-green)]/15 text-[var(--dg-green)] border-[var(--dg-green)]/25' : isFalse ? 'bg-[var(--dg-red)]/15 text-[var(--dg-red)] border-[var(--dg-red)]/25' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}>{st}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">{ts ? new Date(ts).toLocaleString('zh-CN') : '-'}</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-primary)]">{reason}</TableCell>
|
||||
|
||||
@@ -279,6 +279,8 @@ export function PodExecTerminal({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
// 禁用 Radix Dialog 默认的 ESC 关闭:Pod exec 终端会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
|
||||
@@ -27,11 +27,11 @@ type ViewMode = 'view' | 'yaml';
|
||||
|
||||
function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="text-base font-bold mt-0.5" style={{ color: color || '#e2e8f0' }}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-slate-600 mt-0.5">{sub}</p>}
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-base font-bold mt-0.5" style={{ color: color || 'var(--dg-text-primary)' }}>{value}</p>
|
||||
{sub && <p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{sub}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -40,11 +40,11 @@ function StatCard({ label, value, sub, color }: { label: string; value: React.Re
|
||||
// Helper: render key-value pairs as badges
|
||||
function LabelBadges({ labels }: { labels: Record<string, string> | undefined }) {
|
||||
const entries = Object.entries(labels || {});
|
||||
if (entries.length === 0) return <span className="text-xs text-slate-600">无</span>;
|
||||
if (entries.length === 0) return <span className="text-xs text-[var(--dg-text-muted)]">无</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entries.map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="border-slate-700 text-slate-400 text-[10px] font-mono">
|
||||
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-[10px] font-mono">
|
||||
{k}={v}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -192,46 +192,46 @@ export default function WorkloadViewPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-200" onClick={() => navigate(-1)}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Boxes className="h-5 w-5 text-blue-400" />
|
||||
<Boxes className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-slate-100">{name}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{kind}</Badge>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
<h1 className="text-lg font-bold text-[var(--dg-text-primary)]">{name}</h1>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{kind}</Badge>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{namespace}</Badge>
|
||||
{isHealthy
|
||||
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">健康</Badge>
|
||||
: <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30">更新中</Badge>}
|
||||
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">健康</Badge>
|
||||
: <Badge className="bg-[var(--dg-orange)]/20 text-[var(--dg-orange)] border-[var(--dg-orange)]/30">更新中</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5">集群: {clusterCode}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">集群: {clusterCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode tabs */}
|
||||
<div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-lg p-0.5">
|
||||
<div className="flex items-center gap-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-0.5">
|
||||
<button
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'view' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-200'}`}
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'view' ? 'bg-[var(--dg-blue)]/20 text-[var(--dg-blue)]' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}
|
||||
onClick={() => handleModeChange('view')}
|
||||
>查看</button>
|
||||
<button
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'yaml' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-200'}`}
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'yaml' ? 'bg-[var(--dg-blue)]/20 text-[var(--dg-blue)]' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}
|
||||
onClick={() => handleModeChange('yaml')}
|
||||
>YAML</button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
{isSts && (
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
<Button variant="outline" size="sm" className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => { setScaleValue(replicas); setScaleModal(true); }}>
|
||||
<Boxes className="h-3.5 w-3.5 mr-1" />伸缩
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
<Button variant="outline" size="sm" className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
disabled={actionLoading} onClick={handleRestart}>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />{actionLoading ? '重启中...' : '重启'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
<Button variant="outline" size="sm" className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setYamlModal(true)}>
|
||||
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
|
||||
</Button>
|
||||
@@ -239,9 +239,9 @@ export default function WorkloadViewPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-500 py-20">加载中...</div>
|
||||
<div className="text-center text-[var(--dg-text-muted)] py-20">加载中...</div>
|
||||
) : mode === 'yaml' ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
@@ -263,45 +263,45 @@ export default function WorkloadViewPage() {
|
||||
<StatCard label="副本就绪" value={`${readyReplicas}/${replicas}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
|
||||
<StatCard label="当前副本" value={currentReplicas} color={STATUS_COLORS.blue} />
|
||||
<StatCard label="已更新副本" value={updatedReplicas} color={STATUS_COLORS.green} />
|
||||
<StatCard label="服务名" value={spec.serviceName || '-'} color="#94a3b8" />
|
||||
<StatCard label="Pod 数量" value={pods.length} color="#94a3b8" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="#94a3b8" />
|
||||
<StatCard label="服务名" value={spec.serviceName || '-'} color="var(--dg-text-secondary)" />
|
||||
<StatCard label="Pod 数量" value={pods.length} color="var(--dg-text-secondary)" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="var(--dg-text-secondary)" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StatCard label="期望调度" value={desiredScheduled} color={STATUS_COLORS.blue} />
|
||||
<StatCard label="当前调度" value={currentScheduled} color="#94a3b8" />
|
||||
<StatCard label="当前调度" value={currentScheduled} color="var(--dg-text-secondary)" />
|
||||
<StatCard label="就绪数量" value={numberReady} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} />
|
||||
<StatCard label="可用数量" value={numberAvailable} color={STATUS_COLORS.green} />
|
||||
<StatCard label="已更新" value={numberUpdated} color="#94a3b8" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="#94a3b8" />
|
||||
<StatCard label="已更新" value={numberUpdated} color="var(--dg-text-secondary)" />
|
||||
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="var(--dg-text-secondary)" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update Strategy + Selector */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-amber-400" />更新策略
|
||||
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-[var(--dg-orange)]" />更新策略
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">策略类型</span>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-300">{strategyType}</Badge>
|
||||
<span className="text-[var(--dg-text-muted)]">策略类型</span>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">{strategyType}</Badge>
|
||||
</div>
|
||||
{strategyType === 'RollingUpdate' && isSts && updateStrategy.rollingUpdate && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">分区 (partition)</span>
|
||||
<span className="text-slate-300 font-mono">{updateStrategy.rollingUpdate.partition ?? 0}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">分区 (partition)</span>
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">{updateStrategy.rollingUpdate.partition ?? 0}</span>
|
||||
</div>
|
||||
{updateStrategy.rollingUpdate.maxUnavailable && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">最大不可用</span>
|
||||
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">最大不可用</span>
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -310,52 +310,52 @@ export default function WorkloadViewPage() {
|
||||
<>
|
||||
{updateStrategy.rollingUpdate.maxUnavailable && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">最大不可用</span>
|
||||
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">最大不可用</span>
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
|
||||
</div>
|
||||
)}
|
||||
{updateStrategy.rollingUpdate.maxSurge && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">最大 surge</span>
|
||||
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxSurge === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxSurge) : updateStrategy.rollingUpdate.maxSurge}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">最大 surge</span>
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">{typeof updateStrategy.rollingUpdate.maxSurge === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxSurge) : updateStrategy.rollingUpdate.maxSurge}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSts && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Pod 管理策略</span>
|
||||
<span className="text-slate-300 font-mono">{spec.podManagementPolicy || 'OrderedReady'}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">Pod 管理策略</span>
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">{spec.podManagementPolicy || 'OrderedReady'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">修订历史限制</span>
|
||||
<span className="text-slate-300 font-mono">{spec.revisionHistoryLimit ?? 10}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">修订历史限制</span>
|
||||
<span className="text-[var(--dg-text-secondary)] font-mono">{spec.revisionHistoryLimit ?? 10}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-purple-400" />选择器与标签
|
||||
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-[var(--dg-purple)]" />选择器与标签
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3 text-xs">
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Selector (matchLabels)</p>
|
||||
<p className="text-[var(--dg-text-muted)] mb-1.5">Selector (matchLabels)</p>
|
||||
<LabelBadges labels={selectorLabels} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Labels</p>
|
||||
<p className="text-[var(--dg-text-muted)] mb-1.5">Labels</p>
|
||||
<LabelBadges labels={labels} />
|
||||
</div>
|
||||
{Object.keys(annotations).length > 0 && (
|
||||
<div>
|
||||
<p className="text-slate-500 mb-1.5">Annotations</p>
|
||||
<p className="text-[var(--dg-text-muted)] mb-1.5">Annotations</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(annotations).slice(0, 8).map(([k, _v]) => (
|
||||
<Badge key={k} variant="outline" className="border-slate-700 text-slate-500 text-[10px] font-mono max-w-[300px] truncate">
|
||||
<Badge key={k} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px] font-mono max-w-[300px] truncate">
|
||||
{k}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -368,20 +368,20 @@ export default function WorkloadViewPage() {
|
||||
|
||||
{/* VolumeClaimTemplates (StatefulSet only) */}
|
||||
{isSts && vcts.length > 0 && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-cyan-400" />存储卷声明模板 (VolumeClaimTemplates)
|
||||
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-[var(--dg-cyan)]" />存储卷声明模板 (VolumeClaimTemplates)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">访问模式</TableHead>
|
||||
<TableHead className="text-slate-400">存储类</TableHead>
|
||||
<TableHead className="text-slate-400">大小</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">访问模式</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">存储类</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">大小</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -390,11 +390,11 @@ export default function WorkloadViewPage() {
|
||||
const storageClass = vct.spec?.storageClassName || vct.spec?.storageClassName || '-';
|
||||
const size = vct.spec?.resources?.requests?.storage || '-';
|
||||
return (
|
||||
<TableRow key={idx} className="border-slate-800">
|
||||
<TableCell className="font-medium text-slate-200">{vct.metadata?.name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{accessModes || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{storageClass}</TableCell>
|
||||
<TableCell className="text-sm text-slate-300 font-mono">{size}</TableCell>
|
||||
<TableRow key={idx} className="border-[var(--dg-border)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{vct.metadata?.name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{accessModes || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{storageClass}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{size}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -405,16 +405,16 @@ export default function WorkloadViewPage() {
|
||||
)}
|
||||
|
||||
{/* Container template */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-blue-400" />容器模板(Pod Template)
|
||||
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-[var(--dg-blue)]" />容器模板(Pod Template)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{initContainers.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-[11px] text-slate-500 mb-2 uppercase tracking-wide">Init Containers</p>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2 uppercase tracking-wide">Init Containers</p>
|
||||
<div className="space-y-2">
|
||||
{initContainers.map((c: JsonData, idx: number) => (
|
||||
<ContainerInfo key={`init-${idx}`} c={c} />
|
||||
@@ -423,10 +423,10 @@ export default function WorkloadViewPage() {
|
||||
</div>
|
||||
)}
|
||||
{containers.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 py-3">暂无容器数据,请查看 YAML</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-3">暂无容器数据,请查看 YAML</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-slate-500 mb-1 uppercase tracking-wide">Containers</p>
|
||||
<p className="text-[11px] text-[var(--dg-text-muted)] mb-1 uppercase tracking-wide">Containers</p>
|
||||
{containers.map((c: JsonData, idx: number) => (
|
||||
<ContainerInfo key={idx} c={c} />
|
||||
))}
|
||||
@@ -436,45 +436,45 @@ export default function WorkloadViewPage() {
|
||||
</Card>
|
||||
|
||||
{/* Pod list */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-emerald-400" />Pod 实例 ({pods.length})
|
||||
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-green)]" />Pod 实例 ({pods.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">所在节点</TableHead>
|
||||
<TableHead className="text-slate-400">Pod IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">Pod 名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">所在节点</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">Pod IP</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">重启</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-[var(--dg-text-muted)] py-8">暂无 Pod</TableCell></TableRow>
|
||||
) : pods.map((pod: JsonData) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableRow key={pod.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/30">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
|
||||
{pod.status || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{pod.node_name || pod.node || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400 font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
|
||||
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{pod.node_name || pod.node || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
|
||||
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-[var(--dg-orange)]' : 'text-[var(--dg-text-muted)]'}>{pod.restart_count || 0}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-green)] hover:text-[var(--dg-green)] hover:bg-[var(--dg-green)]/10"
|
||||
onClick={() => setTerminalPod(pod)}>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />终端
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-[var(--dg-orange)] hover:text-[var(--dg-orange)] hover:bg-[var(--dg-orange)]/10"
|
||||
onClick={() => setFileBrowserPod(pod)}>
|
||||
<HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器
|
||||
</Button>
|
||||
@@ -492,20 +492,20 @@ export default function WorkloadViewPage() {
|
||||
{/* Scale modal */}
|
||||
{scaleModal && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setScaleModal(false)}>
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6 w-80" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-slate-200 mb-4">伸缩 StatefulSet</h3>
|
||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg p-6 w-80" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-4">伸缩 StatefulSet</h3>
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(Math.max(0, scaleValue - 1))}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-2xl font-bold text-slate-100 w-12 text-center">{scaleValue}</span>
|
||||
<span className="text-2xl font-bold text-[var(--dg-text-primary)] w-12 text-center">{scaleValue}</span>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(scaleValue + 1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1 border-slate-700 text-slate-400" onClick={() => setScaleModal(false)}>取消</Button>
|
||||
<Button size="sm" className="flex-1 bg-blue-600 hover:bg-blue-500" disabled={actionLoading} onClick={handleScale}>
|
||||
<Button variant="outline" size="sm" className="flex-1 border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setScaleModal(false)}>取消</Button>
|
||||
<Button size="sm" className="flex-1 bg-[var(--dg-blue)] hover:opacity-90" disabled={actionLoading} onClick={handleScale}>
|
||||
{actionLoading ? '执行中...' : '确认'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -554,23 +554,23 @@ export default function WorkloadViewPage() {
|
||||
// Container info sub-component
|
||||
function ContainerInfo({ c }: { c: JsonData}) {
|
||||
return (
|
||||
<div className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
|
||||
<div className="border border-[var(--dg-border)] rounded-lg p-3 bg-[var(--dg-bg-primary)]/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-slate-200">{c.name}</span>
|
||||
<Container className="h-4 w-4 text-[var(--dg-blue)]" />
|
||||
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{c.name}</span>
|
||||
</div>
|
||||
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
|
||||
<code className="text-[11px] text-[var(--dg-text-secondary)] font-mono truncate max-w-[400px]">{c.image}</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-slate-400">
|
||||
CPU: <span className="text-slate-200 font-mono">{c.resources?.requests?.cpu || '-'}/{c.resources?.limits?.cpu || '-'}</span>
|
||||
<div className="text-[var(--dg-text-secondary)]">
|
||||
CPU: <span className="text-[var(--dg-text-primary)] font-mono">{c.resources?.requests?.cpu || '-'}/{c.resources?.limits?.cpu || '-'}</span>
|
||||
</div>
|
||||
<div className="text-slate-400">
|
||||
内存: <span className="text-slate-200 font-mono">{c.resources?.requests?.memory || '-'}/{c.resources?.limits?.memory || '-'}</span>
|
||||
<div className="text-[var(--dg-text-secondary)]">
|
||||
内存: <span className="text-[var(--dg-text-primary)] font-mono">{c.resources?.requests?.memory || '-'}/{c.resources?.limits?.memory || '-'}</span>
|
||||
</div>
|
||||
<div className="text-slate-400">
|
||||
端口: <span className="text-slate-200 font-mono">
|
||||
<div className="text-[var(--dg-text-secondary)]">
|
||||
端口: <span className="text-[var(--dg-text-primary)] font-mono">
|
||||
{(c.ports || []).map((p: JsonData) => p.containerPort || p.port).join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@ import { AgentWizard } from '@/components/agent/AgentWizard';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ═══════════ Tab 配置 ═══════════
|
||||
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
|
||||
@@ -56,7 +57,7 @@ const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
// ═══════════ Agent 主机监控表格 ═══════════
|
||||
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; }
|
||||
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; has_auth?: boolean; }
|
||||
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
||||
|
||||
const AgentTab: React.FC = () => {
|
||||
@@ -64,6 +65,7 @@ const AgentTab: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [sf, setSf] = useState<SortField>('cpu_usage');
|
||||
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -76,6 +78,8 @@ const AgentTab: React.FC = () => {
|
||||
const [batchMode, setBatchMode] = useState<'install' | 'upgrade'>('install');
|
||||
const [batchAck, setBatchAck] = useState(false);
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
// 批量安装临时凭据(用于未保存凭据的主机)// Logan.Li
|
||||
const [batchCreds, setBatchCreds] = useState<{ username: string; password: string; sshKey: string; authType: 'password' | 'key' }>({ username: 'root', password: '', sshKey: '', authType: 'password' });
|
||||
// 正在执行操作的主机 ID 集合(版本列 loading)
|
||||
const [operating, setOperating] = useState<Set<number>>(new Set());
|
||||
// 批量卸载
|
||||
@@ -94,7 +98,28 @@ const AgentTab: React.FC = () => {
|
||||
finally { setBatchUninstalling(false); }
|
||||
};
|
||||
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
|
||||
const fetch = async () => { try { setHosts(asList(await agentApi.getLatest()) as typeof hosts); } catch {} finally { setLoading(false); } };
|
||||
const fetch = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const list = asList(await agentApi.getLatest()) as typeof hosts;
|
||||
setHosts(list);
|
||||
// 操作中的主机一旦状态变为 online 即可移除 loading(部署中)状态 // Logan.Li
|
||||
setOperating(prev => {
|
||||
const next = new Set(prev);
|
||||
for (const h of list) {
|
||||
if (next.has(h.server_id) && h.agent_status === 'online') {
|
||||
next.delete(h.server_id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast.error('刷新 Agent 主机列表失败:' + (e?.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
|
||||
const toggle = (f: SortField) => { if (sf === f) setSd(d => d === 'asc' ? 'desc' : 'asc'); else { setSf(f); setSd('desc'); } };
|
||||
const sorted = [...hosts].sort((a, b) => {
|
||||
@@ -143,7 +168,20 @@ const AgentTab: React.FC = () => {
|
||||
// 安装:只安装无 Agent 的
|
||||
const installIds = needInstall.map(h => h.server_id);
|
||||
if (installIds.length === 0) { toast.warning('所选主机均已安装 Agent,无需安装'); return; }
|
||||
const res = await serverApi.batchInstallAgent(installIds, token);
|
||||
// 为未保存凭据的主机构造临时凭据 map;后端遇到空字段不会覆盖已有凭据 // Logan.Li
|
||||
const credentials: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }> = {};
|
||||
const needCreds = batchCreds.username && (batchCreds.authType === 'key' ? batchCreds.sshKey : batchCreds.password);
|
||||
if (needCreds) {
|
||||
for (const sid of installIds) {
|
||||
credentials[sid] = {
|
||||
username: batchCreds.username,
|
||||
auth_type: batchCreds.authType,
|
||||
password: batchCreds.authType === 'password' ? batchCreds.password : undefined,
|
||||
ssh_key: batchCreds.authType === 'key' ? batchCreds.sshKey : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const res = await serverApi.batchInstallAgent(installIds, token, Object.keys(credentials).length ? credentials : undefined);
|
||||
toast.success(`已提交:批量安装 ${res?.total ?? installIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||||
setSelected(new Set());
|
||||
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
|
||||
@@ -171,7 +209,7 @@ const AgentTab: React.FC = () => {
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
|
||||
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
|
||||
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as Record<string, any>).has_auth } : null);
|
||||
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: picked.has_auth } : null);
|
||||
setWizardOpen(true);
|
||||
}}>
|
||||
<Rocket className="w-3 h-3" />安装 Agent
|
||||
@@ -186,15 +224,18 @@ const AgentTab: React.FC = () => {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch} disabled={refreshing}>
|
||||
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
|
||||
onInstalled={() => { toast.success('Agent 安装成功'); setWizardOpen(false); fetch(); }}
|
||||
onFailed={() => toast.error('安装失败')} />
|
||||
onStart={(id) => { setOperating(prev => new Set(prev).add(id)); }}
|
||||
onInstalled={(id) => { toast.success('Agent 安装任务已提交,后台执行中…'); setWizardOpen(false); fetch(); }}
|
||||
onFailed={(id) => { toast.error('安装失败'); setOperating(prev => { const next = new Set(prev); next.delete(id); return next; }); }} />
|
||||
|
||||
{/* ────────── 批量部署 Agent 对话框 ────────── */}
|
||||
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); } }}>
|
||||
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); setBatchCreds({ username: 'root', password: '', sshKey: '', authType: 'password' }); } }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto bg-slate-900 border-slate-700 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
@@ -259,6 +300,45 @@ const AgentTab: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 安装凭据(安装模式且存在未保存凭据的主机时显示)// Logan.Li */}
|
||||
{batchMode === 'install' && needInstall.some(h => !h.has_auth) && (
|
||||
<div className="rounded-md border border-slate-700 bg-slate-800/50 p-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<span className="text-xs text-slate-300">以下主机未保存 SSH 凭据,请提供临时凭据用于批量安装(仅本次使用):</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-400 mb-1 block">认证方式</Label>
|
||||
<Select value={batchCreds.authType} onValueChange={(v: 'password' | 'key') => setBatchCreds(c => ({ ...c, authType: v }))}>
|
||||
<SelectTrigger className="h-8 text-xs bg-slate-900 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-900 border-slate-700 text-slate-200">
|
||||
<SelectItem value="password">密码</SelectItem>
|
||||
<SelectItem value="key">SSH 私钥</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-400 mb-1 block">用户名</Label>
|
||||
<Input value={batchCreds.username} onChange={e => setBatchCreds(c => ({ ...c, username: e.target.value }))} placeholder="root" className="h-8 text-xs bg-slate-900 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
{batchCreds.authType === 'password' ? (
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-400 mb-1 block">密码</Label>
|
||||
<Input type="password" value={batchCreds.password} onChange={e => setBatchCreds(c => ({ ...c, password: e.target.value }))} placeholder="输入 SSH 密码" className="h-8 text-xs bg-slate-900 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label className="text-[11px] text-slate-400 mb-1 block">SSH 私钥</Label>
|
||||
<textarea value={batchCreds.sshKey} onChange={e => setBatchCreds(c => ({ ...c, sshKey: e.target.value }))} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" className="w-full h-24 rounded-md border border-slate-700 bg-slate-900 text-slate-200 text-xs p-2 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ⚠️ 警告文案 — 参考阿里云批量安装/升级风格 */}
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -429,9 +509,20 @@ const DashboardTab: React.FC = () => {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [nd, setNd] = useState({ name: '', description: '', datasource: 'prometheus' as const, datasourceUrl: '' });
|
||||
const [np, setNp] = useState({ title: '', type: 'graph' as const, query: '', width: 6, height: 4 });
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetch = async () => {
|
||||
try { const res = await dashboardApi.getDashboards(); const list = res.data || []; setDashboards(list); if (list.length > 0 && !active) setActive(list[0]); } catch {}
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const res = await dashboardApi.getDashboards();
|
||||
const list = res.data || [];
|
||||
setDashboards(list);
|
||||
if (list.length > 0 && !active) setActive(list[0]);
|
||||
} catch (e: any) {
|
||||
toast.error('刷新自定义仪表盘失败:' + (e?.message || '未知错误'));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { fetch(); }, []);
|
||||
|
||||
@@ -455,7 +546,9 @@ const DashboardTab: React.FC = () => {
|
||||
<SelectTrigger className="w-[240px] h-8 text-xs"><SelectValue placeholder="选择仪表盘" /></SelectTrigger>
|
||||
<SelectContent>{dashboards.map(d => <SelectItem key={d.id} value={d.id}>{d.name}{d.isDefault ? ' (默认)' : ''}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch} disabled={refreshing}>
|
||||
<RefreshCw className={cn('w-3 h-3 mr-1', refreshing && 'animate-spin')} />刷新
|
||||
</Button>
|
||||
{active && <Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setAddOpen(true)}><Plus className="w-3 h-3 mr-1" />添加面板</Button>}
|
||||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setCreateOpen(true)}><Plus className="w-3 h-3 mr-1" />新建</Button>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,9 @@ const DEFAULT_TIME_RANGE: TimeRange = {
|
||||
|
||||
// 引擎默认查询语句 // Logan.Li
|
||||
const DEFAULT_QUERIES: Record<'loki' | 'elasticsearch', string> = {
|
||||
loki: '{app="cex-admin-api"} |= ""',
|
||||
elasticsearch: 'app:cex-admin-api',
|
||||
// 默认查询语句:Loki 用 LogQL / ES 用 Lucene,对齐平台 admin-api 服务 // Logan.Li
|
||||
loki: '{app="admin-api"} |= "INFO"',
|
||||
elasticsearch: 'app:admin-api',
|
||||
};
|
||||
|
||||
// 引擎徽标:左侧小圆点 + 语言标签 // Logan.Li
|
||||
@@ -51,7 +52,7 @@ const ALL_TEMPLATES: Record<'loki' | 'elasticsearch', { label: string; query: st
|
||||
{ label: '高延迟筛选', query: '{app="cex-admin-api"} | json | response_time > 500' },
|
||||
{ label: '异常堆栈', query: '{app="cex-admin-api"} |= "Exception"' },
|
||||
{ label: '日志趋势', query: 'sum(count_over_time({app="cex-admin-api"}[1h]))' },
|
||||
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>\\w+) (?P<path>\\S+)"' },
|
||||
{ label: '正则提取', query: '{app="cex-admin-api"} | regexp "(?P<method>[A-Za-z0-9_]+) (?P<path>[^ ]+)"' }, // LogQL regexp 用 RE2,不支持 \w \S 等 Perl 简写,须用显式字符类,否则 Loki 返回 400 invalid char escape // Logan.Li
|
||||
{ label: '格式输出', query: '{app="cex-admin-api"} | json | line_format "{{.level}} {{.message}}"' },
|
||||
],
|
||||
elasticsearch: [
|
||||
@@ -151,8 +152,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
const [dsId, setDsId] = useState(String(sources[0]?.id || ''));
|
||||
// ES 查询允许在面板内临时覆盖索引(默认取自数据源配置)// Logan.Li
|
||||
const [esIndex, setEsIndex] = useState('');
|
||||
// API 单次查询返回条数上限(后端限制 500),Loki 用 limit / ES 用 size // Logan.Li
|
||||
const [queryLimit, setQueryLimit] = useState(200);
|
||||
// API 单次查询返回条数上限(后端限制 1000),Loki 用 limit / ES 用 size // Logan.Li
|
||||
const [queryLimit, setQueryLimit] = useState(1000);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(DEFAULT_TIME_RANGE);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
@@ -195,7 +196,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
|
||||
// 执行查询 — 接入真实 API(无 mock)// Logan.Li
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!currentSource) return;
|
||||
// 空查询兜底:后端 Query 字段 required,空值会 400,这里提前拦截 // Logan.Li
|
||||
if (!currentSource || !query.trim()) return;
|
||||
const dsIdNum = currentSource.id;
|
||||
setLoading(true);
|
||||
const t0 = performance.now();
|
||||
@@ -280,7 +282,7 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<span className="text-[11px] font-semibold" style={{ color: badge.dotColor }}>{badge.label}</span>
|
||||
</div>
|
||||
<SyntaxHighlightInput value={query} onChange={setQuery} engine={effEngine} placeholder={meta.queryPlaceholder} />
|
||||
<button onClick={handleRun} disabled={loading || !currentSource} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90" style={{ background: '#22c55e' }}>
|
||||
<button onClick={handleRun} disabled={loading || !currentSource || !query.trim()} className="h-10 px-4 rounded text-xs font-medium text-white flex items-center gap-1.5 flex-shrink-0 transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed" style={{ background: '#22c55e' }}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />} 查询
|
||||
</button>
|
||||
</div>
|
||||
@@ -310,11 +312,11 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
max={1000}
|
||||
value={queryLimit}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
setQueryLimit(Number.isNaN(v) ? 200 : Math.min(500, Math.max(1, v)));
|
||||
setQueryLimit(Number.isNaN(v) ? 1000 : Math.min(1000, Math.max(1, v)));
|
||||
}}
|
||||
className="w-14 h-6 text-xs text-center rounded outline-none"
|
||||
style={{ background: 'var(--dg-bg-secondary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)' }}
|
||||
@@ -328,8 +330,8 @@ export function UnifiedQueryPanel({ engine, onRuleCreated }: Props) {
|
||||
{saveMenuOpen && (<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setSaveMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-xl overflow-hidden" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
|
||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 另存为查询</button>
|
||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 另存为告警</button>
|
||||
<button onClick={() => handleSaveMenu('query')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)' }}><Save className="h-3.5 w-3.5" /> 查询</button>
|
||||
<button onClick={() => handleSaveMenu('alert')} className="w-full text-left px-4 py-2 text-xs flex items-center gap-2 hover:opacity-80" style={{ color: 'var(--dg-text-secondary)', borderTop: '1px solid var(--dg-border)' }}><Bell className="h-3.5 w-3.5" /> 告警</button>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
@@ -185,6 +185,8 @@ export function TerminalDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
// 禁用 Radix Dialog 默认的 ESC 关闭:终端内 vi/top 等会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
// 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画
|
||||
|
||||
@@ -1,40 +1,82 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
// ReplayDialog.tsx - 终端录制回放(暂停/时间轴/倍速/命令书签) // Logan.Li
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { terminalSessionApi } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Loader2, Play, Pause, RotateCcw, SkipBack, SkipForward,
|
||||
ChevronsLeft, ChevronsRight, Download, FileText,
|
||||
} from 'lucide-react';
|
||||
import { exportAuditReportCsv, exportAuditReportJson } from './replay/export-audit';
|
||||
import { terminalSessionApi, sessionCommandApi } from '@/api';
|
||||
import { unwrapList } from '@/hooks/api/unwrap';
|
||||
import { formatSize } from '@/pages/audit/terminal-audit/helpers';
|
||||
import { ReplayEngine, type SpeedOption } from './replay/replay-engine';
|
||||
import {
|
||||
parseAsciicast, formatReplayTime, buildCommandBookmarks,
|
||||
type CommandBookmark, type ReplaySessionMeta, type AsciicastRow,
|
||||
} from './replay/replay-utils';
|
||||
import { ReplayCommandSidebar } from './replay/ReplayCommandSidebar';
|
||||
|
||||
interface ReplayDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: number | null;
|
||||
/** 打开后自动跳转的时间点(秒),用于命令 Tab 联动 */ // Logan.Li
|
||||
initialSeekSec?: number;
|
||||
}
|
||||
|
||||
interface AsciicastRow {
|
||||
time: number;
|
||||
eventType: string;
|
||||
data: string;
|
||||
}
|
||||
const SPEEDS: SpeedOption[] = [0.5, 1, 2, 4, 8];
|
||||
const SKIP_SEC = 10;
|
||||
|
||||
/**
|
||||
* 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible)
|
||||
*/
|
||||
export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) {
|
||||
export function ReplayDialog({ open, onOpenChange, sessionId, initialSeekSec }: ReplayDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const engineRef = useRef<ReplayEngine | null>(null);
|
||||
const rowsRef = useRef<AsciicastRow[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [meta, setMeta] = useState<ReplaySessionMeta | null>(null);
|
||||
const [commands, setCommands] = useState<CommandBookmark[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [totalTime, setTotalTime] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<SpeedOption>(1);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
const [legacyMode, setLegacyMode] = useState(false);
|
||||
|
||||
const displayTime = scrubTime ?? currentTime;
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const eng = engineRef.current;
|
||||
if (!eng || legacyMode) return;
|
||||
if (eng.isPlaying()) {
|
||||
eng.pause();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
eng.play();
|
||||
setPlaying(true);
|
||||
}
|
||||
}, [legacyMode]);
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
const eng = engineRef.current;
|
||||
if (!eng || legacyMode) return;
|
||||
eng.seekTo(time);
|
||||
setCurrentTime(eng.getCurrentTime());
|
||||
setScrubTime(null);
|
||||
setPlaying(false);
|
||||
}, [legacyMode]);
|
||||
|
||||
// 加载录制 + 会话元信息 + 命令书签 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
|
||||
@@ -56,133 +98,267 @@ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProp
|
||||
term.loadAddon(fit);
|
||||
termRef.current = term;
|
||||
|
||||
const engine = new ReplayEngine({
|
||||
onTimeUpdate: (time) => {
|
||||
setCurrentTime(time);
|
||||
setScrubTime(null);
|
||||
},
|
||||
onEnded: () => setPlaying(false),
|
||||
});
|
||||
engineRef.current = engine;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setProgress(0);
|
||||
setMeta(null);
|
||||
setCommands([]);
|
||||
setCurrentTime(0);
|
||||
setTotalTime(0);
|
||||
setPlaying(false);
|
||||
setSpeed(1);
|
||||
setLegacyMode(false);
|
||||
setScrubTime(null);
|
||||
|
||||
terminalSessionApi
|
||||
.getRecord(sessionId)
|
||||
.then((raw: any) => {
|
||||
// 录制与元信息必须成功;命令书签失败不阻断回放 // Logan.Li
|
||||
Promise.all([
|
||||
terminalSessionApi.getRecord(sessionId),
|
||||
terminalSessionApi.get(sessionId),
|
||||
])
|
||||
.then(async ([raw, sessRaw]) => {
|
||||
if (cancelled) return;
|
||||
const data = typeof raw === 'string' ? raw : (raw?.data || raw?.record_data || '');
|
||||
|
||||
if (!data || data.trim() === '') {
|
||||
setError('No recording data available for this session');
|
||||
const data = typeof raw === 'string' ? raw : String((raw as { data?: string })?.data || '');
|
||||
if (!data.trim()) {
|
||||
setError(t('audit.terminal.replayPlayer.noRecord'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sess = (sessRaw || {}) as ReplaySessionMeta;
|
||||
setMeta({ ...sess, id: sessionId });
|
||||
|
||||
let cmdList: { id: number; command: string; timestamp?: string; created_at?: string }[] = [];
|
||||
try {
|
||||
const cmdRaw = await sessionCommandApi.list({ session_id: sessionId, page: 1, page_size: 500 });
|
||||
cmdList = unwrapList(cmdRaw);
|
||||
} catch {
|
||||
/* 命令表未就绪时仍可回放录制 */
|
||||
}
|
||||
const rows = parseAsciicast(data);
|
||||
rowsRef.current = rows;
|
||||
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
try { fit.fit(); } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// Parse asciicast v2 format
|
||||
const lines = data.split('\n').filter((l: string) => l.trim());
|
||||
const rows: AsciicastRow[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
// asciicast v2 row: [time, "o", "data"]
|
||||
rows.push({ time: parsed[0], eventType: parsed[1], data: parsed[2] });
|
||||
}
|
||||
// Header is skipped (JSON object, not array)
|
||||
} catch {
|
||||
// Skip lines that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Fallback: just write raw data (old format)
|
||||
term.write(data);
|
||||
setProgress(100);
|
||||
setLegacyMode(true);
|
||||
setTotalTime(0);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Timed playback
|
||||
const total = rows[rows.length - 1].time;
|
||||
setTotalTime(total);
|
||||
setCommands(buildCommandBookmarks(cmdList, sess.start_time, total));
|
||||
engine.load(term, rows);
|
||||
|
||||
const seek = initialSeekSec != null && initialSeekSec > 0 ? initialSeekSec : 0;
|
||||
engine.seekTo(seek, false);
|
||||
setCurrentTime(seek);
|
||||
|
||||
// 二次审计:记录回放查看 // Logan.Li
|
||||
terminalSessionApi.logReplayView(sessionId).catch(() => {});
|
||||
|
||||
setLoading(false);
|
||||
const totalTime = rows[rows.length - 1].time;
|
||||
let rowIndex = 0;
|
||||
let lastTime = 0;
|
||||
|
||||
const playNext = () => {
|
||||
if (cancelled || rowIndex >= rows.length) {
|
||||
setProgress(100);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = rows[rowIndex];
|
||||
const delay = (row.time - lastTime) * 1000; // convert to ms
|
||||
lastTime = row.time;
|
||||
rowIndex++;
|
||||
|
||||
// Clamp delay to avoid extremely long waits (max 2s between chunks)
|
||||
const clampedDelay = Math.min(delay, 2000);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
term.write(row.data);
|
||||
setProgress(Math.round((row.time / totalTime) * 100));
|
||||
playNext();
|
||||
}, clampedDelay);
|
||||
};
|
||||
|
||||
playNext();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
console.error('[Replay] Load failed:', err);
|
||||
setError('Failed to load recording data');
|
||||
setError(t('audit.terminal.replayPlayer.loadFailed'));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
engine.dispose();
|
||||
engineRef.current = null;
|
||||
try { term.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
};
|
||||
}, [open, sessionId]);
|
||||
}, [open, sessionId, initialSeekSec, t]);
|
||||
|
||||
// 倍速变更 // Logan.Li
|
||||
useEffect(() => {
|
||||
engineRef.current?.setSpeed(speed);
|
||||
}, [speed]);
|
||||
|
||||
// 空格键播放/暂停 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space' && (e.target as HTMLElement)?.tagName !== 'INPUT') {
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
if (e.code === 'ArrowLeft') seekTo(Math.max(0, (engineRef.current?.getCurrentTime() || 0) - 5));
|
||||
if (e.code === 'ArrowRight') seekTo(Math.min(totalTime, (engineRef.current?.getCurrentTime() || 0) + 5));
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, togglePlay, seekTo, totalTime]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (sessionId) terminalSessionApi.download(sessionId).catch(() => {});
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (meta) exportAuditReportCsv(meta, commands);
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (meta) exportAuditReportJson(meta, commands);
|
||||
};
|
||||
|
||||
const sliderMax = Math.max(totalTime, 0.01);
|
||||
const markerPercents = commands.map((c) => ({
|
||||
id: c.id,
|
||||
left: (c.offsetSec / sliderMax) * 100,
|
||||
highRisk: c.highRisk,
|
||||
command: c.command,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} 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 flex items-center gap-2">
|
||||
Terminal Replay #{sessionId ?? ''}
|
||||
{progress > 0 && progress < 100 && (
|
||||
<span className="text-xs text-slate-500 font-normal ml-2">{progress}%</span>
|
||||
)}
|
||||
<DialogContent
|
||||
showOverlay={false}
|
||||
className="bg-slate-900 border-slate-800 p-0 gap-0 flex flex-col"
|
||||
style={{ maxWidth: '1100px', width: '92vw' }}
|
||||
>
|
||||
<DialogHeader className="px-4 py-3 border-b border-slate-800 shrink-0">
|
||||
<DialogTitle className="text-slate-100 text-base">
|
||||
{t('audit.terminal.replayPlayer.title', { id: sessionId ?? '' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 sr-only">Replay recorded terminal output</DialogDescription>
|
||||
{meta && (
|
||||
<div className="text-xs text-slate-500 mt-1 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
<span>{meta.username} → {meta.server_name} ({meta.server_ip}:{meta.server_port})</span>
|
||||
{meta.client_ip && <span>{t('audit.terminal.replayPlayer.clientIp')}: {meta.client_ip}</span>}
|
||||
{meta.start_time && <span>{new Date(meta.start_time).toLocaleString()}</span>}
|
||||
{meta.record_size > 0 && <span>{formatSize(meta.record_size)}</span>}
|
||||
</div>
|
||||
)}
|
||||
<DialogDescription className="sr-only">{t('audit.terminal.replayPlayer.desc')}</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 className="flex min-h-0 flex-1" style={{ height: '480px' }}>
|
||||
<div className="flex-1 min-w-0 flex flex-col relative bg-[#0a0e14]">
|
||||
{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>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
|
||||
|
||||
{!legacyMode && totalTime > 0 && (
|
||||
<div className="shrink-0 border-t border-slate-800 bg-slate-900/95 px-3 py-2.5 space-y-2">
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => seekTo(0)} title={t('audit.terminal.replayPlayer.toStart')}>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => engineRef.current?.skip(-SKIP_SEC)}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-400" onClick={togglePlay}>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => engineRef.current?.skip(SKIP_SEC)}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => seekTo(totalTime)} title={t('audit.terminal.replayPlayer.toEnd')}>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => { seekTo(0); engineRef.current?.play(); setPlaying(true); }} title={t('audit.terminal.replayPlayer.restart')}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-5 bg-slate-700 mx-1" />
|
||||
|
||||
{SPEEDS.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 px-2 text-xs ${speed === s ? 'text-blue-400 bg-blue-500/10' : 'text-slate-500'}`}
|
||||
onClick={() => setSpeed(s)}
|
||||
>
|
||||
{s}x
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<span className="text-xs text-slate-400 font-mono tabular-nums">
|
||||
{formatReplayTime(displayTime)} / {formatReplayTime(totalTime)}
|
||||
</span>
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={handleExportCsv} disabled={!meta} title={t('audit.terminal.exportReportCsv')}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-slate-500" onClick={handleExportJson} disabled={!meta}>JSON</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={handleDownload} title={t('audit.terminal.downloadRecord')}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 时间轴 + 命令书签标记 */}
|
||||
<div className="relative px-1 pt-2">
|
||||
{markerPercents.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`absolute w-1 h-3 rounded-full -top-0.5 z-10 hover:scale-125 transition-transform ${m.highRisk ? 'bg-red-400' : 'bg-amber-400/80'}`}
|
||||
style={{ left: `calc(${m.left}% - 2px)` }}
|
||||
title={m.command}
|
||||
onClick={() => seekTo(commands.find((c) => c.id === m.id)?.offsetSec || 0)}
|
||||
/>
|
||||
))}
|
||||
<Slider
|
||||
value={[displayTime]}
|
||||
min={0}
|
||||
max={sliderMax}
|
||||
step={0.1}
|
||||
onValueChange={(v) => setScrubTime(v[0])}
|
||||
onValueCommit={(v) => seekTo(v[0])}
|
||||
className="[&_[data-slot=slider-track]]:bg-slate-700 [&_[data-slot=slider-range]]:bg-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{legacyMode && (
|
||||
<div className="shrink-0 border-t border-slate-800 px-3 py-2 text-xs text-slate-500">
|
||||
{t('audit.terminal.replayPlayer.legacyFormat')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!legacyMode && commands.length > 0 && (
|
||||
<ReplayCommandSidebar
|
||||
commands={commands}
|
||||
activeOffset={currentTime}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{progress > 0 && progress < 100 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-slate-800 z-10">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="absolute inset-0 p-1" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,12 +110,12 @@ export function SystemManager() {
|
||||
|
||||
const getUserStatusBadge = (status: 'active' | 'inactive') => {
|
||||
return status === 'active' ? (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-green)]/30 text-[var(--dg-green)] bg-[var(--dg-green)]/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
禁用
|
||||
</Badge>
|
||||
@@ -236,23 +236,23 @@ export function SystemManager() {
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">系统管理</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">用户、权限与系统配置</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">系统管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">用户、权限与系统配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-slate-700">
|
||||
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<TabsTrigger value="users" className="data-[state=active]:bg-[var(--dg-bg-hover)]">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
用户管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roles" className="data-[state=active]:bg-slate-700">
|
||||
<TabsTrigger value="roles" className="data-[state=active]:bg-[var(--dg-bg-hover)]">
|
||||
<Shield 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-[var(--dg-bg-hover)]">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
系统设置
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -118,29 +118,29 @@ export function SystemUserTab({
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input
|
||||
placeholder="搜索用户..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)]/50 border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPermission('system:user') && (
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]" onClick={() => setImportDialogOpen('csv')}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('system:user') && (
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]" onClick={() => setImportDialogOpen('ldap')}>
|
||||
<Network className="h-4 w-4 mr-2" />
|
||||
LDAP 导入
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('system:user') && (
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建用户
|
||||
</Button>
|
||||
@@ -149,58 +149,58 @@ export function SystemUserTab({
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
<div className="border-t border-[var(--dg-border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<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">状态</TableHead>
|
||||
<TableHead className="text-slate-400">最后登录</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">用户信息</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">联系方式</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">角色</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">来源</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">最后登录</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 border-2 border-[var(--dg-border)] border-t-[var(--dg-blue)] rounded-full animate-spin" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} className="text-center py-12 text-sm text-slate-500">
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={7} className="text-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableRow key={user.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--dg-blue)] flex items-center justify-center text-white text-sm font-medium">
|
||||
{(user.nickname || user.username || '?').charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{user.nickname || user.username}</p>
|
||||
<p className="text-xs text-slate-500">@{user.username}</p>
|
||||
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{user.nickname || user.username}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1 text-sm text-[var(--dg-text-secondary)]">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{user.email || '--'}
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-1 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1 text-sm text-[var(--dg-text-secondary)]">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{user.phone}
|
||||
</div>
|
||||
@@ -210,7 +210,7 @@ export function SystemUserTab({
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(user.roles || []).map((role: string) => (
|
||||
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
|
||||
<Badge key={role} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -218,18 +218,18 @@ export function SystemUserTab({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.authSource === 'ldap' ? (
|
||||
<Badge variant="outline" className="border-cyan-500/30 text-cyan-400 bg-cyan-500/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-cyan)]/30 text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10">
|
||||
LDAP
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-600/30 text-slate-400 bg-slate-600/10">
|
||||
<Badge variant="outline" className="border-[var(--dg-text-muted)]/30 text-[var(--dg-text-muted)] bg-[var(--dg-text-muted)]/10">
|
||||
本地
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -238,7 +238,7 @@ export function SystemUserTab({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -247,7 +247,7 @@ export function SystemUserTab({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => openEdit(user)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
@@ -257,7 +257,7 @@ export function SystemUserTab({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-red)]"
|
||||
onClick={() => handleDelete(user)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -39,7 +39,8 @@ function formatSize(bytes: number): string {
|
||||
*/
|
||||
export function TerminalSessionsTab() {
|
||||
const confirm = useConfirm();
|
||||
const { data: sessions = [], isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
||||
const { data, isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
|
||||
const sessions = data?.list ?? [];
|
||||
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// ReplayCommandSidebar.tsx - 回放命令书签侧栏 // Logan.Li
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CommandBookmark } from './replay-utils';
|
||||
|
||||
interface Props {
|
||||
commands: CommandBookmark[];
|
||||
activeOffset?: number;
|
||||
onSeek: (offsetSec: number) => void;
|
||||
}
|
||||
|
||||
export function ReplayCommandSidebar({ commands, activeOffset, onSeek }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [kw, setKw] = useState('');
|
||||
|
||||
const filtered = commands.filter((c) => {
|
||||
const q = kw.toLowerCase();
|
||||
return !q || c.command.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-56 shrink-0 border-l border-slate-800 flex flex-col bg-slate-900/80">
|
||||
<div className="px-2.5 py-2 border-b border-slate-800">
|
||||
<div className="text-xs font-medium text-slate-300 mb-1.5">
|
||||
{t('audit.terminal.replayPlayer.commands')} ({commands.length})
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-500" />
|
||||
<Input
|
||||
value={kw}
|
||||
onChange={(e) => setKw(e.target.value)}
|
||||
placeholder={t('audit.terminal.replayPlayer.searchCommand')}
|
||||
className="h-7 pl-7 text-xs bg-slate-800 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-3 text-xs text-slate-500 text-center">{t('audit.terminal.replayPlayer.noCommands')}</div>
|
||||
) : (
|
||||
<div className="p-1 space-y-0.5">
|
||||
{filtered.map((c) => {
|
||||
const active = activeOffset != null && Math.abs(activeOffset - c.offsetSec) < 2;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onSeek(c.offsetSec)}
|
||||
className={cn(
|
||||
'w-full text-left rounded px-2 py-1.5 transition-colors',
|
||||
active ? 'bg-blue-500/15 border border-blue-500/30' : 'hover:bg-slate-800/60 border border-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-500 font-mono shrink-0">{c.label}</span>
|
||||
{c.highRisk && <AlertTriangle className="w-3 h-3 text-red-400 shrink-0" />}
|
||||
</div>
|
||||
<code className="text-[11px] text-slate-300 block truncate mt-0.5">{c.command}</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// export-audit.ts - 终端会话审计报告导出(CSV / JSON) // Logan.Li
|
||||
|
||||
import type { ReplaySessionMeta, CommandBookmark } from './replay-utils';
|
||||
import { isHighRiskCommand } from './replay-utils';
|
||||
|
||||
// downloadBlob 触发浏览器下载 // Logan.Li
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// escapeCsv CSV 字段转义 // Logan.Li
|
||||
function escapeCsv(s: string): string {
|
||||
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
|
||||
// exportAuditReportCsv 导出会话审计报告 CSV // Logan.Li
|
||||
export function exportAuditReportCsv(meta: ReplaySessionMeta, commands: CommandBookmark[]) {
|
||||
const lines: string[] = [
|
||||
'# 终端会话审计报告',
|
||||
`会话ID,${meta.id}`,
|
||||
`用户,${escapeCsv(meta.username || '')}`,
|
||||
`目标主机,${escapeCsv(meta.server_name || '')}`,
|
||||
`IP,${meta.server_ip}:${meta.server_port}`,
|
||||
`客户端IP,${meta.client_ip || ''}`,
|
||||
`开始时间,${meta.start_time || ''}`,
|
||||
`时长(秒),${meta.duration || 0}`,
|
||||
`录制大小,${meta.record_size || 0}`,
|
||||
'',
|
||||
'时间偏移,命令,高危',
|
||||
];
|
||||
for (const c of commands) {
|
||||
lines.push(`${c.label},${escapeCsv(c.command)},${c.highRisk ? '是' : '否'}`);
|
||||
}
|
||||
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
||||
downloadBlob(blob, `terminal-audit-${meta.id}.csv`);
|
||||
}
|
||||
|
||||
/** CommandRow 命令列表导出行 // Logan.Li */
|
||||
export interface CommandExportRow {
|
||||
id?: number;
|
||||
session_id: number;
|
||||
username?: string;
|
||||
command?: string;
|
||||
output_size?: number;
|
||||
created_at?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// exportCommandsListCsv 导出命令列表 CSV // Logan.Li
|
||||
export function exportCommandsListCsv(commands: CommandExportRow[]) {
|
||||
const lines: string[] = ['时间,用户,命令,输出大小,会话ID,高危'];
|
||||
for (const c of commands) {
|
||||
const ts = c.timestamp || c.created_at || '';
|
||||
const highRisk = isHighRiskCommand(c.command || '');
|
||||
lines.push([
|
||||
ts,
|
||||
escapeCsv(c.username || ''),
|
||||
escapeCsv(c.command || ''),
|
||||
String(c.output_size ?? 0),
|
||||
String(c.session_id),
|
||||
highRisk ? '是' : '否',
|
||||
].join(','));
|
||||
}
|
||||
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
||||
downloadBlob(blob, `terminal-commands-${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
}
|
||||
|
||||
// exportAuditReportJson 导出会话审计报告 JSON // Logan.Li
|
||||
export function exportAuditReportJson(meta: ReplaySessionMeta, commands: CommandBookmark[]) {
|
||||
const payload = {
|
||||
exported_at: new Date().toISOString(),
|
||||
session: meta,
|
||||
commands: commands.map((c) => ({
|
||||
offset: c.label,
|
||||
offset_sec: c.offsetSec,
|
||||
command: c.command,
|
||||
high_risk: c.highRisk,
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
downloadBlob(blob, `terminal-audit-${meta.id}.json`);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// replay-engine.ts - 终端回放引擎(暂停/seek/倍速) // Logan.Li
|
||||
|
||||
import type { Terminal } from 'xterm';
|
||||
import { findRowIndexAt, type AsciicastRow } from './replay-utils';
|
||||
|
||||
export type SpeedOption = 0.5 | 1 | 2 | 4 | 8;
|
||||
|
||||
export interface ReplayEngineCallbacks {
|
||||
onTimeUpdate: (time: number) => void;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
// ReplayEngine 控制 asciicast 定时回放 // Logan.Li
|
||||
export class ReplayEngine {
|
||||
private rows: AsciicastRow[] = [];
|
||||
private totalTime = 0;
|
||||
private currentTime = 0;
|
||||
private rowIndex = 0;
|
||||
private playing = false;
|
||||
private speed: SpeedOption = 1;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private term: Terminal | null = null;
|
||||
private cancelled = false;
|
||||
private callbacks: ReplayEngineCallbacks;
|
||||
|
||||
constructor(callbacks: ReplayEngineCallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
// load 加载事件流并绑定终端 // Logan.Li
|
||||
load(term: Terminal, rows: AsciicastRow[]) {
|
||||
this.stop();
|
||||
this.term = term;
|
||||
this.rows = rows;
|
||||
this.totalTime = rows.length > 0 ? rows[rows.length - 1].time : 0;
|
||||
this.seekTo(0, false);
|
||||
}
|
||||
|
||||
getTotalTime() { return this.totalTime; }
|
||||
getCurrentTime() { return this.currentTime; }
|
||||
isPlaying() { return this.playing; }
|
||||
getSpeed() { return this.speed; }
|
||||
|
||||
// play 开始或继续播放 // Logan.Li
|
||||
play() {
|
||||
if (!this.term || this.rows.length === 0) return;
|
||||
if (this.rowIndex >= this.rows.length) {
|
||||
this.seekTo(0, false);
|
||||
}
|
||||
this.playing = true;
|
||||
this.scheduleNext();
|
||||
}
|
||||
|
||||
// pause 暂停播放 // Logan.Li
|
||||
pause() {
|
||||
this.playing = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// stop 停止并清理定时器 // Logan.Li
|
||||
stop() {
|
||||
this.cancelled = true;
|
||||
this.pause();
|
||||
this.cancelled = false;
|
||||
}
|
||||
|
||||
// dispose 释放引擎 // Logan.Li
|
||||
dispose() {
|
||||
this.stop();
|
||||
this.term = null;
|
||||
this.rows = [];
|
||||
}
|
||||
|
||||
// setSpeed 设置倍速(播放中立即生效) // Logan.Li
|
||||
setSpeed(speed: SpeedOption) {
|
||||
this.speed = speed;
|
||||
if (this.playing) {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.scheduleNext();
|
||||
}
|
||||
}
|
||||
|
||||
// seekTo 跳转到指定时间点并重绘终端画面 // Logan.Li
|
||||
seekTo(time: number, autoPause = true) {
|
||||
if (!this.term) return;
|
||||
if (autoPause) this.pause();
|
||||
|
||||
const clamped = Math.max(0, Math.min(time, this.totalTime));
|
||||
this.currentTime = clamped;
|
||||
this.rowIndex = findRowIndexAt(this.rows, clamped);
|
||||
|
||||
this.term.reset();
|
||||
for (let i = 0; i < this.rowIndex; i++) {
|
||||
this.term.write(this.rows[i].data);
|
||||
}
|
||||
this.callbacks.onTimeUpdate(clamped);
|
||||
}
|
||||
|
||||
// skip 相对跳转(秒) // Logan.Li
|
||||
skip(deltaSec: number) {
|
||||
this.seekTo(this.currentTime + deltaSec);
|
||||
}
|
||||
|
||||
private scheduleNext() {
|
||||
if (this.cancelled || !this.playing || !this.term) return;
|
||||
if (this.rowIndex >= this.rows.length) {
|
||||
this.playing = false;
|
||||
this.callbacks.onEnded();
|
||||
return;
|
||||
}
|
||||
|
||||
const row = this.rows[this.rowIndex];
|
||||
const delayMs = Math.max(0, (row.time - this.currentTime) * 1000 / this.speed);
|
||||
const clamped = Math.min(delayMs, 8000 / this.speed);
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
if (this.cancelled || !this.playing || !this.term) return;
|
||||
this.term.write(row.data);
|
||||
this.currentTime = row.time;
|
||||
this.rowIndex++;
|
||||
this.callbacks.onTimeUpdate(this.currentTime);
|
||||
this.scheduleNext();
|
||||
}, clamped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// replay-utils.ts - 终端录制回放工具(asciicast v2 解析 / 时间格式化 / 命令书签) // Logan.Li
|
||||
|
||||
export interface AsciicastRow {
|
||||
time: number;
|
||||
eventType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface CommandBookmark {
|
||||
id: number;
|
||||
command: string;
|
||||
offsetSec: number;
|
||||
highRisk: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ReplaySessionMeta {
|
||||
id: number;
|
||||
username: string;
|
||||
server_name: string;
|
||||
server_ip: string;
|
||||
server_port: number;
|
||||
client_ip: string;
|
||||
start_time: string;
|
||||
duration: number;
|
||||
record_size: number;
|
||||
}
|
||||
|
||||
const HIGH_RISK_PATTERNS = [
|
||||
/\brm\s+-rf\b/i,
|
||||
/\bmkfs\b/i,
|
||||
/\bdd\s+if=/i,
|
||||
/\breboot\b/i,
|
||||
/\bshutdown\b/i,
|
||||
/\bpoweroff\b/i,
|
||||
/\buserdel\b/i,
|
||||
/\biptables\s+-F\b/i,
|
||||
/\bsystemctl\s+(stop|restart|disable)\b/i,
|
||||
/\bkill\s+-9\b/i,
|
||||
];
|
||||
|
||||
// parseAsciicast 解析 asciicast v2 录制数据 // Logan.Li
|
||||
export function parseAsciicast(raw: string): AsciicastRow[] {
|
||||
const lines = raw.split('\n').filter((l) => l.trim());
|
||||
const rows: AsciicastRow[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
if (Array.isArray(parsed) && parsed.length >= 3) {
|
||||
rows.push({
|
||||
time: Number(parsed[0]) || 0,
|
||||
eventType: String(parsed[1]),
|
||||
data: String(parsed[2]),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 跳过无法解析的行
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// formatReplayTime 格式化回放时间 mm:ss 或 hh:mm:ss // Logan.Li
|
||||
export function formatReplayTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '00:00';
|
||||
const total = Math.floor(sec);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
||||
return `${pad(m)}:${pad(s)}`;
|
||||
}
|
||||
|
||||
// isHighRiskCommand 高危命令检测(审计高亮) // Logan.Li
|
||||
export function isHighRiskCommand(command: string): boolean {
|
||||
const c = command.trim();
|
||||
if (!c) return false;
|
||||
return HIGH_RISK_PATTERNS.some((re) => re.test(c));
|
||||
}
|
||||
|
||||
// buildCommandBookmarks 将会话命令转为可跳转书签 // Logan.Li
|
||||
export function buildCommandBookmarks(
|
||||
commands: { id: number; command: string; timestamp?: string; created_at?: string }[],
|
||||
sessionStart: string,
|
||||
totalTime: number,
|
||||
): CommandBookmark[] {
|
||||
if (!sessionStart || totalTime <= 0) return [];
|
||||
const startMs = new Date(sessionStart).getTime();
|
||||
if (!Number.isFinite(startMs)) return [];
|
||||
|
||||
const bookmarks: CommandBookmark[] = [];
|
||||
for (const c of commands) {
|
||||
const ts = c.timestamp || c.created_at;
|
||||
if (!ts || !c.command) continue;
|
||||
const offsetSec = (new Date(ts).getTime() - startMs) / 1000;
|
||||
if (offsetSec < 0 || offsetSec > totalTime + 1) continue;
|
||||
bookmarks.push({
|
||||
id: c.id,
|
||||
command: c.command,
|
||||
offsetSec,
|
||||
highRisk: isHighRiskCommand(c.command),
|
||||
label: formatReplayTime(offsetSec),
|
||||
});
|
||||
}
|
||||
return bookmarks.sort((a, b) => a.offsetSec - b.offsetSec);
|
||||
}
|
||||
|
||||
// findRowIndexAt 查找时间点对应的下一事件索引 // Logan.Li
|
||||
export function findRowIndexAt(rows: AsciicastRow[], time: number): number {
|
||||
let idx = 0;
|
||||
for (; idx < rows.length; idx++) {
|
||||
if (rows[idx].time > time) break;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
@@ -3,31 +3,39 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { sessionCommandApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
import { unwrapList, unwrapTotal } from './unwrap';
|
||||
|
||||
export interface SessionCommand {
|
||||
id: number;
|
||||
session_id: number;
|
||||
username: string;
|
||||
server_name?: string;
|
||||
command: string;
|
||||
exit_code: number;
|
||||
output_size: number;
|
||||
output_size?: number;
|
||||
session_record_size?: number;
|
||||
timestamp?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ListParams = {
|
||||
export type SessionCommandListParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
session_id?: number;
|
||||
username?: string;
|
||||
command?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
};
|
||||
|
||||
export function useSessionCommands(params: ListParams = { page: 1, page_size: 200 }) {
|
||||
export function useSessionCommands(params: SessionCommandListParams = { page: 1, page_size: 200 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessionCommands(params),
|
||||
queryFn: async () => {
|
||||
const resp = await sessionCommandApi.list(params);
|
||||
return unwrapList<SessionCommand>(resp);
|
||||
return {
|
||||
list: unwrapList<SessionCommand>(resp),
|
||||
total: unwrapTotal(resp),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { terminalSessionApi } from '@/api';
|
||||
import { queryKeys } from './queryKeys';
|
||||
import { unwrapList } from './unwrap';
|
||||
import { unwrapList, unwrapTotal } from './unwrap';
|
||||
|
||||
export interface TerminalSession {
|
||||
id: number;
|
||||
@@ -21,20 +21,27 @@ export interface TerminalSession {
|
||||
end_time?: string | null;
|
||||
}
|
||||
|
||||
type ListParams = {
|
||||
export type TerminalSessionListParams = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
server_id?: number;
|
||||
username?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
has_record?: string | boolean;
|
||||
};
|
||||
|
||||
export function useTerminalSessions(params: ListParams = { page: 1, page_size: 100 }) {
|
||||
export function useTerminalSessions(params: TerminalSessionListParams = { page: 1, page_size: 100 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.terminalSessions(params),
|
||||
queryFn: async () => {
|
||||
const resp = await terminalSessionApi.list(params);
|
||||
return unwrapList<TerminalSession>(resp);
|
||||
return {
|
||||
list: unwrapList<TerminalSession>(resp),
|
||||
total: unwrapTotal(resp),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"noHistory": "No history sessions",
|
||||
"noCommands": "No command records",
|
||||
"totalCommands": "{{count}} commands",
|
||||
"showingLoaded": "{{loaded}} loaded",
|
||||
"virtualScroll": "virtual scroll",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"autoRefreshHintOn": "Auto-refresh every {{sec}}s · click to disable",
|
||||
@@ -630,6 +631,40 @@
|
||||
"liveMonitor": "Live Monitor",
|
||||
"replay": "Replay",
|
||||
"downloadRecord": "Download Recording",
|
||||
"replayAtCommand": "Replay from command",
|
||||
"filterUsername": "Username",
|
||||
"presetToday": "Today",
|
||||
"preset7d": "Last 7 days",
|
||||
"preset30d": "Last 30 days",
|
||||
"hasRecordOnly": "With recording only",
|
||||
"applyFilter": "Filter",
|
||||
"totalSessions": "{{count}} sessions",
|
||||
"deleteTitle": "Delete Session",
|
||||
"deleteConfirm": "Delete session #{{id}}? Recording will be removed.",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Delete failed",
|
||||
"batchDelete": "Batch delete",
|
||||
"batchDeleteConfirm": "Delete {{count}} selected sessions?",
|
||||
"batchDeleted": "Batch deleted",
|
||||
"exportFailed": "Export failed",
|
||||
"exportReport": "Export report",
|
||||
"exportReportCsv": "Export CSV",
|
||||
"exportCommands": "Export commands",
|
||||
"exportCommandsCsv": "Export commands CSV",
|
||||
"replayPlayer": {
|
||||
"title": "Terminal Replay #{{id}}",
|
||||
"desc": "Replay recorded terminal session",
|
||||
"noRecord": "No recording data for this session",
|
||||
"loadFailed": "Failed to load recording",
|
||||
"legacyFormat": "Legacy format — static display only",
|
||||
"commands": "Command bookmarks",
|
||||
"searchCommand": "Search commands...",
|
||||
"noCommands": "No commands recorded",
|
||||
"toStart": "Jump to start",
|
||||
"toEnd": "Jump to end",
|
||||
"restart": "Restart playback",
|
||||
"clientIp": "Client IP"
|
||||
},
|
||||
"tab": {
|
||||
"active": "Active Sessions",
|
||||
"history": "History Sessions",
|
||||
@@ -647,7 +682,8 @@
|
||||
"actions": "Actions",
|
||||
"command": "Command",
|
||||
"outputSize": "Output Size",
|
||||
"sessionRef": "Session"
|
||||
"sessionRef": "Session",
|
||||
"recordSize": "Recording Size"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"noHistory": "暂无历史会话",
|
||||
"noCommands": "暂无会话命令记录",
|
||||
"totalCommands": "共 {{count}} 条命令",
|
||||
"showingLoaded": "已加载 {{loaded}} 条",
|
||||
"virtualScroll": "虚拟滚动渲染",
|
||||
"autoRefresh": "自动刷新",
|
||||
"autoRefreshHintOn": "每 {{sec}}s 自动刷新 · 点击关闭",
|
||||
@@ -630,6 +631,40 @@
|
||||
"liveMonitor": "实时查看",
|
||||
"replay": "回放",
|
||||
"downloadRecord": "下载录制",
|
||||
"replayAtCommand": "从此命令回放",
|
||||
"filterUsername": "用户名",
|
||||
"presetToday": "今天",
|
||||
"preset7d": "近7天",
|
||||
"preset30d": "近30天",
|
||||
"hasRecordOnly": "仅有录制",
|
||||
"applyFilter": "筛选",
|
||||
"totalSessions": "共 {{count}} 条会话",
|
||||
"deleteTitle": "删除会话",
|
||||
"deleteConfirm": "确认删除会话 #{{id}}?录制文件将一并删除。",
|
||||
"deleted": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"batchDelete": "批量删除",
|
||||
"batchDeleteConfirm": "确认删除选中的 {{count}} 条会话记录?",
|
||||
"batchDeleted": "已批量删除",
|
||||
"exportFailed": "导出失败",
|
||||
"exportReport": "导出报告",
|
||||
"exportReportCsv": "导出 CSV",
|
||||
"exportCommands": "导出命令",
|
||||
"exportCommandsCsv": "导出命令 CSV",
|
||||
"replayPlayer": {
|
||||
"title": "终端回放 #{{id}}",
|
||||
"desc": "回放录制的终端会话",
|
||||
"noRecord": "该会话无录制数据",
|
||||
"loadFailed": "加载录制失败",
|
||||
"legacyFormat": "旧版录制格式,仅支持静态展示",
|
||||
"commands": "命令书签",
|
||||
"searchCommand": "搜索命令...",
|
||||
"noCommands": "无命令记录",
|
||||
"toStart": "跳到开头",
|
||||
"toEnd": "跳到结尾",
|
||||
"restart": "重新播放",
|
||||
"clientIp": "客户端 IP"
|
||||
},
|
||||
"tab": {
|
||||
"active": "在线会话",
|
||||
"history": "历史会话",
|
||||
@@ -647,7 +682,8 @@
|
||||
"actions": "操作",
|
||||
"command": "命令",
|
||||
"outputSize": "输出大小",
|
||||
"sessionRef": "关联会话"
|
||||
"sessionRef": "关联会话",
|
||||
"recordSize": "录制大小"
|
||||
},
|
||||
"status": {
|
||||
"active": "活动中",
|
||||
|
||||
@@ -108,11 +108,12 @@ export function ActiveSessionsPanel({ userToggledOff, onUserToggledOff }: Active
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { killMutation } = useTerminalSessionMutations();
|
||||
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
|
||||
const { data, isLoading, refetch } = useTerminalSessions({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
status: 'active',
|
||||
});
|
||||
const sessions = data?.list ?? [];
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(() => !userToggledOff);
|
||||
|
||||
@@ -1,158 +1,194 @@
|
||||
// 终端审计 — 历史会话子面板 // Logan.Li
|
||||
// 终端审计 — 历史会话子面板(筛选 / 分页 / 回放 / 导出,只读无删除) // Logan.Li
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, RefreshCw, Play, Download, Monitor } from 'lucide-react';
|
||||
import { terminalSessionApi } from '@/api';
|
||||
import { Search, RefreshCw, Play, Download, Monitor, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { terminalSessionApi, sessionCommandApi } from '@/api';
|
||||
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
||||
import { useTerminalSessions } from '@/hooks/api';
|
||||
import { formatDuration, getStatusBadge } from './helpers';
|
||||
import { unwrapList } from '@/hooks/api/unwrap';
|
||||
import { toast } from 'sonner';
|
||||
import { formatDuration, formatSize, getStatusBadge, presetAuditRange } from './helpers';
|
||||
import { buildCommandBookmarks } from '@/components/system/replay/replay-utils';
|
||||
import { exportAuditReportCsv } from '@/components/system/replay/export-audit';
|
||||
import type { ReplaySessionMeta } from '@/components/system/replay/replay-utils';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function HistorySessionsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { data: sessions = [], isLoading, refetch } = useTerminalSessions({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
status: 'closed',
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [hasRecordOnly, setHasRecordOnly] = useState(false);
|
||||
|
||||
const [applied, setApplied] = useState({
|
||||
keyword: '', username: '', startDate: '', endDate: '', hasRecordOnly: false,
|
||||
});
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const queryParams = useMemo(() => ({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
status: 'closed',
|
||||
...(applied.keyword ? { keyword: applied.keyword } : {}),
|
||||
...(applied.username ? { username: applied.username } : {}),
|
||||
...(applied.startDate ? { start_date: applied.startDate } : {}),
|
||||
...(applied.endDate ? { end_date: applied.endDate } : {}),
|
||||
...(applied.hasRecordOnly ? { has_record: '1' } : {}),
|
||||
}), [page, applied]);
|
||||
|
||||
const { data, isLoading, refetch } = useTerminalSessions(queryParams);
|
||||
const sessions = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const [replayId, setReplayId] = useState<number | null>(null);
|
||||
const [replayOpen, setReplayOpen] = useState(false);
|
||||
|
||||
const filtered = sessions.filter((s) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(s.username || '').toLowerCase().includes(kw) ||
|
||||
(s.server_name || '').toLowerCase().includes(kw) ||
|
||||
(s.server_ip || '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
const applyFilters = () => {
|
||||
setApplied({ keyword, username, startDate, endDate, hasRecordOnly });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openReplay = (id: number) => {
|
||||
setReplayId(id);
|
||||
setReplayOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (id: number) => {
|
||||
const handleDownload = (id: number) => {
|
||||
terminalSessionApi.download(id).catch(() => toast.error(t('audit.terminal.exportFailed')));
|
||||
};
|
||||
|
||||
// 导出审计报告:命令列表失败时仍导出会话元信息 // Logan.Li
|
||||
const handleExportReport = async (id: number) => {
|
||||
try {
|
||||
await terminalSessionApi.download(id);
|
||||
const sess = await terminalSessionApi.get(id);
|
||||
const meta = sess as ReplaySessionMeta;
|
||||
let cmdList: { id: number; command: string; timestamp?: string; created_at?: string }[] = [];
|
||||
try {
|
||||
const cmds = await sessionCommandApi.list({ session_id: id, page: 1, page_size: 500 });
|
||||
cmdList = unwrapList(cmds);
|
||||
} catch {
|
||||
/* 无命令表数据时仅导出会话信息 */
|
||||
}
|
||||
const duration = meta.duration || 0;
|
||||
const bookmarks = buildCommandBookmarks(cmdList, meta.start_time, duration);
|
||||
exportAuditReportCsv({ ...meta, id }, bookmarks);
|
||||
toast.success(t('audit.terminal.exportReport'));
|
||||
} catch {
|
||||
// download error is handled in http.download
|
||||
toast.error(t('audit.terminal.exportFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative w-52">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder={t('audit.terminal.searchUserServerIp')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('audit.terminal.searchUserServerIp')}
|
||||
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"
|
||||
placeholder={t('audit.terminal.filterUsername')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
/>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<span className="text-slate-600 text-sm">—</span>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.presetToday')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset7d')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset30d')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
|
||||
<input type="checkbox" checked={hasRecordOnly} onChange={(e) => setHasRecordOnly(e.target.checked)} className="rounded" />
|
||||
{t('audit.terminal.hasRecordOnly')}
|
||||
</label>
|
||||
<Button size="sm" className="h-9" onClick={applyFilters}>{t('audit.terminal.applyFilter')}</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 h-9" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t border-slate-800">
|
||||
{isLoading ? (
|
||||
<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">
|
||||
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">{t('audit.terminal.noHistory')}</p>
|
||||
</div>
|
||||
) : (
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<Monitor className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">{t('audit.terminal.noHistory')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.id')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.user')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.targetServer')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.port')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.startTime')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.endTime')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.duration')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.recordSize')}</TableHead>
|
||||
<TableHead className="text-slate-400">{t('audit.terminal.col.status')}</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">{t('audit.terminal.col.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((s) => (
|
||||
{sessions.map((s) => (
|
||||
<TableRow key={s.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200">{s.username || '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell><span className="text-sm text-slate-400">{s.id}</span></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}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{s.server_port}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.start_time ? new Date(s.start_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><span className="text-sm text-slate-300">{formatDuration(s.duration)}</span></TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{s.end_time ? new Date(s.end_time).toLocaleString() : '--'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{formatDuration(s.duration)}</span>
|
||||
<span className="text-sm text-slate-400">{s.record_size > 0 ? formatSize(s.record_size) : '--'}</span>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(s.status)}</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={t('audit.terminal.replay')}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400" onClick={() => openReplay(s.id)} disabled={!s.record_size} title={t('audit.terminal.replay')}>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-green-400 hover:bg-slate-800"
|
||||
onClick={() => handleDownload(s.id)}
|
||||
title={t('audit.terminal.downloadRecord')}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-400" onClick={() => handleExportReport(s.id)} title={t('audit.terminal.exportReport')}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-green-400" onClick={() => handleDownload(s.id)} disabled={!s.record_size} title={t('audit.terminal.downloadRecord')}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -161,12 +197,24 @@ export function HistorySessionsPanel() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
|
||||
<span className="text-xs text-slate-500">{t('audit.terminal.totalSessions', { count: total })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400">{page} / {totalPages}</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReplayDialog open={replayOpen} onOpenChange={setReplayOpen} sessionId={replayId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// 终端审计 — 会话命令子面板 // Logan.Li
|
||||
// v2: 虚拟滚动,支持万级命令记录流畅查看
|
||||
// 终端审计 — 会话命令子面板(筛选 / 虚拟滚动 / 导出) // Logan.Li
|
||||
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,41 +6,80 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, RefreshCw } from 'lucide-react';
|
||||
import { Search, RefreshCw, Play, FileText } from 'lucide-react';
|
||||
import { useSessionCommands } from '@/hooks/api';
|
||||
import { formatSize } from './helpers';
|
||||
import { terminalSessionApi, sessionCommandApi } from '@/api';
|
||||
import { ReplayDialog } from '@/components/system/ReplayDialog';
|
||||
import { formatCommandOutput, presetAuditRange } from './helpers';
|
||||
import { isHighRiskCommand } from '@/components/system/replay/replay-utils';
|
||||
import { exportCommandsListCsv } from '@/components/system/replay/export-audit';
|
||||
import { unwrapList } from '@/hooks/api/unwrap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import type { SessionCommand } from './types';
|
||||
|
||||
const GRID_COLS = 'minmax(150px,1fr) minmax(80px,0.8fr) minmax(200px,2fr) minmax(100px,0.8fr) minmax(80px,0.5fr)';
|
||||
const GRID_COLS = 'minmax(150px,1fr) minmax(80px,0.8fr) minmax(200px,2fr) minmax(100px,0.8fr) minmax(80px,0.5fr) 40px';
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
export function SessionCommandsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [sessionIdInput, setSessionIdInput] = useState('');
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
page: 1,
|
||||
page_size: 500,
|
||||
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
|
||||
}),
|
||||
[sessionIdInput],
|
||||
);
|
||||
|
||||
const { data: commands = [], isLoading, refetch } = useSessionCommands(queryParams);
|
||||
|
||||
const filtered = commands.filter((c) => {
|
||||
const kw = keyword.toLowerCase();
|
||||
return (
|
||||
!kw ||
|
||||
(c.username || '').toLowerCase().includes(kw) ||
|
||||
(c.command || '').toLowerCase().includes(kw)
|
||||
);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [applied, setApplied] = useState({
|
||||
keyword: '', username: '', sessionId: '', startDate: '', endDate: '',
|
||||
});
|
||||
|
||||
const [replayOpen, setReplayOpen] = useState(false);
|
||||
const [replayId, setReplayId] = useState<number | null>(null);
|
||||
const [replaySeek, setReplaySeek] = useState<number | undefined>(undefined);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const queryParams = useMemo(() => ({
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
...(applied.sessionId ? { session_id: Number(applied.sessionId) } : {}),
|
||||
...(applied.username ? { username: applied.username } : {}),
|
||||
...(applied.keyword ? { command: applied.keyword } : {}),
|
||||
...(applied.startDate ? { start_date: applied.startDate } : {}),
|
||||
...(applied.endDate ? { end_date: applied.endDate } : {}),
|
||||
}), [applied]);
|
||||
|
||||
const { data, isLoading, refetch } = useSessionCommands(queryParams);
|
||||
const commands = data?.list ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const applyFilters = () => {
|
||||
setApplied({ keyword, username, sessionId: sessionIdInput, startDate, endDate });
|
||||
};
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const resp = await sessionCommandApi.list({
|
||||
...queryParams,
|
||||
page: 1,
|
||||
page_size: Math.min(Math.max(total, 1), 10000),
|
||||
});
|
||||
const list = unwrapList<SessionCommand>(resp);
|
||||
if (list.length === 0) {
|
||||
toast.error(t('audit.terminal.noCommands'));
|
||||
return;
|
||||
}
|
||||
exportCommandsListCsv(list);
|
||||
} catch {
|
||||
toast.error(t('audit.terminal.exportFailed'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 虚拟滚动
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
count: commands.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 48,
|
||||
overscan: 10,
|
||||
@@ -49,32 +87,86 @@ export function SessionCommandsPanel() {
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
|
||||
// 从命令行打开回放并跳到该命令时刻(无录制则提示) // Logan.Li
|
||||
const openReplayAtCommand = async (cmd: SessionCommand) => {
|
||||
if (!cmd.session_record_size) {
|
||||
toast.error(t('audit.terminal.replayPlayer.noRecord'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sess = await terminalSessionApi.get(cmd.session_id) as { start_time?: string };
|
||||
const ts = cmd.timestamp || cmd.created_at;
|
||||
let seek: number | undefined;
|
||||
if (sess?.start_time && ts) {
|
||||
seek = (new Date(ts).getTime() - new Date(sess.start_time).getTime()) / 1000;
|
||||
if (seek < 0) seek = 0;
|
||||
}
|
||||
setReplayId(cmd.session_id);
|
||||
setReplaySeek(seek);
|
||||
setReplayOpen(true);
|
||||
} catch {
|
||||
toast.error(t('audit.terminal.replayPlayer.loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative w-52">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder={t('audit.terminal.searchUserCommand')}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('audit.terminal.filterUsername')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-28 h-9 bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('audit.terminal.sessionIdPlaceholder')}
|
||||
value={sessionIdInput}
|
||||
onChange={(e) => setSessionIdInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<span className="text-slate-600 text-sm">—</span>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(1); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.presetToday')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(7); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset7d')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 h-9" onClick={() => { const r = presetAuditRange(30); setStartDate(r.start); setEndDate(r.end); }}>
|
||||
{t('audit.terminal.preset30d')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={applyFilters}>{t('audit.terminal.applyFilter')}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800 h-9"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-700 text-slate-300 hover:text-amber-400 h-9"
|
||||
onClick={handleExportCsv}
|
||||
disabled={exporting || total === 0}
|
||||
title={t('audit.terminal.exportCommandsCsv')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t('audit.terminal.exportCommands')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
@@ -84,14 +176,13 @@ export function SessionCommandsPanel() {
|
||||
<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 ? (
|
||||
) : commands.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">{t('audit.terminal.noCommands')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 表头 */}
|
||||
<div
|
||||
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400"
|
||||
style={{ gridTemplateColumns: GRID_COLS }}
|
||||
@@ -101,13 +192,13 @@ export function SessionCommandsPanel() {
|
||||
<span>{t('audit.terminal.col.command')}</span>
|
||||
<span>{t('audit.terminal.col.outputSize')}</span>
|
||||
<span>{t('audit.terminal.col.sessionRef')}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动列表 */}
|
||||
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
|
||||
<div style={{ height: totalHeight, position: 'relative' }}>
|
||||
{virtualRows.map((vr) => {
|
||||
const c = filtered[vr.index];
|
||||
const c = commands[vr.index];
|
||||
if (!c) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -130,26 +221,54 @@ export function SessionCommandsPanel() {
|
||||
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-200 truncate">{c.username || '--'}</span>
|
||||
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
|
||||
<code className={cn(
|
||||
'text-xs bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block',
|
||||
isHighRiskCommand(c.command || '') ? 'text-red-400' : 'text-slate-300',
|
||||
)}>
|
||||
{c.command || '--'}
|
||||
</code>
|
||||
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
|
||||
<span className="text-xs text-slate-500">#{c.session_id}</span>
|
||||
<span className="text-sm text-slate-400">{formatCommandOutput(c.output_size)}</span>
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
c.session_record_size ? 'text-slate-500' : 'text-amber-500/80',
|
||||
)} title={!c.session_record_size ? t('audit.terminal.replayPlayer.noRecord') : undefined}>
|
||||
#{c.session_id}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-blue-400 disabled:opacity-30"
|
||||
onClick={() => openReplayAtCommand(c)}
|
||||
disabled={!c.session_record_size}
|
||||
title={c.session_record_size
|
||||
? t('audit.terminal.replayAtCommand')
|
||||
: t('audit.terminal.replayPlayer.noRecord')}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行数统计 */}
|
||||
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
|
||||
{t('audit.terminal.totalCommands', { count: filtered.length })} {filtered.length > 200 && `· ${t('audit.terminal.virtualScroll')}`}
|
||||
{t('audit.terminal.totalCommands', { count: total })}
|
||||
{commands.length < total && ` · ${t('audit.terminal.showingLoaded', { loaded: commands.length })}`}
|
||||
{commands.length > 200 && ` · ${t('audit.terminal.virtualScroll')}`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReplayDialog
|
||||
open={replayOpen}
|
||||
onOpenChange={setReplayOpen}
|
||||
sessionId={replayId}
|
||||
initialSeekSec={replaySeek}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,19 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
/** fmtAuditDate 格式化为 YYYY-MM-DD(筛选日期) // Logan.Li */
|
||||
export function fmtAuditDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** presetAuditRange 近 N 天日期范围 // Logan.Li */
|
||||
export function presetAuditRange(days: number): { start: string; end: string } {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - days + 1);
|
||||
return { start: fmtAuditDate(start), end: fmtAuditDate(end) };
|
||||
}
|
||||
|
||||
export function formatDuration(sec: number): string {
|
||||
if (!sec || sec < 0) return '--';
|
||||
if (sec < 60) return i18n.t('common.duration.sec', { count: sec });
|
||||
@@ -21,6 +34,12 @@ export function formatSize(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** formatCommandOutput 命令输出大小(未统计时显示 --) // Logan.Li */
|
||||
export function formatCommandOutput(bytes?: number): string {
|
||||
if (bytes == null) return '--';
|
||||
return formatSize(bytes);
|
||||
}
|
||||
|
||||
export function getStatusBadge(status: string) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
|
||||
@@ -20,9 +20,11 @@ export interface SessionCommand {
|
||||
id: number;
|
||||
session_id: number;
|
||||
username: string;
|
||||
server_name?: string;
|
||||
command: string;
|
||||
exit_code: number;
|
||||
output_size: number;
|
||||
output_size?: number;
|
||||
session_record_size?: number;
|
||||
timestamp?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ export default defineConfig({
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/agent": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -875,19 +875,20 @@ cmd_build() {
|
||||
agent)
|
||||
banner "========== 编译 Agent =========="
|
||||
cd "$SCRIPT_DIR/agent"
|
||||
mkdir -p bin
|
||||
|
||||
info "编译 Linux AMD64..."
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o aiops-agent-linux-amd64 .
|
||||
success "aiops-agent-linux-amd64 ($(du -h aiops-agent-linux-amd64 | cut -f1))"
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-linux-amd64 .
|
||||
success "bin/aiops-agent-linux-amd64 ($(du -h bin/aiops-agent-linux-amd64 | cut -f1))"
|
||||
|
||||
info "编译 Linux ARM64..."
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o aiops-agent-linux-arm64 .
|
||||
success "aiops-agent-linux-arm64 ($(du -h aiops-agent-linux-arm64 | cut -f1))"
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent-linux-arm64 .
|
||||
success "bin/aiops-agent-linux-arm64 ($(du -h bin/aiops-agent-linux-arm64 | cut -f1))"
|
||||
|
||||
if [ "$OS" = "macos" ]; then
|
||||
info "编译 macOS (当前平台)..."
|
||||
CGO_ENABLED=0 go build -ldflags="-w -s" -o aiops-agent .
|
||||
success "aiops-agent ($(du -h aiops-agent | cut -f1))"
|
||||
CGO_ENABLED=0 go build -ldflags="-w -s" -o bin/aiops-agent .
|
||||
success "bin/aiops-agent ($(du -h bin/aiops-agent | cut -f1))"
|
||||
fi
|
||||
;;
|
||||
frontend)
|
||||
|
||||
Reference in new issue
Block a user