Compare commits

...
Author SHA1 Message Date
ji ruo bf2ef120da fix: make resource metrics platform aware 2026-10-30 01:39:39 +08:00
RuoJi6 08cf862b0c feat: unify HTTP packet editor
* feat(web): unify HTTP packet editor with CodeMirror

* feat: add container resource monitoring
2026-10-29 10:29:53 -07:00
12 changed files with 496 additions and 201 deletions

No files matched your search

+2 -73
View File
@@ -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 }
+62
View File
@@ -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 }
+7
View File
@@ -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)
}
+9
View File
@@ -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()}
}
+242
View File
@@ -11,6 +11,13 @@
"@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -966,6 +973,149 @@
"node": ">=14.21.3"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.4",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz",
"integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -1885,6 +2035,80 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.4.tgz",
"integrity": "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -5172,6 +5396,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10259,6 +10489,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -10928,6 +11164,12 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+7
View File
@@ -23,6 +23,13 @@
"@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -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>
);
}
-46
View File
@@ -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>
+3 -28
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, copyText } from "@/lib/utils";
import { PacketCodeEditor } from "@/components/packet-code-editor";
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
@@ -308,34 +309,8 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
</Tooltip>
</div>
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
<div
role="textbox"
aria-label="HTTP 报文代码"
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onCopy={(event) => {
const text = selectedPacketText(event.currentTarget, value, lines);
if (text === null) return;
event.preventDefault();
event.clipboardData.setData("text/plain", text);
}}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
event.preventDefault();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
}}
>
<PacketHighlightContext.Provider value={query}>
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
</PacketHighlightContext.Provider>
<div className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-hidden bg-background">
<PacketCodeEditor value={value} readOnly wrapLines={wrapLines} />
</div>
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
+114
View File
@@ -0,0 +1,114 @@
"use client";
import * as React from "react";
import { EditorState } from "@codemirror/state";
import {
drawSelection,
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
} from "@codemirror/view";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const packetHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#d95656", fontWeight: "700" },
{ tag: tags.string, color: "#0284c7" },
{ tag: tags.variableName, color: "#286dcc", fontWeight: "650" },
{ tag: tags.typeName, color: "#6b7280" },
{ tag: tags.number, color: "#c026d3" },
{ tag: tags.bool, color: "#e11d48" },
]);
const httpLanguage = StreamLanguage.define({
startState: () => ({ line: 0 }),
token(stream, state) {
if (stream.sol()) state.line += 1;
if (stream.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE)\b/)) return "keyword";
if (stream.match(/^HTTP\/\d(?:\.\d)?\b/)) return "typeName";
if (stream.match(/^[A-Za-z][A-Za-z0-9-]*(?=:)/)) return "variableName";
if (stream.match(/^https?:\/\/\S+/)) return "string";
if (stream.match(/^(?:\d{3})(?:\s+[^\n]*)?/)) return "number";
if (stream.match(/^\b(?:true|false|null)\b/)) return "bool";
stream.next();
return null;
},
});
const packetTheme = EditorView.theme({
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
".cm-scroller": { overflow: "auto", fontFamily: "var(--font-mono)", fontSize: "0.75rem", fontWeight: "500", lineHeight: "1.65rem" },
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
".cm-gutters": { backgroundColor: "transparent", color: "var(--muted-foreground)", border: "0", borderRight: "1px solid var(--border)" },
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent" },
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
".cm-cursor": { borderLeftColor: "#222" },
}, { dark: false });
export function PacketCodeEditor({
value,
onChange,
readOnly = false,
wrapLines = true,
ariaLabel = "HTTP 报文代码",
}: {
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
wrapLines?: boolean;
ariaLabel?: string;
}) {
const host = React.useRef<HTMLDivElement>(null);
const viewRef = React.useRef<EditorView | null>(null);
const valueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
React.useEffect(() => {
if (!host.current) return;
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
drawSelection(),
highlightActiveLine(),
keymap.of([...defaultKeymap, indentWithTab]),
httpLanguage,
syntaxHighlighting(defaultHighlightStyle),
syntaxHighlighting(packetHighlight),
packetTheme,
...(wrapLines ? [EditorView.lineWrapping] : []),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
EditorView.contentAttributes.of({ role: "textbox", "aria-label": ariaLabel, "aria-multiline": "true" }),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const next = update.state.doc.toString();
valueRef.current = next;
onChangeRef.current?.(next);
}),
],
});
const view = new EditorView({ state, parent: host.current });
viewRef.current = view;
return () => {
viewRef.current = null;
view.destroy();
};
}, [ariaLabel, readOnly, wrapLines]);
React.useEffect(() => {
const view = viewRef.current;
if (!view || valueRef.current === value) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
valueRef.current = value;
}, [value]);
return <div ref={host} className="h-full min-h-0 w-full" />;
}
@@ -6,8 +6,7 @@ import { WrapTextIcon } from "lucide-react";
import { LiveClock } from "@/app/(main)/_components/live-clock";
import { CopyButton } from "@/components/copy-button";
import { HttpPacketSyntax } from "@/components/http-code-block";
import { Textarea } from "@/components/ui/textarea";
import { PacketCodeEditor } from "@/components/packet-code-editor";
import { cn } from "@/lib/utils";
export function RepeaterTextPane({
@@ -16,8 +15,6 @@ export function RepeaterTextPane({
value,
onChange,
disabled,
placeholder,
syntaxHighlight = false,
toolbar,
panelClassName,
showClock = false,
@@ -33,11 +30,8 @@ export function RepeaterTextPane({
panelClassName?: string;
showClock?: boolean;
}) {
const gutter = React.useRef<HTMLPreElement>(null);
const syntax = React.useRef<HTMLDivElement>(null);
const [wrapLines, setWrapLines] = React.useState(true);
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
return (
<section
className={cn(
@@ -65,54 +59,13 @@ export function RepeaterTextPane({
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
</div>
<div className="group relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
<div
aria-hidden="true"
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
>
<pre ref={gutter} className="select-none px-2 py-2 font-mono text-xs leading-5">
{numbers}
</pre>
</div>
<Textarea
id={id}
<PacketCodeEditor
value={value}
onChange={(event) => onChange?.(event.target.value)}
onScroll={(event) => {
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
if (syntax.current) {
syntax.current.scrollTop = event.currentTarget.scrollTop;
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
}
}}
disabled={disabled}
readOnly={!onChange}
placeholder={placeholder}
wrap={wrapLines ? "soft" : "off"}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className={
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs" +
(syntaxHighlight
? " bg-transparent text-transparent caret-foreground selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
: "")
}
onChange={onChange}
readOnly={!onChange || disabled}
wrapLines={wrapLines}
ariaLabel={label}
/>
{syntaxHighlight ? (
<div
ref={syntax}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 left-12 z-20 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
>
<HttpPacketSyntax
raw={value}
lineNumbers={false}
wrapLines={wrapLines}
emptyFallback=""
lineClassName="leading-5"
/>
</div>
) : null}
</div>
</section>
);
+2 -1
View File
@@ -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;