feat(agent+panel): sing-box core version probe and display (round-2 #4)
agent-go: engineReport gains singBoxVersion. The probe runs
`sing-box version` once, parses the version token from its first line
('unknown' from unstamped builds reports empty), and caches it keyed by
the binary's mtime+size — re-probed only when an engine install/update
replaces the file. Steady-state heartbeats pay one stat, no subprocess.
Agent version stays 0.6.8.
panel-go: EngineState.singBoxVersion persisted from heartbeats (old
agents without the field keep showing '-'), servers table core-version
column now renders it. Golden gains heartbeatWithEngine +
serversAfterEngineHeartbeat sections locking the full chain, plus a
listen-address (<hostip>) normalization for the sandbox bind address.
singboxversion_test.go covers parsing, cache hits, and re-probe on
binary replacement.
This commit is contained in:
@@ -199,6 +199,57 @@ type engineReport struct {
|
|||||||
SingBoxInstalled bool `json:"singBoxInstalled"`
|
SingBoxInstalled bool `json:"singBoxInstalled"`
|
||||||
Certificates []string `json:"certificates"`
|
Certificates []string `json:"certificates"`
|
||||||
SingBoxStatus string `json:"singBoxStatus"`
|
SingBoxStatus string `json:"singBoxStatus"`
|
||||||
|
SingBoxVersion string `json:"singBoxVersion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sing-box version probe -------------------------------------------------
|
||||||
|
// The core version is probed by running `sing-box version` exactly once and
|
||||||
|
// cached against the binary's mtime+size. It is re-probed only when the file
|
||||||
|
// changes (an engine install/update replaces the binary) — the first probe
|
||||||
|
// happens on the first heartbeat after agent start. Steady-state heartbeats
|
||||||
|
// pay one stat, never a subprocess.
|
||||||
|
|
||||||
|
var singBoxVersionMu sync.Mutex
|
||||||
|
var singBoxVersionCache struct {
|
||||||
|
path string
|
||||||
|
mtimeNs int64
|
||||||
|
size int64
|
||||||
|
version string
|
||||||
|
probed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSingBoxVersion extracts the version token from the first line of
|
||||||
|
// `sing-box version` output ("sing-box version 1.13.21"). Unstamped local
|
||||||
|
// builds print "unknown"; those report no version.
|
||||||
|
func parseSingBoxVersion(out string) string {
|
||||||
|
line := out
|
||||||
|
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
||||||
|
line = line[:i]
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 3 && fields[0] == "sing-box" && fields[1] == "version" && fields[2] != "unknown" {
|
||||||
|
return fields[2]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func singBoxVersion() string {
|
||||||
|
info, err := os.Stat(cfg.SingBoxBin)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
singBoxVersionMu.Lock()
|
||||||
|
defer singBoxVersionMu.Unlock()
|
||||||
|
c := &singBoxVersionCache
|
||||||
|
if c.probed && c.path == cfg.SingBoxBin && c.mtimeNs == info.ModTime().UnixNano() && c.size == info.Size() {
|
||||||
|
return c.version
|
||||||
|
}
|
||||||
|
version := ""
|
||||||
|
if out, _, code := runCapture(5*time.Second, cfg.SingBoxBin, "version"); code == 0 {
|
||||||
|
version = parseSingBoxVersion(out)
|
||||||
|
}
|
||||||
|
c.path, c.mtimeNs, c.size, c.version, c.probed = cfg.SingBoxBin, info.ModTime().UnixNano(), info.Size(), version, true
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
|
// engineHealth mirrors agent.js engineHealth(). Callers must hold stateMu
|
||||||
@@ -214,6 +265,7 @@ func engineHealthLocked(resources []map[string]interface{}) engineReport {
|
|||||||
if installed {
|
if installed {
|
||||||
result.Status = service.Status
|
result.Status = service.Status
|
||||||
result.Detail = service.Detail
|
result.Detail = service.Detail
|
||||||
|
result.SingBoxVersion = singBoxVersion()
|
||||||
} else {
|
} else {
|
||||||
result.Status = "error"
|
result.Status = "error"
|
||||||
result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install"
|
result.Detail = "sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests for the sing-box version probe (round-2 item 4): parsing of
|
||||||
|
// `sing-box version` output and the mtime/size-keyed cache that keeps
|
||||||
|
// heartbeats from spawning the binary every round.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSingBoxVersion(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"sing-box version 1.13.21\n\nEnvironment: go1.24.6 linux/amd64\n", "1.13.21"},
|
||||||
|
{"sing-box version 1.13.21", "1.13.21"},
|
||||||
|
{"sing-box version unknown\n", ""},
|
||||||
|
{"", ""},
|
||||||
|
{"garbage output\n", ""},
|
||||||
|
{"sing-box version\n", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := parseSingBoxVersion(c.in); got != c.want {
|
||||||
|
t.Fatalf("parseSingBoxVersion(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFakeSingBox(t *testing.T, dir, version string) (binPath, countPath string) {
|
||||||
|
t.Helper()
|
||||||
|
binPath = filepath.Join(dir, "fake-sing-box")
|
||||||
|
countPath = filepath.Join(dir, "count")
|
||||||
|
script := "#!/bin/sh\necho x >> \"" + countPath + "\"\necho 'sing-box version " + version + "'\n"
|
||||||
|
if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return binPath, countPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingBoxVersionProbeCachesByMtime(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
binPath, countPath := writeFakeSingBox(t, dir, "1.2.3")
|
||||||
|
|
||||||
|
oldBin := cfg.SingBoxBin
|
||||||
|
cfg.SingBoxBin = binPath
|
||||||
|
defer func() { cfg.SingBoxBin = oldBin }()
|
||||||
|
singBoxVersionCache.probed = false
|
||||||
|
defer func() { singBoxVersionCache.probed = false }()
|
||||||
|
|
||||||
|
count := func() int {
|
||||||
|
data, _ := os.ReadFile(countPath)
|
||||||
|
return strings.Count(string(data), "x")
|
||||||
|
}
|
||||||
|
if got := singBoxVersion(); got != "1.2.3" {
|
||||||
|
t.Fatalf("first probe = %q, want 1.2.3", got)
|
||||||
|
}
|
||||||
|
if got := singBoxVersion(); got != "1.2.3" {
|
||||||
|
t.Fatalf("cached probe = %q, want 1.2.3", got)
|
||||||
|
}
|
||||||
|
if n := count(); n != 1 {
|
||||||
|
t.Fatalf("binary executed %d times across two heartbeats, want 1 (cached)", n)
|
||||||
|
}
|
||||||
|
// Replacing the binary (engine install/update) must trigger one re-probe.
|
||||||
|
writeFakeSingBox(t, dir, "1.2.4")
|
||||||
|
if got := singBoxVersion(); got != "1.2.4" {
|
||||||
|
t.Fatalf("probe after binary replacement = %q, want 1.2.4", got)
|
||||||
|
}
|
||||||
|
if n := count(); n != 2 {
|
||||||
|
t.Fatalf("binary executed %d times after replacement, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingBoxVersionMissingBinary(t *testing.T) {
|
||||||
|
oldBin := cfg.SingBoxBin
|
||||||
|
cfg.SingBoxBin = filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
defer func() { cfg.SingBoxBin = oldBin }()
|
||||||
|
if got := singBoxVersion(); got != "" {
|
||||||
|
t.Fatalf("missing binary: got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"capturedAt": "2026-10-20T20:56:25.514Z",
|
"capturedAt": "2026-10-20T21:02:25.354Z",
|
||||||
"note": "Go panel golden baseline; see README.md in this directory."
|
"note": "Go panel golden baseline; see README.md in this directory."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -483,6 +483,75 @@
|
|||||||
"status": 200
|
"status": 200
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"heartbeatWithEngine": {
|
||||||
|
"body": {
|
||||||
|
"ok": true,
|
||||||
|
"serverTime": "<ts>"
|
||||||
|
},
|
||||||
|
"status": 200
|
||||||
|
},
|
||||||
|
"serversAfterEngineHeartbeat": {
|
||||||
|
"body": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"agentVersion": "0.6.8",
|
||||||
|
"createdAt": "<ts>",
|
||||||
|
"enabled": true,
|
||||||
|
"engine": {
|
||||||
|
"at": "<ts>",
|
||||||
|
"certificates": [],
|
||||||
|
"detail": "",
|
||||||
|
"singBoxInstalled": true,
|
||||||
|
"singBoxStatus": "ready",
|
||||||
|
"singBoxVersion": "1.13.21",
|
||||||
|
"status": "ready"
|
||||||
|
},
|
||||||
|
"id": "<srv>#1",
|
||||||
|
"labels": [],
|
||||||
|
"lastSeenAt": "<ts>",
|
||||||
|
"name": "Entry",
|
||||||
|
"pendingCleanup": 0,
|
||||||
|
"portRangeEnd": 50000,
|
||||||
|
"portRangeStart": 20000,
|
||||||
|
"publicAddress": "entry.example.com",
|
||||||
|
"publicAddressV6": "v6.example.com",
|
||||||
|
"region": "",
|
||||||
|
"role": "relay",
|
||||||
|
"status": "online",
|
||||||
|
"system": {},
|
||||||
|
"tlsDomain": "tls.example.com",
|
||||||
|
"updatedAt": "<ts>",
|
||||||
|
"usage": {
|
||||||
|
"error": null,
|
||||||
|
"lastReportAt": "<ts>",
|
||||||
|
"sampleCount": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agentVersion": "0.6.8",
|
||||||
|
"createdAt": "<ts>",
|
||||||
|
"enabled": true,
|
||||||
|
"engine": null,
|
||||||
|
"id": "<srv>#2",
|
||||||
|
"labels": [],
|
||||||
|
"lastSeenAt": "<ts>",
|
||||||
|
"name": "Exit",
|
||||||
|
"pendingCleanup": 0,
|
||||||
|
"portRangeEnd": 50000,
|
||||||
|
"portRangeStart": 20000,
|
||||||
|
"publicAddress": "exit.example.com",
|
||||||
|
"publicAddressV6": "",
|
||||||
|
"region": "",
|
||||||
|
"role": "exit",
|
||||||
|
"status": "online",
|
||||||
|
"system": {},
|
||||||
|
"tlsDomain": "",
|
||||||
|
"updatedAt": "<ts>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": 200
|
||||||
|
},
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"alice": {
|
"alice": {
|
||||||
"body": {
|
"body": {
|
||||||
@@ -787,7 +856,7 @@
|
|||||||
"id": "<res>#6",
|
"id": "<res>#6",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.63",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#4",
|
"listen_port": "<port>#4",
|
||||||
"tag": "ng-<nod>#3-in",
|
"tag": "ng-<nod>#3-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -864,7 +933,7 @@
|
|||||||
"id": "<res>#6",
|
"id": "<res>#6",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.63",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#4",
|
"listen_port": "<port>#4",
|
||||||
"tag": "ng-<nod>#3-in",
|
"tag": "ng-<nod>#3-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -1403,7 +1472,7 @@
|
|||||||
"id": "<res>#2",
|
"id": "<res>#2",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.62",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#2",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -1491,7 +1560,7 @@
|
|||||||
"id": "<res>#2",
|
"id": "<res>#2",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.62",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#2",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -1578,7 +1647,7 @@
|
|||||||
"id": "<res>#1",
|
"id": "<res>#1",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.61",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#1",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
@@ -1629,7 +1698,7 @@
|
|||||||
"id": "<res>#1",
|
"id": "<res>#1",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.61",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#1",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
@@ -2324,7 +2393,7 @@
|
|||||||
"id": "<res>#2",
|
"id": "<res>#2",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.60.35",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#2",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -2406,7 +2475,7 @@
|
|||||||
"id": "<res>#1",
|
"id": "<res>#1",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.60.37",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#1",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
@@ -2457,7 +2526,7 @@
|
|||||||
"id": "<res>#4",
|
"id": "<res>#4",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.60.35",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#3",
|
"listen_port": "<port>#3",
|
||||||
"method": "2022-blake3-aes-128-gcm",
|
"method": "2022-blake3-aes-128-gcm",
|
||||||
"password": "<secret>#12",
|
"password": "<secret>#12",
|
||||||
@@ -2521,7 +2590,7 @@
|
|||||||
"id": "<res>#6",
|
"id": "<res>#6",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.63",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#4",
|
"listen_port": "<port>#4",
|
||||||
"tag": "ng-<nod>#3-in",
|
"tag": "ng-<nod>#3-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -2598,7 +2667,7 @@
|
|||||||
"id": "<res>#2",
|
"id": "<res>#2",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.62",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#2",
|
"listen_port": "<port>#2",
|
||||||
"tag": "ng-<nod>#2-in",
|
"tag": "ng-<nod>#2-in",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -2680,7 +2749,7 @@
|
|||||||
"id": "<res>#1",
|
"id": "<res>#1",
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
{
|
{
|
||||||
"listen": "198.18.187.61",
|
"listen": "<hostip>",
|
||||||
"listen_port": "<port>#1",
|
"listen_port": "<port>#1",
|
||||||
"method": "2022-blake3-aes-256-gcm",
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
"password": "<secret>#11",
|
"password": "<secret>#11",
|
||||||
@@ -2717,15 +2786,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keyStore": {
|
"keyStore": {
|
||||||
"reality-<nod>#2": {
|
|
||||||
"privateKey": "<rpriv>#2",
|
|
||||||
"publicKey": "<rpub>#1",
|
|
||||||
"updatedAt": "<ts>"
|
|
||||||
},
|
|
||||||
"reality-<nod>#3": {
|
"reality-<nod>#3": {
|
||||||
"privateKey": "<rpriv>#1",
|
"privateKey": "<rpriv>#1",
|
||||||
"publicKey": "<rpub>#2",
|
"publicKey": "<rpub>#2",
|
||||||
"updatedAt": "<ts>"
|
"updatedAt": "<ts>"
|
||||||
|
},
|
||||||
|
"reality-<nod>#2": {
|
||||||
|
"privateKey": "<rpriv>#2",
|
||||||
|
"publicKey": "<rpub>#1",
|
||||||
|
"updatedAt": "<ts>"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nodeClients": [
|
"nodeClients": [
|
||||||
@@ -2922,7 +2991,15 @@
|
|||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"createdAt": "<ts>",
|
"createdAt": "<ts>",
|
||||||
"engine": null,
|
"engine": {
|
||||||
|
"at": "<ts>",
|
||||||
|
"certificates": [],
|
||||||
|
"detail": "",
|
||||||
|
"singBoxInstalled": true,
|
||||||
|
"singBoxStatus": "ready",
|
||||||
|
"singBoxVersion": "1.13.21",
|
||||||
|
"status": "ready"
|
||||||
|
},
|
||||||
"id": "<srv>#1",
|
"id": "<srv>#1",
|
||||||
"labels": [],
|
"labels": [],
|
||||||
"lastSeenAt": "<ts>",
|
"lastSeenAt": "<ts>",
|
||||||
|
|||||||
@@ -50,12 +50,15 @@ function view(res) {
|
|||||||
// Keys whose numeric values are wall-clock derived and therefore not part of
|
// Keys whose numeric values are wall-clock derived and therefore not part of
|
||||||
// the behavior contract; squashed wherever they appear.
|
// the behavior contract; squashed wherever they appear.
|
||||||
const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i;
|
const VOLATILE_NUM_KEYS = /uptime|serverTimeMs|nowMs/i;
|
||||||
function squashVolatile(value) {
|
function squashVolatile(value, keyName = null) {
|
||||||
if (Array.isArray(value)) return value.map(squashVolatile);
|
// A resource's inbound `listen` is the panel host's own interface address
|
||||||
|
// (bind resolution) — environment-specific, not behavior.
|
||||||
|
if (keyName === 'listen' && typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return '<hostip>';
|
||||||
|
if (Array.isArray(value)) return value.map((v) => squashVolatile(v, keyName));
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const [k, v] of Object.entries(value)) {
|
for (const [k, v] of Object.entries(value)) {
|
||||||
out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '<num>' : squashVolatile(v);
|
out[k] = typeof v === 'number' && VOLATILE_NUM_KEYS.test(k) ? '<num>' : squashVolatile(v, k);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,16 @@ async function runScript(bin) {
|
|||||||
jobs: view(await client.admin('GET', '/api/jobs')),
|
jobs: view(await client.admin('GET', '/api/jobs')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New-shape heartbeat carrying the core version (round-2 item 4): the
|
||||||
|
// panel must persist it on the server's engine state and expose it in
|
||||||
|
// the servers list. The seed-shape heartbeats above (no engine object)
|
||||||
|
// must keep working and leave engine state absent.
|
||||||
|
cap('heartbeatWithEngine', view(await client.agentPost('/api/agent/heartbeat', agentKeys.entry, {
|
||||||
|
version: '0.6.8', system: {},
|
||||||
|
engine: { status: 'ready', detail: '', singBoxInstalled: true, certificates: [], singBoxStatus: 'ready', singBoxVersion: '1.13.21' },
|
||||||
|
})));
|
||||||
|
cap('serversAfterEngineHeartbeat', view(await client.admin('GET', '/api/servers')));
|
||||||
|
|
||||||
cap('credentials', {
|
cap('credentials', {
|
||||||
alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)),
|
alice: view(await client.admin('GET', `/api/customers/${ids.alice}/credentials`)),
|
||||||
bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)),
|
bob: view(await client.admin('GET', `/api/customers/${ids.bob}/credentials`)),
|
||||||
|
|||||||
@@ -1026,6 +1026,7 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
|||||||
SingBoxInstalled: installed,
|
SingBoxInstalled: installed,
|
||||||
Certificates: certs,
|
Certificates: certs,
|
||||||
SingBoxStatus: singBoxStatus,
|
SingBoxStatus: singBoxStatus,
|
||||||
|
SingBoxVersion: CleanText(eng["singBoxVersion"], 32),
|
||||||
At: now,
|
At: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ type EngineState struct {
|
|||||||
SingBoxInstalled bool `json:"singBoxInstalled"`
|
SingBoxInstalled bool `json:"singBoxInstalled"`
|
||||||
Certificates []string `json:"certificates"`
|
Certificates []string `json:"certificates"`
|
||||||
SingBoxStatus string `json:"singBoxStatus"`
|
SingBoxStatus string `json:"singBoxStatus"`
|
||||||
|
SingBoxVersion string `json:"singBoxVersion,omitempty"`
|
||||||
At string `json:"at"`
|
At string `json:"at"`
|
||||||
Extra extraFields `json:"-"`
|
Extra extraFields `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -476,7 +476,7 @@ function renderServers() {
|
|||||||
<td><span class="num">${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`}</span></td>
|
<td><span class="num">${cpuPct == null ? '—' : `${cpuPct.toFixed(1)}%`}</span></td>
|
||||||
<td><span class="num">${memPct == null ? '—' : `${memPct.toFixed(1)}%`}</span></td>
|
<td><span class="num">${memPct == null ? '—' : `${memPct.toFixed(1)}%`}</span></td>
|
||||||
<td><span class="ver-cell"><span class="ver">${esc(server.agentVersion || '—')}</span>${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '<span class="badge orange">有可用更新</span>' : ''}</span></td>
|
<td><span class="ver-cell"><span class="ver">${esc(server.agentVersion || '—')}</span>${server.agentVersion && versionLessThan(server.agentVersion, state.version) ? '<span class="badge orange">有可用更新</span>' : ''}</span></td>
|
||||||
<td><span class="ver">—</span></td>
|
<td><span class="ver">${esc(server.engine && server.engine.singBoxVersion ? server.engine.singBoxVersion : '—')}</span></td>
|
||||||
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</span></td>
|
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</span></td>
|
||||||
<td><span class="clients-cell">${ICONS.users}${clients.total}<span class="on-n">${clients.online}</span></span></td>
|
<td><span class="clients-cell">${ICONS.users}${clients.total}<span class="on-n">${clients.online}</span></span></td>
|
||||||
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
|
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user