diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 9f43cfc..88028a0 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -40,6 +40,7 @@ | **Node 详情页** | ✅ GetNodeDetail (M10) | ✅ NodeDetailPage | ✅ curl 验证 | | **StatefulSet/DaemonSet 详情页** | ✅ GetStatefulSetDetail/GetDaemonSetDetail + YAML路由修复 (M11) | ✅ WorkloadViewPage 重写 (M11) | ✅ curl 验证 | | **多集群资源对比看板** | ✅ GetMultiClusterComparison (M13) | ✅ ClusterComparePage (M13) | ✅ urllib 验证 | +| **Namespace 管理看板** | ✅ GetNamespacesDetail + CRUD (M14) | ✅ NamespaceManagementPage (M14) | ✅ urllib 验证 | ### ❌ 缺失 / 进行中 diff --git a/ROADMAP.md b/ROADMAP.md index 3c01fa1..c80b765 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -329,3 +329,48 @@ pending → in_progress → done / blocked 1. 更新本文件状态 2. 更新 PROJECT_EVALUATION.md 对应行 3. git commit message 格式: `feat(k8s): M1 SyncCluster metrics 采集` + +--- + +### M14: Namespace 管理看板(资源配额 + 命名空间 CRUD)[done] +**目标**: 从仅返回命名空间名列表升级为完整的命名空间管理看板——含资源统计、配额状态、创建/删除操作 + +**后端改动**: +- `pkg/kubernetesclient/client.go`: + - `ListNamespacesDetailed()` — 返回完整 metadata(status/age/labels/annotations) + - `GetNamespace(name)` — 单个命名空间详情 + - `CreateNamespace(name, labels)` — 创建命名空间 + - `DeleteNamespace(name)` — 删除命名空间 + - `ListResourceQuotas(namespace)` — 支持空 namespace 全集群模式 + - `ListLimitRanges(namespace)` — 支持空 namespace 全集群模式 +- `handlers/kubernetes.go`: + - `GetNamespacesDetail` — 全量命名空间 + 资源计数 + 配额状态(6 次 API 调用聚合,非 N×3) + - `GetNamespaceDetailHandler` — 单命名空间详情 + - `CreateNamespaceHandler` — 创建(系统命名空间保护) + - `DeleteNamespaceHandler` — 删除(kube-system/default/devops 保护) +- `routes.go`: + - `GET /:clusterCode/namespaces/detail` — 全量命名空间详情 + - `GET /:clusterCode/namespace/:namespace/detail` — 单命名空间详情 + - `POST /:clusterCode/namespaces` — 创建命名空间 + - `DELETE /:clusterCode/namespaces/:namespace` — 删除命名空间 + +**前端改动**: +- 新增 `NamespaceManagementPage.tsx` (~280行): + - 概览卡片:命名空间总数 / Deployments / Pods / Services / 配额限制数 + - 命名空间表格:名称(可点击跳转)/ 状态 Badge / 年龄 / Dep/Pod/Svc 计数 / 配额状态 / 标签 / 操作(删除) + - 创建命名空间弹框(名称 + DNS-1123 提示) + - 删除确认弹框(红色警告 + 二次确认) + - 系统命名空间保护(kube-system/default/devops 不可删除) + - 搜索过滤 +- `api.ts`: 新增 4 个方法(getNamespacesDetail/getNamespaceDetail/createNamespace/deleteNamespace) +- `App.tsx`: 新增路由 `kubernetes/:clusterCode/namespaces` +- `ClusterOverviewPage.tsx`: 命名空间区域新增「管理全部」链接 + +**验收标准**: +- [x] `GET /:clusterCode/namespaces/detail` 返回 9 个命名空间(含 status/age/labels/resource_counts) +- [x] 每个命名空间包含 7 个字段(name/status/age/labels/resource_counts/has_resource_quota/has_limit_range) +- [x] 创建命名空间 API 返回 200 + 创建后列表中出现 +- [x] 删除命名空间 API 返回 200 + 删除后列表中消失 +- [x] 系统命名空间删除被拦截(403) +- [x] SPA 路由 `/kubernetes/:code/namespaces` 返回 200 +- [x] TypeScript 零编译错误 + Vite 构建成功 diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 8b46279..d5ccaac 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -478,3 +478,73 @@ cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 - [x] 前端 4 类图表正确渲染(柱状图×2 + 雷达图 + 饼图) - [x] 详细对比表格可点击跳转集群概览 - [x] totals 聚合正确(cluster_count/node_count/pod_count/deployment_count/service_count) + +--- + +## M14: Namespace 管理看板(资源配额 + 命名空间 CRUD) + +### 验证用例 + +```bash +# U1: 登录获取 Token +TOKEN=$(python3 -c " +import urllib.request, json +login_data = json.dumps({'username': 'admin', 'password': 'admin123'}).encode() +req = urllib.request.Request('http://127.0.0.1:8080/api/v1/auth/login', data=login_data, method='POST') +req.add_header('Content-Type', 'application/json') +resp = urllib.request.urlopen(req) +print(json.loads(resp.read())['data']['token']) +") + +# U2: 命名空间详情 API(含资源计数+配额状态) +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespaces/detail" \ + -H "Authorization: Bearer $TOKEN" | jq '.data | length' +# 期望: 返回所有命名空间(含 status/age/labels/resource_counts) + +# U3: 字段完整性 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespaces/detail" \ + -H "Authorization: Bearer $TOKEN" | jq '.data[0] | keys' +# 期望: 含 name/status/age/labels/resource_counts/has_resource_quota/has_limit_range + +# U4: 创建命名空间 +curl -s -X POST "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespaces" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"test-ns-m14","labels":{"env":"test"}}' +# 期望: code=200, message="命名空间创建成功" + +# U5: 创建后验证存在于列表 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespaces/detail" \ + -H "Authorization: Bearer $TOKEN" | jq '[.data[] | select(.name=="test-ns-m14")] | length' +# 期望: 1 + +# U6: 删除命名空间 +curl -s -X DELETE "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespaces/test-ns-m14" \ + -H "Authorization: Bearer $TOKEN" +# 期望: code=200, message="命名空间已删除" + +# U7: 系统命名空间删除保护 +curl -s -X DELETE "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespaces/kube-system" \ + -H "Authorization: Bearer $TOKEN" +# 期望: code=403, message="禁止删除系统命名空间: kube-system" + +# U8: SPA 路由 +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1/kubernetes/prod-cluster/namespaces +# 期望: 200 + +# U9: TypeScript + Vite 构建 +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 +# 期望: 零错误 + built in Ns +``` + +### 通过标准 +- [x] U2: Namespaces Detail API 返回完整命名空间列表(含 status/age/resource_counts) +- [x] U3: 每个命名空间包含 7 个字段(name/status/age/labels/resource_counts/has_resource_quota/has_limit_range) +- [x] U4: 创建命名空间 API 返回 200 +- [x] U5: 创建后命名空间出现在列表中 +- [x] U6: 删除命名空间 API 返回 200 +- [x] U7: 系统命名空间删除被拦截(403) +- [x] U8: SPA 路由返回 200 +- [x] U9: TypeScript 零编译错误 + Vite 构建成功 +- [x] 前端命名空间管理页表格正确渲染(名称/状态/年龄/资源计数/配额/操作) +- [x] 创建/删除弹框功能正常 +- [x] 集群概览页「管理全部」链接跳转正常 diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..8893e21 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,151 @@ +# AIOps Agent + +轻量主机采集代理,定时采集 CPU/内存/磁盘/网络/端口/进程 等指标,通过 HTTP 上报至 AIOps 后端。 + +## 目录结构 + +``` +agent/ +├── main.go # 入口:CLI 解析、采集编排、上报循环 +├── Makefile # 本地构建(单架构) +│ +├── collector/ # 采集器 —— 从 /proc /sys netstat 读原始数据 +│ ├── cpu.go # CPU 使用率、核数、负载 +│ ├── mem.go # 内存 total/used/cached +│ ├── swap.go # Swap 分区 +│ ├── disk.go # 磁盘(按挂载点) +│ ├── diskio.go # 磁盘 IO(读写速率/IOPS) +│ ├── network.go # TCP 连接状态、网卡流量 +│ ├── port.go # 监听端口发现 +│ ├── proctop.go # 进程 TOP N(按 CPU 排序) +│ ├── fd.go # 系统文件句柄使用量 +│ ├── context.go # 上下文切换/中断次数 +│ ├── health_probe.go # 健康探针(HTTP GET / TCP / CMD) +│ ├── system.go # 系统信息(OS/Kernel/Uptime) +│ └── util.go # 通用工具(读文件、解析等) +│ +├── model/ # 数据模型 +│ ├── model.go # Report 结构体(一次采集的完整报文) +│ ├── model_ext.go # NetworkStat / DiskIOStat / SwapStat 等 +│ └── model_proctop.go # ProcessStat(含 Cmdline/User/CPU%/Mem%/FD) +│ +├── client/ # HTTP 客户端(连接池复用、Keep-Alive) +│ └── http.go +├── reporter/ # 上报器 —— 将 Report JSON 序列化后 POST 到后端 +│ └── http.go +├── task/ # 任务系统(后端下发命令,Agent 执行并回传结果) +│ ├── client.go +│ └── executor.go +│ +├── deploy/ # 部署文件 +│ ├── install.sh # 一键安装脚本(自动检测架构) +│ └── aiops-agent.service # systemd 服务模板 +│ +├── bin/ # 编译产物(Docker 多架构构建后拷贝) +├── go.mod / go.sum # Go 依赖 +└── README.md +``` + +## 架构 + +``` +┌─────────────┐ 采集 ┌──────────────────────────────┐ +│ /proc/cpu │──────────▶│ collector/ │ +│ /proc/mem │ │ cpu / mem / disk / net / ... │ +│ netstat │ └──────────┬───────────────────┘ +│ /sys/... │ │ Go struct +└─────────────┘ ▼ + ┌────────────────┐ + │ model.Report │ ← 一次采集的完整数据 + └───────┬────────┘ + │ JSON 序列化 + ▼ + ┌────────────────┐ + │ reporter/ │ ← HTTP POST /api/v1/agent/report + └───────┬────────┘ + │ + ▼ + ┌─────────────────────┐ + │ AIOps Backend │ + │ (10.10.10.75:8080) │ + └─────────────────────┘ +``` + +- **采集层**:从 `/proc`、`/sys`、`netstat` 等系统接口读取原始数据 +- **模型层**:统一的数据结构 `Report`,涵盖系统指标、进程 TOP、端口、FD、探针等 +- **上报层**:共享 HTTP 连接池,JSON 序列化后 POST 到后端 +- **任务层**:独立协程轮询后端任务(脚本执行、探针配置拉取) + +## 快速开始 + +```bash +# 输出全部上报指标(Zabbix-agent -p 风格),调试用 +./aiops-agent output + +# 按 CPU 占用输出 TOP 5 进程 +./aiops-agent output -top-n 5 + +# 正常采集上报(daemon) +./aiops-agent -server http://10.10.10.75:8080 -token -interval 30 +``` + +## 参数 + +| 参数 | 默认 | 说明 | +|------|------|------| +| `-server` | (空) | 后端地址,daemon 模式必填 | +| `-token` | (空) | Bearer 鉴权令牌 | +| `-hostname` | 系统主机名 | 上报标识 | +| `-interval` | `10` | 采集间隔(秒),建议 30~60 | +| `-top-n` | `3` | 进程 TOP N,按 CPU 占用排序 | + +## output 输出 + +格式:`key [type|value]`,type=`s`(string) `u`(uint) `d`(double)。 + +```text +agent.hostname [s|opsx-server] +system.hostname [s|opsx-server] +agent.version [s|v2.1.0] +agent.ip [s|10.10.10.75] +system.os [s|Ubuntu 24.04 LTS] +system.kernel [s|6.8.0-90-generic] +system.uptime [u|603932] +cpu.usage_percent [d|0.00] +cpu.core_count [u|8] +cpu.load1 / load5 / load15 [d|...] +memory.total_gb / used_gb / usage_percent [d|...] +swap.total_gb / used_gb / usage_percent [d|...] +disk[/].total_gb / used_gb / usage_percent [d|...] (每挂载点一行) +ports.discovery[*] [s|{JSON}] (Zabbix LLD 格式) +net.listen[PORT].process / .protocol [s|...] +net.tcp.established / time_wait / close_wait [u|...] +net.rx_bps / tx_bps [d|...] +disk_io.read_bps / write_bps / read_iops / write_iops +proc.top[N].name / pid / user / cpu_pct / mem_pct / fd_count / threads +fd.allocated / max / usage_pct [u|...] +ctx.ctx_per_sec / irq_per_sec [d|...] +agent.heartbeat.version / uptime_sec / self_rss_mb / report_seq +``` + +## 安装 + +```bash +# x86_64 +curl -o /usr/local/bin/aiops-agent http://10.10.10.75:8080/agent/aiops-agent-linux-amd64 + +# ARM64 +curl -o /usr/local/bin/aiops-agent http://10.10.10.75:8080/agent/aiops-agent-linux-arm64 + +# 一键安装(自动检测架构并注册 systemd) +curl -fsSL http://10.10.10.75:8080/agent/install.sh | sudo bash -s -- --server http://10.10.10.75:8080 --token +``` + +## 本地构建 + +```bash +cd agent +make # CGO_ENABLED=0 静态编译当前平台 +``` + +Docker 多架构构建由顶层 `backend/Dockerfile` 的 `agent-builder` stage 完成(linux-amd64 / linux-arm64 / darwin-arm64)。 diff --git a/agent/collector/cpu.go b/agent/collector/cpu.go index 74f7f1e..656f3d6 100644 --- a/agent/collector/cpu.go +++ b/agent/collector/cpu.go @@ -18,8 +18,9 @@ import ( // cpuSample 保存某一时刻 CPU 的累计 jiffies,用于两次采样计算差值 type cpuSample struct { - total uint64 // 自系统启动以来 CPU 所有状态的累计 jiffies - idle uint64 // idle + iowait 的累计 jiffies + total uint64 // 自系统启动以来 CPU 所有状态的累计 jiffies + idle uint64 // idle + iowait 的累计 jiffies + iowait uint64 // iowait 单独累计 jiffies(用于计算 iowait 占比) } // CPUCollector CPU 指标采集器。 @@ -50,12 +51,18 @@ func (c *CPUCollector) Collect() (model.CPUStat, error) { if c.prev != nil { totalDelta := cur.total - c.prev.total idleDelta := cur.idle - c.prev.idle + iowaitDelta := cur.iowait - c.prev.iowait if totalDelta > 0 { // 使用率 = (总增量 - 空闲增量) / 总增量 * 100 stat.UsagePercent = float64(totalDelta-idleDelta) / float64(totalDelta) * 100.0 if stat.UsagePercent < 0 { stat.UsagePercent = 0 } + // iowait = iowait增量 / 总增量 * 100 + stat.IOWait = float64(iowaitDelta) / float64(totalDelta) * 100.0 + if stat.IOWait < 0 { + stat.IOWait = 0 + } } } c.prev = &cur @@ -100,6 +107,10 @@ func readCPUSample() (cpuSample, error) { if i == 3 || i == 4 { sample.idle += x } + // iowait(索引4) 单独记录 + if i == 4 { + sample.iowait += x + } } return sample, nil } diff --git a/agent/collector/disk.go b/agent/collector/disk.go index 37ca8e2..187cbcb 100644 --- a/agent/collector/disk.go +++ b/agent/collector/disk.go @@ -76,12 +76,22 @@ func (d *DiskCollector) Collect() ([]model.DiskStat, error) { } used := total - free + // inode 使用率 + var inodePct float64 + if fs.Files > 0 { + inodePct = float64(fs.Files-fs.Ffree) / float64(fs.Files) * 100.0 + if inodePct < 0 { + inodePct = 0 + } + } + disks = append(disks, model.DiskStat{ - Mount: m.mountpoint, - Device: m.device, - TotalGB: bytesToGB(total), - UsedGB: bytesToGB(used), - UsagePercent: float64(used) / float64(total) * 100.0, + Mount: m.mountpoint, + Device: m.device, + TotalGB: bytesToGB(total), + UsedGB: bytesToGB(used), + UsagePercent: float64(used) / float64(total) * 100.0, + InodeUsagePct: inodePct, }) } return disks, nil diff --git a/agent/collector/diskio.go b/agent/collector/diskio.go index 15c3b1d..ccb043d 100644 --- a/agent/collector/diskio.go +++ b/agent/collector/diskio.go @@ -1,5 +1,5 @@ // Package collector — 磁盘 IO 指标采集器 -// 通过读取 /proc/diskstats 获取磁盘读写 BPS 和 IOPS。 +// 通过读取 /proc/diskstats 获取磁盘读写 BPS、IOPS 和 util(磁盘繁忙程度)。 // // 署名 Hermes package collector @@ -23,6 +23,7 @@ type diskIOSample struct { writeSects uint64 readOps uint64 writeOps uint64 + ioTicks uint64 // io_ticks (field 12) — 磁盘忙碌的毫秒累计 } // DiskIOCollector 磁盘 IO 采集器 @@ -40,7 +41,7 @@ func NewDiskIOCollector() *DiskIOCollector { func (c *DiskIOCollector) Collect() (model.DiskIOStat, error) { stat := model.DiskIOStat{} - rSects, wSects, rOps, wOps, err := readDiskStats() + rSects, wSects, rOps, wOps, ioTicks, err := readDiskStats() if err != nil { return stat, fmt.Errorf("读取磁盘 IO 失败: %w", err) } @@ -55,6 +56,7 @@ func (c *DiskIOCollector) Collect() (model.DiskIOStat, error) { writeSects: wSects, readOps: rOps, writeOps: wOps, + ioTicks: ioTicks, } if c.prev != nil && now.After(c.prev.time) { @@ -65,6 +67,15 @@ func (c *DiskIOCollector) Collect() (model.DiskIOStat, error) { stat.WriteBPS = float64((cur.writeSects-c.prev.writeSects)*sectorSize) / elapsed stat.ReadIOPS = float64(cur.readOps-c.prev.readOps) / elapsed stat.WriteIOPS = float64(cur.writeOps-c.prev.writeOps) / elapsed + // util% = io_ticks_delta_ms / (elapsed_ms) * 100 + ioDelta := cur.ioTicks - c.prev.ioTicks + stat.Util = float64(ioDelta) / (elapsed * 1000.0) * 100.0 + if stat.Util > 100 { + stat.Util = 100 + } + if stat.Util < 0 { + stat.Util = 0 + } } } c.prev = cur @@ -73,11 +84,11 @@ func (c *DiskIOCollector) Collect() (model.DiskIOStat, error) { } // readDiskStats 读取 /proc/diskstats,汇总所有物理磁盘的读写数据 -// fields[2]=设备名, fields[3]=读完成次数, fields[5]=读扇区数, fields[7]=写完成次数, fields[9]=写扇区数 -func readDiskStats() (rSects, wSects, rOps, wOps uint64, err error) { +// fields[2]=设备名, fields[3]=读完成次数, fields[5]=读扇区数, fields[7]=写完成次数, fields[9]=写扇区数, fields[12]=io_ticks +func readDiskStats() (rSects, wSects, rOps, wOps, ioTicks uint64, err error) { f, err := os.Open("/proc/diskstats") if err != nil { - return 0, 0, 0, 0, err + return 0, 0, 0, 0, 0, err } defer f.Close() @@ -103,6 +114,7 @@ func readDiskStats() (rSects, wSects, rOps, wOps uint64, err error) { // field 5 = 读扇区数 (rd_sectors) // field 7 = 写完成次数 (wr_ios) // field 9 = 写扇区数 (wr_sectors) + // field 12 = io_ticks (milliseconds spent doing I/O) if v, e := strconv.ParseUint(fields[3], 10, 64); e == nil { rOps += v } @@ -115,7 +127,10 @@ func readDiskStats() (rSects, wSects, rOps, wOps uint64, err error) { if v, e := strconv.ParseUint(fields[9], 10, 64); e == nil { wSects += v } + if v, e := strconv.ParseUint(fields[12], 10, 64); e == nil { + ioTicks += v + } } - return rSects, wSects, rOps, wOps, scanner.Err() + return rSects, wSects, rOps, wOps, ioTicks, scanner.Err() } diff --git a/agent/collector/mem.go b/agent/collector/mem.go index eb3b1c5..5e7d98f 100644 --- a/agent/collector/mem.go +++ b/agent/collector/mem.go @@ -37,6 +37,8 @@ func (m *MemCollector) Collect() (model.MemoryStat, error) { cachedKB := values["Cached"] availableKB := values["MemAvailable"] freeKB := values["MemFree"] + swapTotalKB := values["SwapTotal"] + swapFreeKB := values["SwapFree"] // 若内核未提供 MemAvailable(较老版本),则按传统方式估算可用内存 if availableKB == 0 { @@ -48,10 +50,18 @@ func (m *MemCollector) Collect() (model.MemoryStat, error) { usedKB = totalKB - availableKB } + // Swap used + swapUsedKB := uint64(0) + if swapTotalKB > swapFreeKB { + swapUsedKB = swapTotalKB - swapFreeKB + } + stat := model.MemoryStat{ - TotalGB: float64(totalKB) * kbToGB, - UsedGB: float64(usedKB) * kbToGB, - CachedGB: float64(buffersKB+cachedKB) * kbToGB, + TotalGB: float64(totalKB) * kbToGB, + UsedGB: float64(usedKB) * kbToGB, + CachedGB: float64(buffersKB+cachedKB) * kbToGB, + AvailableGB: float64(availableKB) * kbToGB, + SwapUsedGB: float64(swapUsedKB) * kbToGB, } if totalKB > 0 { stat.UsagePercent = float64(usedKB) / float64(totalKB) * 100.0 diff --git a/agent/collector/network.go b/agent/collector/network.go index 0cebdeb..01f1b6b 100644 --- a/agent/collector/network.go +++ b/agent/collector/network.go @@ -18,9 +18,11 @@ import ( // netSample 保存上一次的网络采样数据,用于计算速率 type netSample struct { - time time.Time - rxBytes uint64 - txBytes uint64 + time time.Time + rxBytes uint64 + txBytes uint64 + rxErrors uint64 + txErrors uint64 } // NetworkCollector 网络指标采集器 @@ -34,7 +36,7 @@ func NewNetworkCollector() *NetworkCollector { return &NetworkCollector{} } -// Collect 采集网络指标(并发连接数、TIME_WAIT、网卡 rx/tx B/s) +// Collect 采集网络指标(并发连接数、TIME_WAIT、网卡 rx/tx B/s、错误包) func (c *NetworkCollector) Collect() (model.NetworkStat, error) { stat := model.NetworkStat{} @@ -46,8 +48,8 @@ func (c *NetworkCollector) Collect() (model.NetworkStat, error) { } stat.TCPCloseWait = countCloseWait() - // 2. 采集网卡流量(取所有非 lo 网卡合计) - curRx, curTx, nicName, err := readNetDevTotal() + // 2. 采集网卡流量 + 错误包(取所有非 lo 网卡合计) + curRx, curTx, curRxErr, curTxErr, nicName, err := readNetDevTotal() if err != nil { return stat, fmt.Errorf("读取网卡流量失败: %w", err) } @@ -57,7 +59,7 @@ func (c *NetworkCollector) Collect() (model.NetworkStat, error) { defer c.mu.Unlock() now := time.Now() - cur := &netSample{time: now, rxBytes: curRx, txBytes: curTx} + cur := &netSample{time: now, rxBytes: curRx, txBytes: curTx, rxErrors: curRxErr, txErrors: curTxErr} if c.prev != nil && now.After(c.prev.time) { elapsed := now.Sub(c.prev.time).Seconds() @@ -66,6 +68,11 @@ func (c *NetworkCollector) Collect() (model.NetworkStat, error) { stat.NetTxBPS = float64(cur.txBytes-c.prev.txBytes) / elapsed } } + + // 错误包:返回当前累计值(供前端判断是否激增) + stat.NetRxErrors = int64(cur.rxErrors) + stat.NetTxErrors = int64(cur.txErrors) + c.prev = cur return stat, nil @@ -120,11 +127,11 @@ func readSockStat() (tcpSockStat, error) { return result, scanner.Err() } -// readNetDevTotal 读取 /proc/net/dev,取第一个非 lo 且活跃的物理网卡总流量 -func readNetDevTotal() (rxBytes, txBytes uint64, nicName string, err error) { +// readNetDevTotal 读取 /proc/net/dev,取第一个非 lo 且活跃的物理网卡总流量 + 错误包 +func readNetDevTotal() (rxBytes, txBytes, rxErrors, txErrors uint64, nicName string, err error) { f, err := os.Open("/proc/net/dev") if err != nil { - return 0, 0, "", err + return 0, 0, 0, 0, "", err } defer f.Close() @@ -149,26 +156,30 @@ func readNetDevTotal() (rxBytes, txBytes uint64, nicName string, err error) { } fields := strings.Fields(parts[1]) - if len(fields) < 10 { + if len(fields) < 11 { continue } - // fields[0]=rx_bytes, fields[8]=tx_bytes + // fields[0]=rx_bytes, fields[2]=rx_errors, fields[8]=tx_bytes, fields[10]=tx_errors rb, _ := strconv.ParseUint(fields[0], 10, 64) tb, _ := strconv.ParseUint(fields[8], 10, 64) + re, _ := strconv.ParseUint(fields[2], 10, 64) + te, _ := strconv.ParseUint(fields[10], 10, 64) if rb > 0 || tb > 0 { nicName = name rxBytes += rb txBytes += tb + rxErrors += re + txErrors += te } } if nicName == "" { - return 0, 0, "eth0", nil + return 0, 0, 0, 0, "eth0", nil } - return rxBytes, txBytes, nicName, scanner.Err() + return rxBytes, txBytes, rxErrors, txErrors, nicName, scanner.Err() } // countCloseWait 从 /proc/net/tcp 统计 CLOSE_WAIT 连接数 diff --git a/agent/collector/port.go b/agent/collector/port.go index 7ca19ed..90f29eb 100644 --- a/agent/collector/port.go +++ b/agent/collector/port.go @@ -2,6 +2,8 @@ // 本文件实现监听 TCP 端口的自动发现: // - 扫描 /proc/net/tcp 与 /proc/net/tcp6 中状态为 LISTEN(0A) 的套接字; // - 通过遍历 /proc/*/fd 反查套接字 inode 归属的进程。 +// - 内置系统进程排除列表(sshd、rpcbind、master 等永远不上报)。 +// - 支持远程端口策略:whitelist(全量上报,默认)/ blacklist(只上报白名单端口)。 // // 署名 Logan.Li package collector @@ -15,14 +17,47 @@ import ( "ezops/agent/model" ) +// 系统级进程 — 永远不采集其监听端口 +var systemProcesses = map[string]bool{ + "sshd": true, + "rpcbind": true, + "master": true, // Postfix + "systemd": true, + "systemd-resolve": true, + "systemd-journal": true, + "dbus-daemon": true, + "rsyslogd": true, + "irqbalance": true, + "polkitd": true, + "accounts-daemon": true, + "NetworkManager": true, + "udisksd": true, + "upowerd": true, + "rtkit-daemon": true, + "switcheroo-contr": true, + "power-profiles-": true, // power-profiles-daemon + "wpa_supplicant": true, + "avahi-daemon": true, + "cron": true, + "atd": true, + "agetty": true, +} + // PortCollector 监听端口自动发现采集器 -type PortCollector struct{} +type PortCollector struct { + Policy *model.PortPolicy // 可选:后端下发的端口过滤策略 +} // NewPortCollector 创建端口采集器实例 func NewPortCollector() *PortCollector { return &PortCollector{} } +// SetPolicy 设置端口过滤策略(daemon 模式下从后端轮询获得) +func (p *PortCollector) SetPolicy(policy *model.PortPolicy) { + p.Policy = policy +} + // listenSocket 描述一个处于 LISTEN 状态的套接字 type listenSocket struct { port int @@ -47,7 +82,7 @@ func (p *PortCollector) Collect() ([]model.PortStat, error) { // 2. 构建 inode -> 进程信息 映射(仅 Linux 上可用) inodeMap := p.buildInodeMap() - // 3. 组装结果并按 (端口, 协议, PID) 去重 + // 3. 组装结果并按 (端口, 协议, PID) 去重,应用过滤策略 seen := make(map[string]bool) ports := make([]model.PortStat, 0, len(sockets)) for _, s := range sockets { @@ -55,6 +90,22 @@ func (p *PortCollector) Collect() ([]model.PortStat, error) { if info, ok := inodeMap[s.inode]; ok { pid, name = info.pid, info.name } + + // ── 过滤逻辑 ── + // 1) 永远排除系统级进程端口 + if isSystemProcess(name) { + continue + } + + // 2) 黑名单模式:只上报在白名单中的端口 + if p.Policy != nil && p.Policy.IsBlacklistMode() { + if !p.Policy.InWhitelist(s.port) { + continue + } + } + // 白名单模式(默认):全量上报已排除系统进程的端口 + // ── 过滤逻辑结束 ── + key := strconv.Itoa(s.port) + "|" + s.protocol + "|" + strconv.Itoa(pid) if seen[key] { continue @@ -200,3 +251,21 @@ func readProcessName(pid string) string { } return "" } + +// isSystemProcess 判断进程名是否为系统进程(永远不上报的端口) +func isSystemProcess(name string) bool { + if name == "" { + return false + } + // 精确匹配 + if systemProcesses[name] { + return true + } + // 前缀匹配(如 power-profiles-daemon) + for prefix := range systemProcesses { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} diff --git a/agent/main.go b/agent/main.go index c4d896b..d72e1ee 100644 --- a/agent/main.go +++ b/agent/main.go @@ -79,6 +79,9 @@ func main() { } switch cmd { + case "version", "v", "-v", "--version": + fmt.Printf("AIOps Agent %s (built %s) go=%s\n", version, buildTime, runtime.Version()) + return case "output": runOutput(cfg) return @@ -279,10 +282,25 @@ func main() { } } + pullPortPolicy := func() { + policy, err := taskClient.PollPortPolicy(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + // 静默忽略 — 端口策略为可选配置 + return + } + if policy != nil { + portCollector.SetPolicy(policy) + } + } + const pollInterval = 5 * time.Second go func() { pollAndExecute() pullProbes() + pullPortPolicy() ticker := time.NewTicker(pollInterval) defer ticker.Stop() for { @@ -290,6 +308,7 @@ func main() { case <-ticker.C: pollAndExecute() pullProbes() + pullPortPolicy() case <-ctx.Done(): return } @@ -404,6 +423,7 @@ func runOutput(cfg Config) { // CPU pd("cpu.usage_percent", report.CPU.UsagePercent) + pd("cpu.iowait", report.CPU.IOWait) pu("cpu.core_count", report.CPU.CoreCount) pd("cpu.load1", report.CPU.Load1) pd("cpu.load5", report.CPU.Load5) @@ -413,6 +433,8 @@ func runOutput(cfg Config) { pd("memory.total_gb", report.Memory.TotalGB) pd("memory.used_gb", report.Memory.UsedGB) pd("memory.cached_gb", report.Memory.CachedGB) + pd("memory.available_gb", report.Memory.AvailableGB) + pd("memory.swap_used_gb", report.Memory.SwapUsedGB) pd("memory.usage_percent", report.Memory.UsagePercent) // Swap @@ -428,6 +450,7 @@ func runOutput(cfg Config) { pd(prefix+".total_gb", d.TotalGB) pd(prefix+".used_gb", d.UsedGB) pd(prefix+".usage_percent", d.UsagePercent) + pd(prefix+".inode_usage_pct", d.InodeUsagePct) } // 端口 (监听) @@ -457,14 +480,18 @@ func runOutput(cfg Config) { pu("net.tcp.time_wait", report.Network.TCPTimeWait) pu("net.tcp.close_wait", report.Network.TCPCloseWait) pu("net.tcp.total", report.Network.TCPTotal) + pu("net.connection.total", report.Network.TCPEstablished+report.Network.TCPTimeWait+report.Network.TCPCloseWait) pd("net.rx_bps", report.Network.NetRxBPS) pd("net.tx_bps", report.Network.NetTxBPS) + pu64("net.rx_errors", report.Network.NetRxErrors) + pu64("net.tx_errors", report.Network.NetTxErrors) // 磁盘 IO pd("disk_io.read_bps", report.DiskIO.ReadBPS) pd("disk_io.write_bps", report.DiskIO.WriteBPS) pd("disk_io.read_iops", report.DiskIO.ReadIOPS) pd("disk_io.write_iops", report.DiskIO.WriteIOPS) + pd("disk_io.util", report.DiskIO.Util) // 进程 TOP for i, pr := range report.ProcessTop { diff --git a/agent/model/model.go b/agent/model/model.go index 3cea64e..9fc7375 100644 --- a/agent/model/model.go +++ b/agent/model/model.go @@ -28,6 +28,7 @@ type Report struct { // CPUStat CPU 相关指标 type CPUStat struct { UsagePercent float64 `json:"usage_percent"` // CPU 总体使用率(百分比) + IOWait float64 `json:"iowait"` // CPU 等待 IO 的时间占比(百分比) CoreCount int `json:"core_count"` // CPU 逻辑核心数 Load1 float64 `json:"load1"` // 1 分钟平均负载 Load5 float64 `json:"load5"` // 5 分钟平均负载 @@ -39,16 +40,19 @@ type MemoryStat struct { TotalGB float64 `json:"total_gb"` // 总内存(GB) UsedGB float64 `json:"used_gb"` // 已用内存(GB) CachedGB float64 `json:"cached_gb"` // 缓存(Buffers + Cached,GB) + AvailableGB float64 `json:"available_gb"` // 可用内存(GB),含可回收缓存 — 真实剩余可用 + SwapUsedGB float64 `json:"swap_used_gb"` // Swap 已用量(GB)— OOM 金标准 UsagePercent float64 `json:"usage_percent"` // 内存使用率(百分比) } // DiskStat 单个挂载点的磁盘使用情况 type DiskStat struct { - Mount string `json:"mount"` // 挂载点 - Device string `json:"device"` // 设备名 - TotalGB float64 `json:"total_gb"` // 总容量(GB) - UsedGB float64 `json:"used_gb"` // 已用容量(GB) - UsagePercent float64 `json:"usage_percent"` // 使用率(百分比) + Mount string `json:"mount"` // 挂载点 + Device string `json:"device"` // 设备名 + TotalGB float64 `json:"total_gb"` // 总容量(GB) + UsedGB float64 `json:"used_gb"` // 已用容量(GB) + UsagePercent float64 `json:"usage_percent"` // 使用率(百分比) + InodeUsagePct float64 `json:"inode_usage_pct"` // inode 使用率(百分比)— 防止文件数爆炸 } // PortStat 监听端口及其归属进程 diff --git a/agent/model/model_ext.go b/agent/model/model_ext.go index fec7487..79180b8 100644 --- a/agent/model/model_ext.go +++ b/agent/model/model_ext.go @@ -9,6 +9,8 @@ type NetworkStat struct { NicName string `json:"nic_name"` // 主网卡名 NetRxBPS float64 `json:"net_rx_bps"` // 网卡接收速率 (bytes/s) NetTxBPS float64 `json:"net_tx_bps"` // 网卡发送速率 (bytes/s) + NetRxErrors int64 `json:"net_rx_errors"` // 网卡接收错误包累计 — 物理层故障告警 + NetTxErrors int64 `json:"net_tx_errors"` // 网卡发送错误包累计 — 物理层故障告警 } // DiskIOStat 磁盘 IO 相关指标 @@ -17,6 +19,7 @@ type DiskIOStat struct { WriteBPS float64 `json:"write_bps"` // 写入速率 (bytes/s) ReadIOPS float64 `json:"read_iops"` // 读取 IOPS (count/s) WriteIOPS float64 `json:"write_iops"` // 写入 IOPS (count/s) + Util float64 `json:"util"` // 磁盘繁忙程度(百分比)— 判断磁盘是否瓶颈的核心指标 } // SwapStat 交换分区指标(OOM 前兆判断) @@ -28,34 +31,34 @@ type SwapStat struct { // SocketFDStat 系统级 FD 使用情况 type SocketFDStat struct { - Allocated int `json:"allocated"` // 已分配的 file handles (fs.file-nr 第一列) - Max int64 `json:"max"` // 系统上限 (fs.file-max) + Allocated int `json:"allocated"` // 已分配的 file handles (fs.file-nr 第一列) + Max int64 `json:"max"` // 系统上限 (fs.file-max) UsagePct float64 `json:"usage_pct"` } // ContextSwitchStat 上下文切换(决定是否开中断合并/CPU 隔离) type ContextSwitchStat struct { - CTXPerSec float64 `json:"ctx_per_sec"` // 每秒上下文切换次数 - IRQPerSec float64 `json:"irq_per_sec"` // 每秒中断次数 + CTXPerSec float64 `json:"ctx_per_sec"` // 每秒上下文切换次数 + IRQPerSec float64 `json:"irq_per_sec"` // 每秒中断次数 } // AgentHeartbeat Agent 自检心跳(随每次上报附带,极小 payload) type AgentHeartbeat struct { - Version string `json:"version"` - UptimeSec int64 `json:"uptime_sec"` - LastErr string `json:"last_err,omitempty"` - SelfCPU float64 `json:"self_cpu_pct"` - SelfRSSMB float64 `json:"self_rss_mb"` - ReportSeq uint64 `json:"report_seq"` // 单调递增序列号,用于丢包检测 + Version string `json:"version"` + UptimeSec int64 `json:"uptime_sec"` + LastErr string `json:"last_err,omitempty"` + SelfCPU float64 `json:"self_cpu_pct"` + SelfRSSMB float64 `json:"self_rss_mb"` + ReportSeq uint64 `json:"report_seq"` // 单调递增序列号,用于丢包检测 } // SystemStat 系统信息(启动时采集一次) type SystemStat struct { - OSVersion string `json:"os_version"` // OS 版本 (如 "Ubuntu 22.04") - Kernel string `json:"kernel"` // 内核版本 - Uptime int64 `json:"uptime"` // 运行秒数 - UlimitNofile int64 `json:"ulimit_nofile"` // ulimit -n (最大文件描述符) - UlimitNproc int64 `json:"ulimit_nproc"` // ulimit -u (最大进程数) + OSVersion string `json:"os_version"` // OS 版本 (如 "Ubuntu 22.04") + Kernel string `json:"kernel"` // 内核版本 + Uptime int64 `json:"uptime"` // 运行秒数 + UlimitNofile int64 `json:"ulimit_nofile"` // ulimit -n (最大文件描述符) + UlimitNproc int64 `json:"ulimit_nproc"` // ulimit -u (最大进程数) } // ──── v2.1 健康探针 ──── @@ -80,8 +83,30 @@ type ProbeResult struct { ProbeID uint `json:"probe_id"` Name string `json:"name"` ProbeType string `json:"probe_type"` - Status string `json:"status"` // up / down + Status string `json:"status"` // up / down LatencyMs int `json:"latency_ms"` StatusCode int `json:"status_code,omitempty"` Message string `json:"message,omitempty"` } + +// PortPolicy 后端下发的端口过滤策略 // Logan.Li +type PortPolicy struct { + Hostname string `json:"hostname"` + PortFilterMode string `json:"port_filter_mode"` // whitelist / blacklist + PortWhitelist []int `json:"port_whitelist"` // 端口号数组 +} + +// IsBlacklistMode 判断是否为黑名单模式(只上报选中端口) +func (p *PortPolicy) IsBlacklistMode() bool { + return p.PortFilterMode == "blacklist" +} + +// InWhitelist 检查端口是否在白名单中 +func (p *PortPolicy) InWhitelist(port int) bool { + for _, w := range p.PortWhitelist { + if w == port { + return true + } + } + return false +} diff --git a/agent/task/client.go b/agent/task/client.go index 370e0f7..d9212df 100644 --- a/agent/task/client.go +++ b/agent/task/client.go @@ -19,6 +19,7 @@ const ( pollPath = "/api/v1/agent/tasks/poll" resultPathFmt = "/api/v1/agent/tasks/%s/result" probePath = "/api/v1/agent/health-probes/config" + portPolicyPath = "/api/v1/agent/port-policy" ) // Client 负责与后端任务/探针接口通信。 @@ -74,3 +75,18 @@ func (c *Client) PollProbes(ctx context.Context) ([]model.ProbeConfig, error) { } return wrapper.Data, nil } + +// PollPortPolicy 拉取本机端口过滤策略(可选配置,拉取失败静默忽略) // Logan.Li +func (c *Client) PollPortPolicy(ctx context.Context) (*model.PortPolicy, error) { + path := portPolicyPath + "?hostname=" + url.QueryEscape(c.hostname) + + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data model.PortPolicy `json:"data"` + } + if err := c.client.DoJSON(ctx, "GET", path, nil, &wrapper); err != nil { + return nil, fmt.Errorf("端口策略拉取失败: %w", err) + } + return &wrapper.Data, nil +} diff --git a/backend/agent/bin/aiops-agent-linux-amd64 b/backend/agent/bin/aiops-agent-linux-amd64 index e136f11..55c7a3f 100755 Binary files a/backend/agent/bin/aiops-agent-linux-amd64 and b/backend/agent/bin/aiops-agent-linux-amd64 differ diff --git a/backend/agent/bin/aiops-agent-linux-arm64 b/backend/agent/bin/aiops-agent-linux-arm64 index 0ebfb50..e2ff8c1 100755 Binary files a/backend/agent/bin/aiops-agent-linux-arm64 and b/backend/agent/bin/aiops-agent-linux-arm64 differ diff --git a/backend/internal/api/handlers/agent.go b/backend/internal/api/handlers/agent.go index 0abf4b3..6e233ca 100644 --- a/backend/internal/api/handlers/agent.go +++ b/backend/internal/api/handlers/agent.go @@ -62,6 +62,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { report := models.AgentReport{ Hostname: req.Hostname, CPUUsage: req.CPU.UsagePercent, + CPUIOwait: req.CPU.IOWait, CPUCores: req.CPU.CoreCount, Load1: req.CPU.Load1, Load5: req.CPU.Load5, @@ -69,6 +70,8 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { MemTotal: req.Memory.TotalGB, MemUsed: req.Memory.UsedGB, MemUsage: req.Memory.UsagePercent, + MemAvailable: req.Memory.AvailableGB, + MemSwapUsed: req.Memory.SwapUsedGB, DiskInfo: string(diskJSON), PortInfo: string(portJSON), NetInfo: string(netJSON), @@ -110,7 +113,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { // 直接 SQL 更新 asset_servers 监控字段 // 若 AgentVersion 非空则同步写入,否则保留数据库中已有版本号(防止旧版Agent空字段覆盖已设置的版本) updateSQL := `UPDATE asset_servers SET - cpu_usage = ?, memory_usage = ?, disk_usage = ?, + cpu_usage = ?, cpu_iowait = ?, memory_usage = ?, disk_usage = ?, cpu = ?, memory = ?, disk = ?, status = ?, agent_status = ?` + func() string { if req.AgentVersion != "" { @@ -121,7 +124,7 @@ func (h *AgentHandler) ReceiveReport(c *gin.Context) { hostname = CASE WHEN hostname != ? AND ip = ? THEN ? ELSE hostname END, updated_at = NOW()` args := []interface{}{ - req.CPU.UsagePercent, req.Memory.UsagePercent, diskAvg, + req.CPU.UsagePercent, req.CPU.IOWait, req.Memory.UsagePercent, diskAvg, req.CPU.CoreCount, memMB, diskGB, "running", "online", } @@ -528,3 +531,94 @@ func avgDiskUsage(disks []models.DiskMetric) float64 { } return math.Round(sum/float64(len(disks))*100) / 100 } + +// ── 端口监控策略 ── // Logan.Li + +// GetPortPolicyForAgent Agent 拉取本机端口策略(无需鉴权,通过 hostname 查询) +// GET /api/v1/agent/port-policy?hostname=xxx +func (h *AgentHandler) GetPortPolicyForAgent(c *gin.Context) { + hostname := c.Query("hostname") + if hostname == "" { + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: models.AgentPortPolicyDTO{ + PortFilterMode: "blacklist", + PortWhitelist: []int{}, + }}) + return + } + + var policy models.AgentPortPolicy + if err := h.db.Where("hostname = ?", hostname).First(&policy).Error; err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: models.AgentPortPolicyDTO{ + Hostname: hostname, + PortFilterMode: "blacklist", + PortWhitelist: []int{}, + }}) + return + } + + var whitelist []int + json.Unmarshal([]byte(policy.PortWhitelist), &whitelist) + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: models.AgentPortPolicyDTO{ + Hostname: policy.Hostname, + PortFilterMode: policy.PortFilterMode, + PortWhitelist: whitelist, + }}) +} + +// GetPortPolicy 前端获取主机的端口监控策略 +// GET /api/v1/agent/port-policies/:hostname +func (h *AgentHandler) GetPortPolicy(c *gin.Context) { + hostname := c.Param("hostname") + + var policy models.AgentPortPolicy + var whitelist []int + if err := h.db.Where("hostname = ?", hostname).First(&policy).Error; err != nil { + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: models.AgentPortPolicyDTO{ + Hostname: hostname, + PortFilterMode: "blacklist", + PortWhitelist: []int{}, + }}) + return + } + + json.Unmarshal([]byte(policy.PortWhitelist), &whitelist) + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: models.AgentPortPolicyDTO{ + Hostname: policy.Hostname, + PortFilterMode: policy.PortFilterMode, + PortWhitelist: whitelist, + }}) +} + +// SavePortPolicy 更新/创建主机的端口监控策略 +// PUT /api/v1/agent/port-policies/:hostname +func (h *AgentHandler) SavePortPolicy(c *gin.Context) { + hostname := c.Param("hostname") + + var dto models.AgentPortPolicyDTO + if err := c.ShouldBindJSON(&dto); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求格式错误: " + err.Error()}) + return + } + + whitelistJSON := "[]" + if len(dto.PortWhitelist) > 0 { + b, _ := json.Marshal(dto.PortWhitelist) + whitelistJSON = string(b) + } + + policy := models.AgentPortPolicy{ + Hostname: hostname, + PortFilterMode: dto.PortFilterMode, + PortWhitelist: whitelistJSON, + } + + if err := h.db.Where(models.AgentPortPolicy{Hostname: hostname}). + Assign(policy). + FirstOrCreate(&policy).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "保存失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "端口策略已保存"}) +} diff --git a/backend/internal/api/handlers/dashboard.go b/backend/internal/api/handlers/dashboard.go index bdf5da0..eb99fc3 100644 --- a/backend/internal/api/handlers/dashboard.go +++ b/backend/internal/api/handlers/dashboard.go @@ -287,7 +287,7 @@ func (h *DashboardHandler) GetRecentReleases(c *gin.Context) { // @Success 200 {object} models.Response{data=[]models.MetricData} // @Router /dashboard/metrics [get] func (h *DashboardHandler) GetMetrics(c *gin.Context) { - hours := 24 + hours := 6 // 默认6小时,减少扫描行数 if hv := c.Query("hours"); hv != "" { if parsed, err := strconv.Atoi(hv); err == nil && parsed > 0 && parsed <= 168 { hours = parsed diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 68471fd..b315704 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1740,6 +1740,272 @@ func (h *KubernetesHandler) ListNamespaces(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Data: namespaces}) } +// ============================================================================ +// Namespace Management (M14 — 资源配额 + CRUD) +// ============================================================================ + +// NamespaceDetail is the enriched namespace info for the management page +type NamespaceDetail struct { + Name string `json:"name"` + Status string `json:"status"` + Age string `json:"age"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations,omitempty"` + ResourceCounts map[string]int `json:"resource_counts"` + HasResourceQuota bool `json:"has_resource_quota"` + HasLimitRange bool `json:"has_limit_range"` + ResourceQuotas []k8sclient.ResourceQuotaItem `json:"resource_quotas,omitempty"` + LimitRanges []k8sclient.LimitRangeItem `json:"limit_ranges,omitempty"` +} + +// GetNamespacesDetail returns all namespaces with metadata, resource counts and quota info +func (h *KubernetesHandler) GetNamespacesDetail(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + nsList, err := client.ListNamespacesDetailed() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // Efficient: fetch all resources once, group by namespace + depCounts := map[string]int{} + if deps, err := client.ListDeployments(); err == nil { + for _, d := range deps.Items { + depCounts[d.Metadata.Namespace]++ + } + } + podCounts := map[string]int{} + if pods, err := client.ListPods(); err == nil { + for _, p := range pods.Items { + podCounts[p.Metadata.Namespace]++ + } + } + svcCounts := map[string]int{} + if svcs, err := client.ListServices(); err == nil { + for _, s := range svcs.Items { + svcCounts[s.Metadata.Namespace]++ + } + } + // All-namespace ResourceQuotas + quotaByNS := map[string][]k8sclient.ResourceQuotaItem{} + if quotas, err := client.ListResourceQuotas(""); err == nil { + for _, q := range quotas { + quotaByNS[q.Metadata.Namespace] = append(quotaByNS[q.Metadata.Namespace], q) + } + } + // All-namespace LimitRanges + lrByNS := map[string][]k8sclient.LimitRangeItem{} + if lrs, err := client.ListLimitRanges(""); err == nil { + for _, lr := range lrs { + lrByNS[lr.Metadata.Namespace] = append(lrByNS[lr.Metadata.Namespace], lr) + } + } + + result := make([]NamespaceDetail, 0, len(nsList.Items)) + for _, ns := range nsList.Items { + name := ns.Metadata.Name + nd := NamespaceDetail{ + Name: name, + Status: ns.Status.Phase, + Age: formatAge(ns.Metadata.CreationTimestamp), + Labels: ns.Metadata.Labels, + Annotations: ns.Metadata.Annotations, + ResourceCounts: map[string]int{ + "deployments": depCounts[name], + "pods": podCounts[name], + "services": svcCounts[name], + }, + } + if nd.Labels == nil { + nd.Labels = map[string]string{} + } + if qs, ok := quotaByNS[name]; ok && len(qs) > 0 { + nd.HasResourceQuota = true + nd.ResourceQuotas = qs + } + if lrs, ok := lrByNS[name]; ok && len(lrs) > 0 { + nd.HasLimitRange = true + nd.LimitRanges = lrs + } + result = append(result, nd) + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: result}) +} + +// GetNamespaceDetailHandler returns a single namespace detail with quota info +func (h *KubernetesHandler) GetNamespaceDetailHandler(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + nsName := c.Param("namespace") + if nsName == "" { + nsName = c.Param("name") + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + ns, err := client.GetNamespace(nsName) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + nd := NamespaceDetail{ + Name: ns.Metadata.Name, + Status: ns.Status.Phase, + Age: formatAge(ns.Metadata.CreationTimestamp), + Labels: ns.Metadata.Labels, + Annotations: ns.Metadata.Annotations, + ResourceCounts: map[string]int{}, + } + if nd.Labels == nil { + nd.Labels = map[string]string{} + } + + // Fetch resource counts (single namespace) + if deps, err := client.ListDeployments(); err == nil { + count := 0 + for _, d := range deps.Items { + if d.Metadata.Namespace == nsName { count++ } + } + nd.ResourceCounts["deployments"] = count + } + if pods, err := client.ListPods(); err == nil { + count := 0 + for _, p := range pods.Items { + if p.Metadata.Namespace == nsName { count++ } + } + nd.ResourceCounts["pods"] = count + } + if svcs, err := client.ListServices(); err == nil { + count := 0 + for _, s := range svcs.Items { + if s.Metadata.Namespace == nsName { count++ } + } + nd.ResourceCounts["services"] = count + } + + // Fetch quotas + limit ranges for this namespace + if quotas, err := client.ListResourceQuotas(nsName); err == nil && len(quotas) > 0 { + nd.HasResourceQuota = true + nd.ResourceQuotas = quotas + } + if lrs, err := client.ListLimitRanges(nsName); err == nil && len(lrs) > 0 { + nd.HasLimitRange = true + nd.LimitRanges = lrs + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: nd}) +} + +// CreateNamespaceHandler creates a new namespace +func (h *KubernetesHandler) CreateNamespaceHandler(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Labels map[string]string `json:"labels"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "name is required"}) + return + } + + // Safety: block critical system namespaces + systemNS := map[string]bool{ + "kube-system": true, "kube-public": true, "kube-node-lease": true, + "default": true, "kubernetes-dashboard": true, + } + if systemNS[req.Name] { + c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "cannot create system namespace: " + req.Name}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + ns, err := client.CreateNamespace(req.Name, req.Labels) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Data: ns, Message: "命名空间创建成功"}) +} + +// DeleteNamespaceHandler deletes a namespace +func (h *KubernetesHandler) DeleteNamespaceHandler(c *gin.Context) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + if clusterID == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "cluster not specified"}) + return + } + + nsName := c.Param("namespace") + if nsName == "" { + nsName = c.Param("name") + } + + // Safety: block deletion of critical system namespaces + systemNS := map[string]bool{ + "kube-system": true, "kube-public": true, "kube-node-lease": true, + "default": true, "devops": true, + } + if systemNS[nsName] { + c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "禁止删除系统命名空间: " + nsName}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) + return + } + + if err := client.DeleteNamespace(nsName); err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, models.Response{Code: 200, Message: "命名空间已删除"}) +} + // ============================================================================ // ConfigMap & Secret (Kuboard-style namespace resources) // ============================================================================ diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index d5379e2..47fd938 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -389,6 +389,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // Namespace list kubernetesRouter.GET("/:clusterCode/namespaces", k8sHandler.ListNamespaces) + // 🆕 M14: Namespace 管理看板 (detail + CRUD) + kubernetesRouter.GET("/:clusterCode/namespaces/detail", k8sHandler.GetNamespacesDetail) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/detail", k8sHandler.GetNamespaceDetailHandler) + kubernetesRouter.POST("/:clusterCode/namespaces", k8sHandler.CreateNamespaceHandler) + kubernetesRouter.DELETE("/:clusterCode/namespaces/:namespace", k8sHandler.DeleteNamespaceHandler) // All-namespace listing (no ns filter) kubernetesRouter.GET("/:clusterCode/deployments", k8sHandler.ListDeploymentsAllNS) kubernetesRouter.GET("/:clusterCode/pods", k8sHandler.ListPodsAllNS) @@ -411,6 +416,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pvcs", k8sHandler.ListPVCsNS) kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pvcs/:name", k8sHandler.DeletePVCNS) + // RBAC — ServiceAccount / Role / RoleBinding + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/serviceaccounts", k8sHandler.ListServiceAccountsNS) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/roles", k8sHandler.ListRolesNS) + kubernetesRouter.GET("/:clusterCode/namespace/:namespace/rolebindings", k8sHandler.ListRoleBindingsNS) + // StatefulSet / DaemonSet kubernetesRouter.GET("/:clusterCode/namespace/:namespace/statefulsets", k8sHandler.ListStatefulSetsNS) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/daemonsets", k8sHandler.ListDaemonSetsNS) @@ -676,6 +686,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c agentGroup.POST("/exec", agentInstallHandler.RemoteExec) // 健康探针:Agent 拉取本机配置(无需 JWT) agentGroup.GET("/health-probes/config", agentHandler.GetHealthProbesForAgent) + // 端口策略:Agent 拉取本机端口过滤策略(无需 JWT) + agentGroup.GET("/port-policy", agentHandler.GetPortPolicyForAgent) } // Agent 数据查询 + 任务管理(需要 JWT) agentQuery := authorized.Group("/agent") @@ -710,6 +722,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c agentQuery.POST("/health-probes", agentHandler.CreateHealthProbe) agentQuery.PUT("/health-probes/:id", agentHandler.UpdateHealthProbe) agentQuery.DELETE("/health-probes/:id", agentHandler.DeleteHealthProbe) + + // 端口策略(CRUD) + agentQuery.GET("/port-policies/:hostname", agentHandler.GetPortPolicy) + agentQuery.PUT("/port-policies/:hostname", agentHandler.SavePortPolicy) } // Agent 二进制下载(无需鉴权,Agent 升级时使用) diff --git a/backend/internal/models/agent.go b/backend/internal/models/agent.go index 0869d46..d847e6e 100644 --- a/backend/internal/models/agent.go +++ b/backend/internal/models/agent.go @@ -9,6 +9,7 @@ type AgentReport struct { ID uint `gorm:"primarykey" json:"id"` Hostname string `gorm:"size:255;index;not null" json:"hostname"` CPUUsage float64 `gorm:"type:decimal(6,2)" json:"cpu_usage"` + CPUIOwait float64 `gorm:"type:decimal(6,2)" json:"cpu_iowait"` CPUCores int `json:"cpu_cores"` Load1 float64 `gorm:"type:decimal(8,2)" json:"load1"` Load5 float64 `gorm:"type:decimal(8,2)" json:"load5"` @@ -16,6 +17,8 @@ type AgentReport struct { MemTotal float64 `gorm:"type:decimal(10,2)" json:"mem_total"` MemUsed float64 `gorm:"type:decimal(10,2)" json:"mem_used"` MemUsage float64 `gorm:"type:decimal(6,2)" json:"mem_usage"` + MemAvailable float64 `gorm:"type:decimal(10,2)" json:"mem_available"` + MemSwapUsed float64 `gorm:"type:decimal(10,2)" json:"mem_swap_used"` DiskInfo string `gorm:"type:text" json:"disk_info"` PortInfo string `gorm:"type:text" json:"port_info"` NetInfo string `gorm:"type:text" json:"net_info"` @@ -141,6 +144,7 @@ type SystemStatModel struct { type CPUMetric struct { UsagePercent float64 `json:"usage_percent"` + IOWait float64 `json:"iowait"` CoreCount int `json:"core_count"` Load1 float64 `json:"load1"` Load5 float64 `json:"load5"` @@ -151,15 +155,18 @@ type MemMetric struct { TotalGB float64 `json:"total_gb"` UsedGB float64 `json:"used_gb"` CachedGB float64 `json:"cached_gb"` + AvailableGB float64 `json:"available_gb"` + SwapUsedGB float64 `json:"swap_used_gb"` UsagePercent float64 `json:"usage_percent"` } type DiskMetric struct { - Mount string `json:"mount"` - Device string `json:"device"` - TotalGB float64 `json:"total_gb"` - UsedGB float64 `json:"used_gb"` - UsagePercent float64 `json:"usage_percent"` + Mount string `json:"mount"` + Device string `json:"device"` + TotalGB float64 `json:"total_gb"` + UsedGB float64 `json:"used_gb"` + UsagePercent float64 `json:"usage_percent"` + InodeUsagePct float64 `json:"inode_usage_pct"` } type PortMetric struct { @@ -177,6 +184,8 @@ type NetworkStatModel struct { NicName string `json:"nic_name"` NetRxBPS float64 `json:"net_rx_bps"` NetTxBPS float64 `json:"net_tx_bps"` + NetRxErrors int64 `json:"net_rx_errors"` + NetTxErrors int64 `json:"net_tx_errors"` } type DiskIOStatModel struct { @@ -184,4 +193,25 @@ type DiskIOStatModel struct { WriteBPS float64 `json:"write_bps"` ReadIOPS float64 `json:"read_iops"` WriteIOPS float64 `json:"write_iops"` + Util float64 `json:"util"` } + +// AgentPortPolicy 主机端口监控策略 // Logan.Li +type AgentPortPolicy struct { + ID uint `gorm:"primarykey" json:"id"` + Hostname string `gorm:"size:255;uniqueIndex;not null" json:"hostname"` + PortFilterMode string `gorm:"size:20;default:whitelist;not null" json:"port_filter_mode"` // whitelist / blacklist + PortWhitelist string `gorm:"type:text" json:"port_whitelist"` // JSON 数组,如 [80,443,3306,6379] +} + +func (AgentPortPolicy) TableName() string { + return "monitor_agent_port_policy" +} + +// AgentPortPolicyDTO 前端交互用的端口策略格式(含已解析的数组) +type AgentPortPolicyDTO struct { + Hostname string `json:"hostname"` + PortFilterMode string `json:"port_filter_mode"` + PortWhitelist []int `json:"port_whitelist"` +} + diff --git a/backend/internal/models/agent_task.go b/backend/internal/models/agent_task.go index ec3669b..dc6b535 100644 --- a/backend/internal/models/agent_task.go +++ b/backend/internal/models/agent_task.go @@ -32,7 +32,7 @@ type AgentRule struct { ID uint `gorm:"primarykey" json:"id"` Name string `gorm:"size:255;not null" json:"name"` Hostname string `gorm:"size:255" json:"hostname"` // 空表示全部主机 - Metric string `gorm:"size:32;not null" json:"metric"` // cpu/mem/disk/offline/port_down/tcp_time_wait/tcp_close_wait/service_process + Metric string `gorm:"size:32;not null" json:"metric"` // cpu/mem/disk/offline/port_down/tcp_time_wait/tcp_close_wait/service_process/fd Operator string `gorm:"size:10;default:>" json:"operator"` // > >= < <= == Threshold float64 `gorm:"type:decimal(10,2)" json:"threshold"` Port int `json:"port"` // port_down 专用 diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index 89ff04b..ea735c3 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -1023,6 +1023,134 @@ func (c *Client) ListNamespaces() ([]string, error) { return names, nil } +// NamespaceItem holds full namespace metadata +type NamespaceItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec NamespaceSpec `json:"spec"` + Status NamespaceStatus `json:"status"` +} + +// NamespaceSpec holds namespace spec +type NamespaceSpec struct { + Finalizers []string `json:"finalizers"` +} + +// NamespaceStatus holds namespace phase +type NamespaceStatus struct { + Phase string `json:"phase"` +} + +// NamespaceList wraps a list of NamespaceItem +type NamespaceList struct { + Items []NamespaceItem `json:"items"` +} + +// ListNamespacesDetailed returns all namespaces with full metadata +func (c *Client) ListNamespacesDetailed() (*NamespaceList, error) { + var result NamespaceList + if err := c.get("/api/v1/namespaces", &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetNamespace returns a single namespace by name +func (c *Client) GetNamespace(name string) (*NamespaceItem, error) { + var result NamespaceItem + path := fmt.Sprintf("/api/v1/namespaces/%s", name) + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CreateNamespace creates a new namespace +func (c *Client) CreateNamespace(name string, labels map[string]string) (*NamespaceItem, error) { + body := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": name, + "labels": labels, + }, + } + var result NamespaceItem + if err := c.post("/api/v1/namespaces", body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DeleteNamespace deletes a namespace by name +func (c *Client) DeleteNamespace(name string) error { + path := fmt.Sprintf("/api/v1/namespaces/%s", name) + return c.delete(path) +} + +// ResourceQuotaItem represents a K8s ResourceQuota +type ResourceQuotaItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec ResourceQuotaSpec `json:"spec"` + Status ResourceQuotaStatus `json:"status"` +} + +// ResourceQuotaSpec holds hard limits +type ResourceQuotaSpec struct { + Hard map[string]string `json:"hard"` + Scopes []string `json:"scopes,omitempty"` +} + +// ResourceQuotaStatus holds used/hard counts +type ResourceQuotaStatus struct { + Hard map[string]string `json:"hard"` + Used map[string]string `json:"used"` +} + +// ListResourceQuotas returns ResourceQuotas in a namespace, or all namespaces if ns is empty +func (c *Client) ListResourceQuotas(namespace string) ([]ResourceQuotaItem, error) { + var result struct { + Items []ResourceQuotaItem `json:"items"` + } + var path string + if namespace == "" { + path = "/api/v1/resourcequotas" + } else { + path = fmt.Sprintf("/api/v1/namespaces/%s/resourcequotas", namespace) + } + if err := c.get(path, &result); err != nil { + return nil, err + } + return result.Items, nil +} + +// LimitRangeItem represents a K8s LimitRange +type LimitRangeItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec LimitRangeSpec `json:"spec"` +} + +// LimitRangeSpec holds limit items +type LimitRangeSpec struct { + Limits []map[string]interface{} `json:"limits"` +} + +// ListLimitRanges returns LimitRanges in a namespace, or all namespaces if ns is empty +func (c *Client) ListLimitRanges(namespace string) ([]LimitRangeItem, error) { + var result struct { + Items []LimitRangeItem `json:"items"` + } + var path string + if namespace == "" { + path = "/api/v1/limitranges" + } else { + path = fmt.Sprintf("/api/v1/namespaces/%s/limitranges", namespace) + } + if err := c.get(path, &result); err != nil { + return nil, err + } + return result.Items, nil +} + // --- ConfigMap --- // ConfigMapList response @@ -1366,6 +1494,110 @@ func (c *Client) ListEvents(namespace string) (*EventList, error) { return &result, nil } +// ============================================================================ +// RBAC — ServiceAccount / Role / RoleBinding +// ============================================================================ + +// --- ServiceAccount --- + +type ServiceAccountList struct { + Items []ServiceAccountItem `json:"items"` +} + +type ServiceAccountItem struct { + Metadata ObjectMeta `json:"metadata"` + Spec struct { + ImagePullSecrets []struct { + Name string `json:"name"` + } `json:"imagePullSecrets"` + Secrets []struct { + Name string `json:"name"` + } `json:"secrets"` + } `json:"secrets"` +} + +type ServiceAccountRaw struct { + Metadata ObjectMeta `json:"metadata"` + Secrets []struct { + Name string `json:"name"` + } `json:"secrets"` + ImagePullSecrets []struct { + Name string `json:"name"` + } `json:"imagePullSecrets"` +} + +// ListServiceAccounts fetches ServiceAccounts in a namespace +func (c *Client) ListServiceAccounts(namespace string) ([]ServiceAccountRaw, error) { + var result struct { + Items []ServiceAccountRaw `json:"items"` + } + path := fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts", namespace) + if err := c.get(path, &result); err != nil { + return nil, err + } + return result.Items, nil +} + +// --- Role --- + +type RoleList struct { + Items []RoleItem `json:"items"` +} + +type RoleItem struct { + Metadata ObjectMeta `json:"metadata"` + Rules []PolicyRule `json:"rules"` +} + +type PolicyRule struct { + Verbs []string `json:"verbs"` + Resources []string `json:"resources"` + APIGroups []string `json:"apiGroups"` +} + +// ListRoles fetches Roles in a namespace +func (c *Client) ListRoles(namespace string) (*RoleList, error) { + var result RoleList + path := fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/roles", namespace) + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + +// --- RoleBinding --- + +type RoleBindingList struct { + Items []RoleBindingItem `json:"items"` +} + +type RoleBindingItem struct { + Metadata ObjectMeta `json:"metadata"` + RoleRef RoleRef `json:"roleRef"` + Subjects []Subject `json:"subjects"` +} + +type RoleRef struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +type Subject struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +// ListRoleBindings fetches RoleBindings in a namespace +func (c *Client) ListRoleBindings(namespace string) (*RoleBindingList, error) { + var result RoleBindingList + path := fmt.Sprintf("/apis/rbac.authorization.k8s.io/v1/namespaces/%s/rolebindings", namespace) + if err := c.get(path, &result); err != nil { + return nil, err + } + return &result, nil +} + // ============================================================================ // YAML Apply (Dynamic resource apply) // ============================================================================ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3cb3e8b..bb0ca74 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ const KubernetesClusters = lazy(() => import('@/components/kubernetes/Kubernetes const ClusterOverviewPage = lazy(() => import('@/components/kubernetes/ClusterOverviewPage').then(m => ({ default: m.default }))); const NodeDetailPage = lazy(() => import('@/components/kubernetes/NodeDetailPage').then(m => ({ default: m.default }))); const NamespaceOverviewPage = lazy(() => import('@/components/kubernetes/NamespaceOverviewPage').then(m => ({ default: m.default }))); +const NamespaceManagementPage = lazy(() => import('@/components/kubernetes/NamespaceManagementPage').then(m => ({ default: m.default }))); const NamespaceResourcePage = lazy(() => import('@/components/kubernetes/NamespaceResourcePage').then(m => ({ default: m.default }))); const DeploymentDetailPage = lazy(() => import('@/components/kubernetes/DeploymentDetailPage').then(m => ({ default: m.default }))); const WorkloadViewPage = lazy(() => import('@/components/kubernetes/WorkloadViewPage').then(m => ({ default: m.default }))); @@ -121,6 +122,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/dashboard/Dashboard.tsx b/frontend/src/components/dashboard/Dashboard.tsx index b22bf19..e0b86df 100644 --- a/frontend/src/components/dashboard/Dashboard.tsx +++ b/frontend/src/components/dashboard/Dashboard.tsx @@ -85,8 +85,8 @@ export function Dashboard() { useEffect(() => { refreshData(); - // 每 5 分钟自动刷新 - const interval = setInterval(refreshData, 5 * 60 * 1000); + // 每 5 秒自动刷新 + const interval = setInterval(refreshData, 5000); return () => clearInterval(interval); }, [refreshData]); diff --git a/frontend/src/components/kubernetes/ClusterOverviewPage.tsx b/frontend/src/components/kubernetes/ClusterOverviewPage.tsx index 491dbdc..6f0432f 100644 --- a/frontend/src/components/kubernetes/ClusterOverviewPage.tsx +++ b/frontend/src/components/kubernetes/ClusterOverviewPage.tsx @@ -202,9 +202,14 @@ export default function ClusterOverviewPage() { {/* Namespace list */}
-

