From 5366160a5d821755cbd74a2c23f0a21614bda5e0 Mon Sep 17 00:00:00 2001 From: RuoJi6 <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:40 -0700 Subject: [PATCH] feat: add container resource monitoring --- server/server.go | 7 +- server/system_metrics.go | 102 ++++++++++++++++++++++++++ server/system_metrics_test.go | 27 +++++++ web/src/app/(main)/dashboard/page.tsx | 46 ++++++++++++ web/src/lib/api.ts | 2 + web/src/lib/types.ts | 16 ++++ 6 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 server/system_metrics.go create mode 100644 server/system_metrics_test.go diff --git a/server/server.go b/server/server.go index db3d1c8..2cd3610 100644 --- a/server/server.go +++ b/server/server.go @@ -43,6 +43,10 @@ type Server struct { m *Manager engine *Engine ctx context.Context + dataDir string + metricsMu sync.Mutex + lastCPUUse uint64 + lastCPUAt time.Time skillDir string // root directory for skill subdirectories jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key @@ -152,7 +156,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi if err != nil { log.Fatalf("[auth] JWT key: %v", err) } - s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{}, + s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, dataDir: dataDir, jwtKey: key, chatBusy: map[string]bool{}, chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, profChatAgents: map[int64]*agent.ChatAgent{}, @@ -713,6 +717,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/health", s.health) mux.HandleFunc("GET /api/stats", s.stats) + mux.HandleFunc("GET /api/system/metrics", s.systemMetrics) mux.HandleFunc("GET /api/logs", s.getLogs) mux.HandleFunc("GET /api/logs/history", s.getLogsHistory) mux.HandleFunc("GET /api/logs/stream", s.streamLogs) diff --git a/server/system_metrics.go b/server/system_metrics.go new file mode 100644 index 0000000..53a632a --- /dev/null +++ b/server/system_metrics.go @@ -0,0 +1,102 @@ +package server + +// Container-level resource metrics. These deliberately read cgroups directly: +// generic host libraries often report the host's capacity from inside Docker. + +import ( + "encoding/json" + "os" + "net/http" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +type metricValue struct { + UsedBytes uint64 `json:"used_bytes"` + TotalBytes uint64 `json:"total_bytes"` + Percent float64 `json:"percent"` +} + +type systemMetricsResponse struct { + Scope string `json:"scope"` + Timestamp time.Time `json:"timestamp"` + CPU metricValue `json:"cpu"` + Memory metricValue `json:"memory"` + Disk metricValue `json:"disk"` + DataDisk metricValue `json:"data_disk"` + Container bool `json:"container_metrics"` +} + +func (s *Server) systemMetrics(w http.ResponseWriter, r *http.Request) { + cpuUse, cpuLimit, ok := cgroupCPU() + cpuPct := 0.0 + if ok { + now := time.Now() + s.metricsMu.Lock() + if !s.lastCPUAt.IsZero() && cpuUse >= s.lastCPUUse { + elapsed := now.Sub(s.lastCPUAt).Seconds() + if elapsed > 0 { + cpuPct = float64(cpuUse-s.lastCPUUse) / 1e6 / elapsed / cpuLimit * 100 + } + } + s.lastCPUUse, s.lastCPUAt = cpuUse, now + s.metricsMu.Unlock() + } + memUsed, memTotal, memOK := cgroupMemory() + root := diskMetric("/") + data := diskMetric(s.dataDir) + if data.TotalBytes == 0 { data = root } + containerMetrics := ok || memOK + scope := "process" + if containerMetrics { + scope = "container" + } + out := systemMetricsResponse{Scope: scope, Timestamp: time.Now().UTC(), Container: containerMetrics, + CPU: metricValue{Percent: clampPercent(cpuPct)}, + Memory: metricValue{UsedBytes: memUsed, TotalBytes: memTotal, Percent: percent(memUsed, memTotal)}, Disk: root, DataDisk: data} + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +func cgroupCPU() (uint64, float64, bool) { + for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/cpu"} { + b, err := os.ReadFile(filepath.Join(dir, "cpu.stat")); if err != nil { continue } + var use uint64 + for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) == 2 && f[0] == "usage_usec" { use, _ = strconv.ParseUint(f[1], 10, 64) } } + limit := float64(runtime.NumCPU()) + if q, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil { f := strings.Fields(string(q)); if len(f) == 2 && f[0] != "max" { quota, _ := strconv.ParseFloat(f[0], 64); period, _ := strconv.ParseFloat(f[1], 64); if period > 0 { limit = quota / period } } } + if use > 0 { return use, limit, true } + } + return 0, 0, false +} + +func cgroupMemory() (uint64, uint64, bool) { + for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/memory"} { + cur, e1 := readUint(filepath.Join(dir, "memory.current")); max, e2 := readUint(filepath.Join(dir, "memory.max")) + if e1 == nil && e2 == nil { return cur, max, max > 0 } + if e1 == nil { + if b, err := os.ReadFile(filepath.Join(dir, "memory.max")); err == nil && strings.TrimSpace(string(b)) == "max" { + return cur, hostMemoryTotal(), true + } + } + cur, e1 = readUint(filepath.Join(dir, "memory.usage_in_bytes")); max, e2 = readUint(filepath.Join(dir, "memory.limit_in_bytes")); if e1 == nil && e2 == nil && max < ^uint64(0)/2 { return cur, max, true } + } + return 0, 0, false +} + +func hostMemoryTotal() uint64 { + b, err := os.ReadFile("/proc/meminfo"); if err != nil { return 0 } + for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) >= 2 && f[0] == "MemTotal:" { n, _ := strconv.ParseUint(f[1], 10, 64); return n * 1024 } } + return 0 +} + +func readUint(path string) (uint64, error) { b, err := os.ReadFile(path); if err != nil { return 0, err }; return strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) } + +func diskMetric(path string) metricValue { var st syscall.Statfs_t; if path == "" || syscall.Statfs(path, &st) != nil { return metricValue{} }; total := st.Blocks * uint64(st.Bsize); free := st.Bavail * uint64(st.Bsize); used := total - free; return metricValue{UsedBytes: used, TotalBytes: total, Percent: percent(used, total)} } +func percent(used, total uint64) float64 { if total == 0 { return 0 }; return clampPercent(float64(used) / float64(total) * 100) } +func clampPercent(v float64) float64 { if v < 0 { return 0 }; if v > 100 { return 100 }; return v } diff --git a/server/system_metrics_test.go b/server/system_metrics_test.go new file mode 100644 index 0000000..46cd221 --- /dev/null +++ b/server/system_metrics_test.go @@ -0,0 +1,27 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +func TestSystemMetricsReturnsContainerAndDiskValues(t *testing.T) { + s := &Server{dataDir: t.TempDir()} + r := httptest.NewRequest("GET", "/api/system/metrics", nil) + w := httptest.NewRecorder() + s.systemMetrics(w, r) + if w.Code != 200 { + t.Fatalf("status=%d, want 200", w.Code) + } + var got systemMetricsResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Scope == "" || got.Timestamp.IsZero() { + t.Fatalf("unexpected response metadata: %+v", got) + } + if got.Disk.TotalBytes == 0 || got.DataDisk.TotalBytes == 0 { + t.Fatalf("disk metrics missing: %+v", got) + } +} diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index ec79ed8..6a6c199 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -13,6 +13,9 @@ import { ShieldCheckIcon, TargetIcon, ZapIcon, + CpuIcon, + HardDriveIcon, + MemoryStickIcon, } from "lucide-react"; import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts"; @@ -38,6 +41,7 @@ import type { Tool, TrafficExchange, UsageStats, + SystemMetrics, } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -69,6 +73,15 @@ function fmtTokens(n: number): string { return String(n); } +function fmtBytes(n: number): string { + if (!n) return "—"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = n; + let i = 0; + while (value >= 1024 && i < units.length - 1) { value /= 1024; i++; } + return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} + const ASSET_TYPE_LABELS: Record = { root_domain: "根域名", ip: "IP", @@ -147,6 +160,15 @@ export default function DashboardPage() { const [skills, setSkills] = React.useState([]); const [tools, setTools] = React.useState([]); const [llmProfiles, setLLMProfiles] = React.useState([]); + const [systemMetrics, setSystemMetrics] = React.useState(null); + + React.useEffect(() => { + let alive = true; + const load = async () => { try { const m = await api.systemMetrics(); if (alive) setSystemMetrics(m); } catch { /* retry */ } }; + void load(); + const timer = setInterval(load, 5000); + return () => { alive = false; clearInterval(timer); }; + }, []); // The task list is the most expensive dashboard source. Poll it independently // so a large history cannot hold back every other dashboard panel. @@ -1129,6 +1151,30 @@ export default function DashboardPage() { 系统状态 + {systemMetrics && ( +
+
+ 容器资源 + 每 5 秒刷新 +
+
+ {([ + [CpuIcon, "CPU", systemMetrics.cpu], + [MemoryStickIcon, "内存", systemMetrics.memory], + [HardDriveIcon, "磁盘", systemMetrics.data_disk], + ] as const).map(([Icon, label, metric]) => ( +
+
{label}
+ +
{metric.percent.toFixed(1)}%
+ {label !== "CPU" &&
{fmtBytes(metric.used_bytes)} / {fmtBytes(metric.total_bytes)}
} +
+ ))} +
+
监控范围:{systemMetrics.scope === "container" ? "ARTEX 容器" : "当前进程(非容器运行)"}
+
+ )} +
LLM 配置
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 40b7745..5b6e5d5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -116,6 +116,7 @@ import type { UsageStats, WorkspaceFile, WorkspaceListing, + SystemMetrics, } from "@/lib/types"; function getToken(): string | null { @@ -292,6 +293,7 @@ function schedulePayload(input: ScheduleInput) { export const api = { // 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。 health: () => get<{ ok: boolean; service: string; version: string }>("/health"), + systemMetrics: () => get("/system/metrics"), // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"), diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 89b1df8..94e0619 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1331,6 +1331,22 @@ export interface Stats { active_task?: Partial; } +export interface SystemMetricValue { + used_bytes: number; + total_bytes: number; + percent: number; +} + +export interface SystemMetrics { + scope: "container" | string; + timestamp: string; + container_metrics: boolean; + cpu: SystemMetricValue; + memory: SystemMetricValue; + disk: SystemMetricValue; + data_disk: SystemMetricValue; +} + // ---- Intercept Rules ---- export type InterceptAction = "allow" | "deny" | "ask"; export type InterceptMatchTarget = "tool_name" | "tool_input";