feat: add container resource monitoring

This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-29 10:28:40 -07:00
1 parent 5527120089
commit 5366160a5d
6 files changed
+199 -1

No files matched your search

+6 -1
View File
@@ -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)
+102
View File
@@ -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 }
+27
View File
@@ -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)
}
}
+46
View File
@@ -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<string, string> = {
root_domain: "根域名",
ip: "IP",
@@ -147,6 +160,15 @@ export default function DashboardPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
const [systemMetrics, setSystemMetrics] = React.useState<SystemMetrics | null>(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() {
<Card className="p-4">
<SectionTitle icon={ShieldCheckIcon}>系统状态</SectionTitle>
{systemMetrics && (
<div className="mb-3 rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-medium">容器资源</span>
<span className="text-[9px] text-muted-foreground">每 5 秒刷新</span>
</div>
<div className="grid grid-cols-3 gap-2">
{([
[CpuIcon, "CPU", systemMetrics.cpu],
[MemoryStickIcon, "内存", systemMetrics.memory],
[HardDriveIcon, "磁盘", systemMetrics.data_disk],
] as const).map(([Icon, label, metric]) => (
<div key={label} className="min-w-0">
<div className="mb-1 flex items-center gap-1 text-[9px] text-muted-foreground"><Icon className="size-3" />{label}</div>
<Progress value={metric.percent} className="h-1.5" />
<div className="mt-1 truncate text-[9px] tabular-nums">{metric.percent.toFixed(1)}%</div>
{label !== "CPU" && <div className="truncate text-[9px] text-muted-foreground">{fmtBytes(metric.used_bytes)} / {fmtBytes(metric.total_bytes)}</div>}
</div>
))}
</div>
<div className="mt-2 text-[9px] text-muted-foreground">监控范围:{systemMetrics.scope === "container" ? "ARTEX 容器" : "当前进程(非容器运行)"}</div>
</div>
)}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-[10px] text-muted-foreground">LLM 配置</div>
+2
View File
@@ -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<SystemMetrics>("/system/metrics"),
// ---- auth ----
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
+16
View File
@@ -1331,6 +1331,22 @@ export interface Stats {
active_task?: Partial<Task>;
}
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";