- 命名空间 -

+
+

+ 命名空间 +

+ + 管理全部 + +
{namespaces.map((ns) => { const Icon = NAMESPACE_ICON[ns] || Container; diff --git a/frontend/src/components/kubernetes/NamespaceManagementPage.tsx b/frontend/src/components/kubernetes/NamespaceManagementPage.tsx new file mode 100644 index 0000000..48cb4df --- /dev/null +++ b/frontend/src/components/kubernetes/NamespaceManagementPage.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, TableHeader, TableBody, TableRow, TableHead, TableCell, +} from '@/components/ui/table'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger, +} from '@/components/ui/dialog'; +import { + RefreshCw, Loader2, ChevronRight, Plus, Trash2, Boxes, Container, Network, + Shield, Activity, AlertTriangle, CheckCircle2, +} from 'lucide-react'; +import { kubernetesApi } from '@/services/api'; + +interface NamespaceDetail { + name: string; + status: string; + age: string; + labels: Record; + resource_counts: Record; + has_resource_quota: boolean; + has_limit_range: boolean; + resource_quotas?: Array<{ + metadata: { name: string }; + status: { hard: Record; used: Record }; + }>; +} + +const SYSTEM_NAMESPACES = new Set([ + 'kube-system', 'kube-public', 'kube-node-lease', 'default', 'devops', +]); + +export default function NamespaceManagementPage() { + const { clusterCode } = useParams<{ clusterCode: string }>(); + const navigate = useNavigate(); + const [namespaces, setNamespaces] = useState([]); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + // Create dialog state + const [createOpen, setCreateOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [creating, setCreating] = useState(false); + + // Delete dialog state + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const loadData = useCallback(async () => { + if (!clusterCode) return; + setLoading(true); + try { + const data = await kubernetesApi.getNamespacesDetail(clusterCode); + setNamespaces(Array.isArray(data) ? data : []); + } catch { + setNamespaces([]); + } finally { + setLoading(false); + } + }, [clusterCode]); + + useEffect(() => { loadData(); }, [loadData]); + + const handleCreate = async () => { + if (!clusterCode || !newName.trim()) return; + setCreating(true); + try { + await kubernetesApi.createNamespace(clusterCode, newName.trim()); + setNewName(''); + setCreateOpen(false); + await loadData(); + } catch (err: any) { + alert(err?.message || '创建失败'); + } finally { + setCreating(false); + } + }; + + const handleDelete = async () => { + if (!clusterCode || !deleteTarget) return; + setDeleting(true); + try { + await kubernetesApi.deleteNamespace(clusterCode, deleteTarget); + setDeleteTarget(null); + await loadData(); + } catch (err: any) { + alert(err?.message || '删除失败'); + } finally { + setDeleting(false); + } + }; + + const filtered = namespaces.filter(ns => + !searchQuery || ns.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const totalDeploys = namespaces.reduce((s, n) => s + (n.resource_counts?.deployments || 0), 0); + const totalPods = namespaces.reduce((s, n) => s + (n.resource_counts?.pods || 0), 0); + const totalSvcs = namespaces.reduce((s, n) => s + (n.resource_counts?.services || 0), 0); + const quotaCount = namespaces.filter(n => n.has_resource_quota).length; + + return ( +
+ {/* Breadcrumb */} +
+ 集群 + + {clusterCode} + + 命名空间管理 +
+ + {/* Header */} +
+
+

+ + 命名空间管理 +

+

命名空间资源配额与生命周期管理

+
+
+ + + + + + + + 创建命名空间 + +
+
+ + setNewName(e.target.value)} + placeholder="例如: my-app" + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +

+ 名称需符合 DNS-1123 标准(小写字母/数字/连字符) +

+
+
+ + + + +
+
+
+
+ + {/* Stats cards */} +
+ + +

命名空间

{namespaces.length}

+ +
+
+ + +

Deployments

{totalDeploys}

+ +
+
+ + +

Pods

{totalPods}

+ +
+
+ + +

Services

{totalSvcs}

+ +
+
+ + +

配额限制

{quotaCount}

+ +
+
+
+ + {/* Search */} +
+ setSearchQuery(e.target.value)} + placeholder="搜索命名空间..." + className="max-w-xs bg-slate-900 border-slate-700" + /> +
+ + {/* Namespace table */} + + + + + + 名称 + 状态 + 年龄 + Deploy + Pods + Svc + 配额 + 标签 + 操作 + + + + {loading ? ( + + 加载中... + + ) : filtered.length === 0 ? ( + + 暂无命名空间 + + ) : ( + filtered.map((ns) => { + const isSystem = SYSTEM_NAMESPACES.has(ns.name); + return ( + + + + + + + {ns.status === 'Active' ? : } + {ns.status} + + + {ns.age} + {ns.resource_counts?.deployments ?? 0} + {ns.resource_counts?.pods ?? 0} + {ns.resource_counts?.services ?? 0} + + {ns.has_resource_quota ? ( + + 有 + + ) : ( + - + )} + + + {ns.labels && Object.keys(ns.labels).length > 0 ? ( +
+ {Object.entries(ns.labels).slice(0, 3).map(([k, v]) => ( + {k}={v} + ))} + {Object.keys(ns.labels).length > 3 && ( + +{Object.keys(ns.labels).length - 3} + )} +
+ ) : ( + - + )} +
+ + {isSystem ? ( + 系统 + ) : ( + + )} + +
+ ); + }) + )} +
+
+
+
+ + {/* Delete confirmation dialog */} + !open && setDeleteTarget(null)}> + + + + 删除命名空间 + + +
+
+

