mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-30 10:03:50 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2ef120da | ||
|
|
08cf862b0c |
No files matched your search
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<EgressIpNavbar />
|
||||
<SystemResourceNavbar />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LiveClock />
|
||||
@@ -95,3 +98,42 @@ function MainContentInner({ children }: { children: ReactNode }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemResourceNavbar() {
|
||||
const [metrics, setMetrics] = useState<SystemMetrics | null>(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 <div className="flex items-center justify-center text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
|
||||
}
|
||||
const items = [
|
||||
[CpuIcon, "CPU", metrics.cpu.percent],
|
||||
[MemoryStickIcon, "内存", metrics.memory.percent],
|
||||
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
|
||||
] as const;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground" role="status" aria-live="polite">
|
||||
<span>容器资源</span>
|
||||
{items.map(([Icon, label, value]) => (
|
||||
<span key={label} className="inline-flex items-center gap-1 tabular-nums" title={`${label} ${value.toFixed(1)}%`}>
|
||||
<Icon className="size-3" aria-hidden="true" /> {label} {value.toFixed(1)}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
root_domain: "根域名",
|
||||
ip: "IP",
|
||||
@@ -160,15 +147,6 @@ 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.
|
||||
@@ -1151,30 +1129,6 @@ 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user