From df9d38af0d02c83e85403d9e458db46525991a14 Mon Sep 17 00:00:00 2001 From: RuoJi6 <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:41:04 -0700 Subject: [PATCH] fix: support non-Linux resource status builds --- server/system_metrics.go | 75 +------------------ server/system_metrics_linux.go | 62 +++++++++++++++ server/system_metrics_test.go | 7 ++ server/system_metrics_unsupported.go | 9 +++ .../app/(main)/_components/main-content.tsx | 42 +++++++++++ web/src/app/(main)/dashboard/page.tsx | 46 ------------ web/src/lib/types.ts | 3 +- 7 files changed, 124 insertions(+), 120 deletions(-) create mode 100644 server/system_metrics_linux.go create mode 100644 server/system_metrics_unsupported.go diff --git a/server/system_metrics.go b/server/system_metrics.go index 53a632a..b8b23f1 100644 --- a/server/system_metrics.go +++ b/server/system_metrics.go @@ -1,17 +1,8 @@ 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" ) @@ -23,6 +14,7 @@ type metricValue struct { type systemMetricsResponse struct { Scope string `json:"scope"` + Supported bool `json:"supported"` Timestamp time.Time `json:"timestamp"` CPU metricValue `json:"cpu"` Memory metricValue `json:"memory"` @@ -32,71 +24,8 @@ type systemMetricsResponse struct { } 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} + out := systemMetricsSnapshot(s) 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_linux.go b/server/system_metrics_linux.go new file mode 100644 index 0000000..eb7510e --- /dev/null +++ b/server/system_metrics_linux.go @@ -0,0 +1,62 @@ +//go:build linux + +package server + +import ( + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +func systemMetricsSnapshot(s *Server) systemMetricsResponse { + use, limit, cpuOK := cgroupCPU() + cpuPct := 0.0 + if cpuOK { + now := time.Now() + s.metricsMu.Lock() + if !s.lastCPUAt.IsZero() && use >= s.lastCPUUse && limit > 0 { + elapsed := now.Sub(s.lastCPUAt).Seconds() + if elapsed > 0 { cpuPct = float64(use-s.lastCPUUse) / 1e6 / elapsed / limit * 100 } + } + s.lastCPUUse, s.lastCPUAt = use, now + s.metricsMu.Unlock() + } + memUsed, memTotal, memOK := cgroupMemory() + root, data := diskMetric("/"), diskMetric(s.dataDir) + if data.TotalBytes == 0 { data = root } + return systemMetricsResponse{Scope: "container", Supported: true, Timestamp: time.Now().UTC(), Container: cpuOK || memOK, + CPU: metricValue{Percent: clampPercent(cpuPct)}, Memory: metricValue{UsedBytes: memUsed, TotalBytes: memTotal, Percent: percent(memUsed, memTotal)}, Disk: root, DataDisk: data} +} + +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, ce := readUint(filepath.Join(dir, "memory.current")); max, me := readUint(filepath.Join(dir, "memory.max")) + if ce == nil && me == nil { return cur, max, max > 0 } + if ce == nil { if b, err := os.ReadFile(filepath.Join(dir, "memory.max")); err == nil && strings.TrimSpace(string(b)) == "max" { return cur, hostMemoryTotal(), true } } + cur, ce = readUint(filepath.Join(dir, "memory.usage_in_bytes")); max, me = readUint(filepath.Join(dir, "memory.limit_in_bytes")) + if ce == nil && me == 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, free := st.Blocks*uint64(st.Bsize), 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 index 46cd221..d498ad7 100644 --- a/server/system_metrics_test.go +++ b/server/system_metrics_test.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "net/http/httptest" + "runtime" "testing" ) @@ -21,6 +22,12 @@ func TestSystemMetricsReturnsContainerAndDiskValues(t *testing.T) { if got.Scope == "" || got.Timestamp.IsZero() { t.Fatalf("unexpected response metadata: %+v", got) } + if runtime.GOOS != "linux" { + if got.Supported || got.Scope != "unsupported" { + t.Fatalf("unsupported platform returned metrics: %+v", got) + } + return + } if got.Disk.TotalBytes == 0 || got.DataDisk.TotalBytes == 0 { t.Fatalf("disk metrics missing: %+v", got) } diff --git a/server/system_metrics_unsupported.go b/server/system_metrics_unsupported.go new file mode 100644 index 0000000..c3d78a5 --- /dev/null +++ b/server/system_metrics_unsupported.go @@ -0,0 +1,9 @@ +//go:build !linux + +package server + +import "time" + +func systemMetricsSnapshot(_ *Server) systemMetricsResponse { + return systemMetricsResponse{Scope: "unsupported", Supported: false, Timestamp: time.Now().UTC()} +} diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index d6951cf..ecb42c3 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -6,9 +6,11 @@ import { usePathname } from "next/navigation"; import { Separator } from "@/components/ui/separator"; import { SidebarTrigger } from "@/components/ui/sidebar"; +import { CpuIcon, HardDriveIcon, MemoryStickIcon } from "lucide-react"; import { useCurrentUser } from "@/hooks/use-current-user"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import type { SystemMetrics } from "@/lib/types"; import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner"; import { LiveClock } from "./live-clock"; @@ -76,6 +78,7 @@ function MainContentInner({ children }: { children: ReactNode }) {
+
@@ -95,3 +98,42 @@ function MainContentInner({ children }: { children: ReactNode }) { ); } + +function SystemResourceNavbar() { + const [metrics, setMetrics] = useState(null); + + useEffect(() => { + let alive = true; + const load = async () => { + try { + const next = await api.systemMetrics(); + if (alive) setMetrics(next); + } catch { + /* Keep the status bar quiet while the API is unavailable. */ + } + }; + void load(); + const timer = setInterval(load, 5000); + return () => { alive = false; clearInterval(timer); }; + }, []); + + if (!metrics) return null; + if (!metrics.supported) { + return
资源状态:暂不支持
; + } + const items = [ + [CpuIcon, "CPU", metrics.cpu.percent], + [MemoryStickIcon, "内存", metrics.memory.percent], + [HardDriveIcon, "磁盘", metrics.data_disk.percent], + ] as const; + return ( +
+ 容器资源 + {items.map(([Icon, label, value]) => ( + + + ))} +
+ ); +} diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index 6a6c199..ec79ed8 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -13,9 +13,6 @@ import { ShieldCheckIcon, TargetIcon, ZapIcon, - CpuIcon, - HardDriveIcon, - MemoryStickIcon, } from "lucide-react"; import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts"; @@ -41,7 +38,6 @@ import type { Tool, TrafficExchange, UsageStats, - SystemMetrics, } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -73,15 +69,6 @@ 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", @@ -160,15 +147,6 @@ 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. @@ -1151,30 +1129,6 @@ 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/types.ts b/web/src/lib/types.ts index 94e0619..33de71f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1338,7 +1338,8 @@ export interface SystemMetricValue { } export interface SystemMetrics { - scope: "container" | string; + scope: "container" | "unsupported" | string; + supported: boolean; timestamp: string; container_metrics: boolean; cpu: SystemMetricValue;