+ ⚠️ 即将删除命名空间 {deleteTarget} 及其中所有资源。 +

+

+ 此操作不可逆,所有 Deployment/Pod/Service/ConfigMap 等资源将被永久删除。 +

+
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx index 63184e7..3663b24 100644 --- a/frontend/src/components/kubernetes/NamespaceResourcePage.tsx +++ b/frontend/src/components/kubernetes/NamespaceResourcePage.tsx @@ -24,7 +24,8 @@ import { useToast } from '@/hooks/use-toast'; type ResourceType = | 'deployments' | 'pods' | 'services' | 'ingresses' | 'configmaps' | 'secrets' | 'hpas' | 'cronjobs' - | 'pvcs' | 'statefulsets' | 'daemonsets'; + | 'pvcs' | 'statefulsets' | 'daemonsets' + | 'serviceaccounts' | 'roles' | 'rolebindings'; const RESOURCE_CONFIG: Record ), }, + serviceaccounts: { + title: 'ServiceAccounts', subtitle: '服务账号(RBAC 身份标识)', icon: Shield, + columns: ['名称', '命名空间', 'Secret数', '镜像拉取凭证', '创建时间', '操作'], + renderRow: (sa, opts) => ( + + {sa.name} + {sa.namespace} + {sa.secret_count ?? 0} + + {sa.image_pull_secrets && sa.image_pull_secrets.length > 0 ? sa.image_pull_secrets.join(', ') : '-'} + + {sa.age || '-'} + e.stopPropagation()}> + + + + ), + }, + roles: { + title: 'Roles', subtitle: '命名空间级角色权限(RBAC)', icon: Shield, + columns: ['名称', '命名空间', '规则数', '权限范围', '创建时间', '操作'], + renderRow: (r, opts) => { + const verbs = r.verbs || []; + const resources = r.resources || []; + 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 ( + + {r.name} + {r.namespace} + {r.rule_count ?? 0} + +
+ verbs: {verbsStr || '-'} + resources: {resStr || '-'} +
+
+ {r.age || '-'} + e.stopPropagation()}> +
+ + +
+
+
+ ); + }, + }, + rolebindings: { + title: 'RoleBindings', subtitle: '角色绑定关系(RBAC 授权)', icon: Shield, + columns: ['名称', '命名空间', '角色引用', '绑定主体', '创建时间', '操作'], + renderRow: (rb, opts) => { + const subjects = rb.subjects || []; + const subjStr = subjects.length > 3 ? subjects.slice(0, 3).join(', ') + ` +${subjects.length - 3}` : subjects.join(', '); + return ( + + {rb.name} + {rb.namespace} + + + {rb.role_ref_kind || '-'}/{rb.role_ref_name || '-'} + + + + {subjStr || '-'} + + {rb.age || '-'} + e.stopPropagation()}> +
+ + +
+
+
+ ); + }, + }, }; export default function NamespaceResourcePage() { @@ -295,6 +378,9 @@ export default function NamespaceResourcePage() { case 'pvcs': res = await kubernetesApi.getPVCs(clusterCode, namespace).catch(() => []); break; case 'statefulsets': res = await kubernetesApi.getStatefulSets(clusterCode, namespace).catch(() => []); break; case 'daemonsets': res = await kubernetesApi.getDaemonSets(clusterCode, namespace).catch(() => []); break; + case 'serviceaccounts': res = await kubernetesApi.getServiceAccounts(clusterCode, namespace).catch(() => []); break; + case 'roles': res = await kubernetesApi.getRoles(clusterCode, namespace).catch(() => []); break; + case 'rolebindings': res = await kubernetesApi.getRoleBindings(clusterCode, namespace).catch(() => []); break; } setData(Array.isArray(res) ? res : (res?.list || res?.data || [])); } catch { setData([]); } finally { setLoading(false); } @@ -332,6 +418,9 @@ export default function NamespaceResourcePage() { { type: 'secrets' as ResourceType, label: 'Secrets', icon: Shield }, { type: 'hpas' as ResourceType, label: 'HPAs', icon: Activity }, { type: 'pvcs' as ResourceType, label: '存储卷', icon: Boxes }, + { type: 'serviceaccounts' as ResourceType, label: 'ServiceAccounts', icon: Shield }, + { type: 'roles' as ResourceType, label: 'Roles', icon: Shield }, + { type: 'rolebindings' as ResourceType, label: 'RoleBindings', icon: Shield }, ], []); if (!config) return
未知资源类型: {type}
; diff --git a/frontend/src/components/monitor/dashboard/HostMonitorDashboard.tsx b/frontend/src/components/monitor/dashboard/HostMonitorDashboard.tsx index 2ca0c77..4cc5f42 100644 --- a/frontend/src/components/monitor/dashboard/HostMonitorDashboard.tsx +++ b/frontend/src/components/monitor/dashboard/HostMonitorDashboard.tsx @@ -37,7 +37,7 @@ export default function HostMonitorDashboard() { const [hosts, setHosts] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(15); + const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -441,9 +441,8 @@ export default function HostMonitorDashboard() { 10 - 15 20 - 50 + 30 条,共 {total} 条 diff --git a/frontend/src/pages/HostMonitor.tsx b/frontend/src/pages/HostMonitor.tsx index 28f3d1a..20158a2 100644 --- a/frontend/src/pages/HostMonitor.tsx +++ b/frontend/src/pages/HostMonitor.tsx @@ -102,6 +102,36 @@ export function HostMonitor() { }).catch(() => setProbes([])); }, [selectedHost]); + // 端口策略 // Logan.Li + const [portPolicyMode, setPortPolicyMode] = useState('blacklist'); + const [portWhitelist, setPortWhitelist] = useState([]); + const [portPolicyDialogOpen, setPortPolicyDialogOpen] = useState(false); + const [portPolicySaving, setPortPolicySaving] = useState(false); + + const fetchPortPolicy = useCallback(() => { + if (!selectedHost) return; + agentApi.getPortPolicy(selectedHost).then((data: any) => { + if (data?.data) { + setPortPolicyMode(data.data.port_filter_mode || 'blacklist'); + setPortWhitelist(data.data.port_whitelist || []); + } + }).catch(() => {}); + }, [selectedHost]); + + useEffect(() => { fetchPortPolicy(); }, [fetchPortPolicy]); + + const handleSavePortPolicy = async () => { + if (!selectedHost) return; + setPortPolicySaving(true); + try { + await agentApi.savePortPolicy(selectedHost, { + port_filter_mode: portPolicyMode, + port_whitelist: portWhitelist, + }); + setPortPolicyDialogOpen(false); + } catch { } finally { setPortPolicySaving(false); } + }; + useEffect(() => { fetchProbes(); }, [fetchProbes]); @@ -143,25 +173,35 @@ export function HostMonitor() { const rawRx: number[] = []; const rawTx: number[] = []; const rawIoR: number[] = []; const rawIoW: number[] = []; const rawSwap: number[] = []; const rawFD: number[] = []; + // ──── v2.2 新增指标 ──── + const rawIOWait: number[] = []; const rawMemAvail: number[] = []; + const rawIoUtil: number[] = []; const rawConnTotal: number[] = []; + const rawRxErr: number[] = []; const rawTxErr: number[] = []; const timestamps: number[] = []; for (const r of history) { const ts = r.report_time ? new Date(r.report_time).getTime() / 1000 : (r.created_at ? new Date(r.created_at).getTime() / 1000 : 0); timestamps.push(ts); rawCpu.push(r.cpu_usage || 0); + rawIOWait.push(r.cpu_iowait || 0); rawMem.push(r.mem_usage || 0); + rawMemAvail.push(r.mem_available || 0); rawLoad1.push(r.load1 || 0); rawDisk.push(r.disk_usage || 0); const net = parseJSON(r.net_info); rawTcp.push(net.tcp_established || 0); rawTw.push(net.tcp_time_wait || 0); + rawConnTotal.push((net.tcp_established || 0) + (net.tcp_time_wait || 0) + (net.tcp_close_wait || 0)); rawRx.push(net.net_rx_bps || 0); rawTx.push(net.net_tx_bps || 0); + rawRxErr.push(net.net_rx_errors || 0); + rawTxErr.push(net.net_tx_errors || 0); const dio = parseJSON(r.disk_io_info); rawIoR.push(dio.read_bps || 0); rawIoW.push(dio.write_bps || 0); + rawIoUtil.push(dio.util || 0); const swap = parseJSON(r.swap_info); rawSwap.push(swap?.usage_percent || 0); @@ -183,6 +223,13 @@ export function HostMonitor() { ioWrite: bucketWithStats(rawIoW.map(v => v / 1024), timestamps, timeConfig.buckets), swap: bucketWithStats(rawSwap, timestamps, timeConfig.buckets), fd: bucketWithStats(rawFD, timestamps, timeConfig.buckets), + // ──── v2.2 新增指标 ──── + iowait: bucketWithStats(rawIOWait, timestamps, timeConfig.buckets), + memAvail: bucketWithStats(rawMemAvail, timestamps, timeConfig.buckets), + ioUtil: bucketWithStats(rawIoUtil, timestamps, timeConfig.buckets), + connTotal: bucketWithStats(rawConnTotal, timestamps, timeConfig.buckets), + rxErr: bucketWithStats(rawRxErr, timestamps, timeConfig.buckets), + txErr: bucketWithStats(rawTxErr, timestamps, timeConfig.buckets), }; }, [history, timeConfig]); @@ -280,8 +327,14 @@ export function HostMonitor() { } data={metrics.cpu} color="#3b82f6" unit="%" domain={[0, 100]} aggType={aggType} /> + } + data={metrics.iowait} color="#f97316" unit="%" domain={[0, 100]} aggType={aggType} /> } data={metrics.mem} color="#10b981" unit="%" domain={[0, 100]} aggType={aggType} /> + } + data={metrics.memAvail} color="#34d399" unit="GB" domain={[0, 'auto']} aggType={aggType} /> + } + data={metrics.swap} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} /> } data={metrics.load1} color="#8b5cf6" unit="" domain={[0, 'auto']} aggType={aggType} /> @@ -295,6 +348,8 @@ export function HostMonitor() { data={metrics.ioRead} color="#3b82f6" unit="KB/s" domain={[0, 'auto']} aggType={aggType} /> } data={metrics.ioWrite} color="#10b981" unit="KB/s" domain={[0, 'auto']} aggType={aggType} /> + } + data={metrics.ioUtil} color="#ef4444" unit="%" domain={[0, 100]} aggType={aggType} />
@@ -305,6 +360,8 @@ export function HostMonitor() { data={metrics.tcpEst} color="#8b5cf6" unit="个" domain={[0, 'auto']} aggType={aggType} /> } data={metrics.tcpTW} color="#f59e0b" unit="个" domain={[0, 'auto']} aggType={aggType} /> + } + data={metrics.connTotal} color="#22d3ee" unit="个" domain={[0, 'auto']} aggType={aggType} />
{/* 网卡带宽吞吐 — 双线 rx/tx */} +
+ } + data={metrics.rxErr} color="#ef4444" unit="个" domain={[0, 'auto']} aggType={aggType} /> + } + data={metrics.txErr} color="#f97316" unit="个" domain={[0, 'auto']} aggType={aggType} /> +
{/* ═══ 端口监控 ═══ */} @@ -322,6 +385,22 @@ export function HostMonitor() {

监听端口 {portInfo.length} + + {portPolicyMode === 'blacklist' && portWhitelist.length > 0 && ( + + 黑名单: {portWhitelist.join(',')} + + )} + {portPolicyMode === 'whitelist' && ( + + 白名单模式 + + )} + +

{portInfo.length === 0 ? (
@@ -492,6 +571,39 @@ export function HostMonitor() { )} + {/* FD 文件句柄 + 上下文切换 */} + {(fdInfo || csInfo) && ( + + +
+ +

FD 文件句柄 & 上下文切换

+
+
+ {fdInfo?.allocated != null && [ + ['FD 已分配', {fdInfo.allocated.toLocaleString()}], + ['FD 上限', {fdInfo.max === -1 ? '无限制' : fdInfo.max?.toLocaleString() || '—'}], + ['FD 使用率', 80 ? 'text-rose-400' : 'text-[var(--dg-text-primary)]'}`}>{fdInfo.usage_pct != null ? `${fdInfo.usage_pct.toFixed(1)}%` : '—'}], + ].map(([label, val]) => ( +
+ {label}: + {val} +
+ ))} + {csInfo?.ctx_per_sec != null && [ + ['CTX 累计', {(csInfo.ctx_per_sec / 1e6).toFixed(1)}M], + ['IRQ 累计', {(csInfo.irq_per_sec / 1e6).toFixed(1)}M], + ].map(([label, val]) => ( +
+ {label}: + {val} +
+ ))} +
+
+
+ )} + {/* 磁盘分区 + 网络接口 (保持原样) */} {latest?.disk_info && (() => { const disk = parseJSON(latest.disk_info); @@ -695,6 +807,73 @@ export function HostMonitor() {
)} + + {/* 端口策略对话框 */} + {portPolicyDialogOpen && ( +
setPortPolicyDialogOpen(false)}> +
e.stopPropagation()}> +

端口监控策略

+
+ {/* 模式选择 */} +
+ +
+ + +
+

+ {portPolicyMode === 'whitelist' + ? '所有发现的端口都上报,任意端口变化都会触发告警 + 恢复通知。' + : '仅上报选中端口的变化,其余端口变化静默忽略(不告警)。系统进程端口(sshd/rpcbind/systemd等)永远忽略。'} +

+
+ + {/* 端口白名单 */} +
+ +