Compare commits

..
5 Commits
Author SHA1 Message Date
MengMengCodeandClaude Opus 4.8 e2177a6e9a feat: remove legacy device stat panels from dashboard
The total/online/offline/last-refresh mini panels are superseded by the
new module online-rate card.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 18:33:25 +08:00
MengMengCodeandClaude Opus 4.8 115598757a feat: dashboard host, performance, task, and online-rate cards
Add four cards to the dashboard:

- Host hardware card (CPU / motherboard / memory / disk model) backed by
  a new GET /api/dashboard/host endpoint that probes /proc and /sys once
  and caches the identities. x86 hosts read cpuinfo model name, DMI board
  data, dmidecode DIMM info, and block device models; ARM boards compose
  the device-tree SoC with the Cortex part name and fall back to memory
  capacity.
- Performance card with live CPU / memory / disk usage bars and real-time
  network up/down rates. Rates derive from cumulative kernel counters
  sampled on demand by dashboard polling (no background goroutine), with
  bridge/tunnel/vocat virtual interfaces excluded to avoid double
  counting.
- Upcoming scheduled tasks card listing the next three enabled automatic
  tasks with their run times.
- Module online rate card aggregating all recognized modules into one
  large percentage colored by four levels (red/orange/yellow/green).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-16 17:46:38 +08:00
Rain SevenandGitHub 297d2c1839 fix: deduplicate cumulative IMS SMS segments (#34) 2026-08-16 17:09:08 +08:00
Rain SevenandGitHub 7ba30132f9 fix: retry interrupted serial drain (#33) 2026-08-16 17:09:01 +08:00
Rain SevenandGitHub 82494f519b feat: auto-detect DJI 4G modules (#32) 2026-08-16 17:08:51 +08:00
21 changed files with 1423 additions and 21 deletions
+20 -3
View File
@@ -11,7 +11,11 @@ import (
"strings"
)
const quectelVendorID = "2c7c"
const (
quectelVendorID = "2c7c"
djiVendorID = "2ca3"
dji4GProductID = "4006"
)
type SysFSDiscoverer struct {
SysRoot string
@@ -70,13 +74,13 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
resolvedDevice = devicePath
}
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
if vendorID != quectelVendorID {
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
if !isSupportedUSBModem(vendorID, productID) {
continue
}
state := devices[deviceName]
if state == nil {
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
state = &discoveredUSBDevice{
candidate: Candidate{
@@ -141,6 +145,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
return result, nil
}
func isSupportedUSBModem(vendorID, productID string) bool {
return strings.EqualFold(strings.TrimSpace(vendorID), quectelVendorID) ||
IsDJI4GUSB(vendorID, productID)
}
// IsDJI4GUSB reports whether a USB identity belongs to the first-generation
// DJI/Baiwang 4G module. It keeps the factory 2ca3:4006 identity usable without
// requiring a persistent AT+QCFG USB identity rewrite to Quectel 2c7c:0125.
func IsDJI4GUSB(vendorID, productID string) bool {
return strings.EqualFold(strings.TrimSpace(vendorID), djiVendorID) &&
strings.EqualFold(strings.TrimSpace(productID), dji4GProductID)
}
type discoveredWWANDevice struct {
index string
ports []Port
+23
View File
@@ -2,6 +2,29 @@ package modem
import "testing"
func TestSupportedUSBModemIdentity(t *testing.T) {
tests := []struct {
name string
vendorID string
productID string
want bool
}{
{name: "Quectel", vendorID: "2c7c", productID: "0125", want: true},
{name: "DJI 4G module", vendorID: "2ca3", productID: "4006", want: true},
{name: "DJI 4G module uppercase", vendorID: "2CA3", productID: "4006", want: true},
{name: "unrelated DJI device", vendorID: "2ca3", productID: "001f", want: false},
{name: "unrelated USB device", vendorID: "0403", productID: "6001", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isSupportedUSBModem(test.vendorID, test.productID); got != test.want {
t.Fatalf("isSupportedUSBModem(%q, %q) = %v, want %v", test.vendorID, test.productID, got, test.want)
}
})
}
}
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
ports := []Port{
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
+19 -3
View File
@@ -8,6 +8,7 @@ import (
"io"
"strings"
"sync"
"syscall"
"time"
)
@@ -151,7 +152,7 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
session.poisonLocked()
return response, fmt.Errorf("write %s: %w", command, err)
}
if err := session.transport.Drain(); err != nil {
if err := drainTransport(ctx, session.transport); err != nil {
session.poisonLocked()
return response, fmt.Errorf("drain %s: %w", command, err)
}
@@ -178,7 +179,7 @@ func (session *Session) executePromptLocked(
session.poisonLocked()
return response, fmt.Errorf("write %s: %w", command, err)
}
if err := session.transport.Drain(); err != nil {
if err := drainTransport(ctx, session.transport); err != nil {
session.poisonLocked()
return response, fmt.Errorf("drain %s: %w", command, err)
}
@@ -203,7 +204,7 @@ func (session *Session) executePromptLocked(
response.Duration = time.Since(started)
return response, fmt.Errorf("terminate %s payload: %w", command, err)
}
if err := session.transport.Drain(); err != nil {
if err := drainTransport(ctx, session.transport); err != nil {
session.poisonLocked()
response.Duration = time.Since(started)
return response, fmt.Errorf("drain %s payload: %w", command, err)
@@ -211,6 +212,21 @@ func (session *Session) executePromptLocked(
return session.readFinalLocked(ctx, started, command, string(payload), response)
}
// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a
// signal. go.bug.st/serial already retries EINTR for Read, but its Linux
// Drain implementation currently returns the transient error directly.
func drainTransport(ctx context.Context, transport Transport) error {
for {
err := transport.Drain()
if !errors.Is(err, syscall.EINTR) {
return err
}
if err := ctx.Err(); err != nil {
return err
}
}
}
func (session *Session) readFinalLocked(
ctx context.Context,
started time.Time,
+39 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"sync"
"syscall"
"testing"
"time"
)
@@ -27,6 +28,8 @@ type transcriptTransport struct {
unexpected error
writePartial bool
writeEvents chan string
drainErrors []error
drainCount int
}
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
@@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
return 0, nil
}
func (transport *transcriptTransport) Drain() error { return nil }
func (transport *transcriptTransport) Drain() error {
transport.mu.Lock()
defer transport.mu.Unlock()
transport.drainCount++
if len(transport.drainErrors) == 0 {
return nil
}
err := transport.drainErrors[0]
transport.drainErrors = transport.drainErrors[1:]
return err
}
func (transport *transcriptTransport) ResetInputBuffer() error {
transport.mu.Lock()
@@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error {
return nil
}
func TestSessionRetriesInterruptedDrain(t *testing.T) {
transport := &transcriptTransport{
steps: []transportStep{{
write: "AT+CSQ\r",
chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"},
}},
drainErrors: []error{syscall.EINTR},
}
session, err := NewSession(transport, SessionOptions{})
if err != nil {
t.Fatalf("NewSession() error = %v", err)
}
response, err := session.Execute(context.Background(), "AT+CSQ")
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if response.Final != "OK" {
t.Fatalf("response final = %q", response.Final)
}
if transport.drainCount != 2 {
t.Fatalf("Drain() calls = %d, want 2", transport.drainCount)
}
}
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
transport := &transcriptTransport{steps: []transportStep{{
write: "AT+CSQ\r",
+16
View File
@@ -142,6 +142,9 @@ func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool {
}
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
return true
case "dashboard/host":
s.handleDashboardHost(w, r)
return true
case "devices":
return s.handleDevices(w, r)
case "devices/discovered":
@@ -387,6 +390,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
result = append(result, map[string]any{
"hardware_kind": candidate.HardwareKind,
"reader_name": candidate.ReaderName,
"device_type": discoveredDeviceType(candidate),
"discovery_key": entry.ID,
"control_path": controlPath,
"net_interface": candidate.NetworkInterface,
@@ -1913,6 +1917,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
config.NetworkEnabled = false
config.SMSEnabled = true
config.VoWiFiEnabled = true
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
config.DeviceType = store.DeviceTypeDJI4G
}
if config.Interface == "" {
config.Interface = candidate.NetworkInterface
@@ -1937,6 +1943,16 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
}
}
func discoveredDeviceType(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return store.DeviceTypeUSBSIMReader
}
if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
return store.DeviceTypeDJI4G
}
return ""
}
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
if snapshot == nil {
return map[string]any{
+18
View File
@@ -6,10 +6,28 @@ import (
"time"
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestFillConfigFromPhysicalClassifiesDJI4G(t *testing.T) {
config := store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}
entry := device.Device{Candidate: modem.Candidate{
VendorID: "2ca3",
ProductID: "4006",
}}
fillConfigFromPhysical(&config, entry)
if config.DeviceType != store.DeviceTypeDJI4G {
t.Fatalf("device type = %q, want %q", config.DeviceType, store.DeviceTypeDJI4G)
}
if got := discoveredDeviceType(entry.Candidate); got != store.DeviceTypeDJI4G {
t.Fatalf("discovered device type = %q, want %q", got, store.DeviceTypeDJI4G)
}
}
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
+468
View File
@@ -0,0 +1,468 @@
package server
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// hostStaticInfo describes hardware identities that do not change while the
// process runs, so they are probed once and cached.
type hostStaticInfo struct {
CPUModel string `json:"cpu_model"`
BoardModel string `json:"board_model"`
MemoryModel string `json:"memory_model"`
DiskModel string `json:"disk_model"`
}
// hostPerfSnapshot is one rendered read of host utilization for the dashboard.
type hostPerfSnapshot struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryPercent float64 `json:"memory_percent"`
MemoryUsed uint64 `json:"memory_used_bytes"`
MemoryTotal uint64 `json:"memory_total_bytes"`
DiskPercent float64 `json:"disk_percent"`
DiskUsed uint64 `json:"disk_used_bytes"`
DiskTotal uint64 `json:"disk_total_bytes"`
NetRxBps float64 `json:"net_rx_bps"`
NetTxBps float64 `json:"net_tx_bps"`
}
// hostCPUTimes is one cumulative /proc/stat reading: idle already includes
// iowait, total sums every other column (guest time is already folded into
// user/nice and therefore excluded).
type hostCPUTimes struct {
idle uint64
total uint64
}
const (
// hostStatsMinGap keeps back-to-back polls from dividing a handful of
// jiffies by a few milliseconds; the previous rate is reused instead.
hostStatsMinGap = 300 * time.Millisecond
// hostStatsMaxGap mirrors liveNetMaxGap: a gap past this means the tab was
// closed or the browser was hidden; re-baseline instead of averaging a
// long dead interval.
hostStatsMaxGap = 15 * time.Second
// hostStatsFirstSample is how long the very first request blocks so CPU
// and network readings have a real interval to average over. It must
// exceed hostStatsMinGap so the re-read survives the min-gap guard below.
hostStatsFirstSample = 400 * time.Millisecond
)
// hostStatsSampler derives live host utilization from cumulative kernel
// counters. Like liveNetTracker it is driven on demand by dashboard polling,
// so no background goroutine is required.
type hostStatsSampler struct {
mu sync.Mutex
static *hostStaticInfo
sampledAt time.Time
prevCPU hostCPUTimes
prevNetRx uint64
prevNetTx uint64
lastCPU float64
lastRxBps float64
lastTxBps float64
}
func newHostStatsSampler() *hostStatsSampler {
return &hostStatsSampler{}
}
// handleDashboardHost serves the dashboard host card: static hardware identity
// plus live utilization. Both halves are cheap reads of /proc and /sys.
func (s *Server) handleDashboardHost(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"host": s.hostStats.info(),
"perf": s.hostStats.perf(),
}})
}
// info returns the cached static hardware description, probing it on first use.
func (s *hostStatsSampler) info() hostStaticInfo {
s.mu.Lock()
defer s.mu.Unlock()
if s.static == nil {
info := probeHostStatic()
s.static = &info
}
return *s.static
}
// perf renders one utilization snapshot. CPU and network rates need a baseline,
// so the first-ever call takes a short inline second reading; later calls
// average against the previous request.
func (s *hostStatsSampler) perf() hostPerfSnapshot {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
cpu, cpuOK := readHostCPUTimes()
rx, tx, netOK := readHostNetTotals()
// No usable baseline yet (first request, or the tab was hidden past the
// max gap): establish one, then re-read after a short interval so the
// first dashboard paint already reports real numbers.
needBaseline := s.sampledAt.IsZero() || now.Sub(s.sampledAt) > hostStatsMaxGap
if needBaseline && (cpuOK || netOK) {
s.sampledAt = now
if cpuOK {
s.prevCPU = cpu
}
if netOK {
s.prevNetRx, s.prevNetTx = rx, tx
}
time.Sleep(hostStatsFirstSample)
now = time.Now()
if next, ok := readHostCPUTimes(); ok {
cpu, cpuOK = next, true
}
if nextRx, nextTx, ok := readHostNetTotals(); ok {
rx, tx, netOK = nextRx, nextTx, true
}
}
memPercent, memUsed, memTotal := readHostMemory()
diskPercent, diskUsed, diskTotal := readHostDisk()
gap := now.Sub(s.sampledAt)
if gap >= hostStatsMinGap && (cpuOK || netOK) {
if cpuOK {
if busyDelta, totalDelta := cpuDelta(s.prevCPU, cpu); totalDelta > 0 {
s.lastCPU = clampPercent(float64(busyDelta) * 100 / float64(totalDelta))
}
s.prevCPU = cpu
}
if netOK {
// Counter resets (interface flap) must not produce a giant spike.
if rx >= s.prevNetRx {
s.lastRxBps = float64(rx-s.prevNetRx) / gap.Seconds()
} else {
s.lastRxBps = 0
}
if tx >= s.prevNetTx {
s.lastTxBps = float64(tx-s.prevNetTx) / gap.Seconds()
} else {
s.lastTxBps = 0
}
s.prevNetRx, s.prevNetTx = rx, tx
}
s.sampledAt = now
}
return hostPerfSnapshot{
CPUPercent: s.lastCPU,
MemoryPercent: memPercent,
MemoryUsed: memUsed,
MemoryTotal: memTotal,
DiskPercent: diskPercent,
DiskUsed: diskUsed,
DiskTotal: diskTotal,
NetRxBps: s.lastRxBps,
NetTxBps: s.lastTxBps,
}
}
// cpuDelta returns the busy and total jiffies elapsed between two cumulative
// readings. A backwards counter (theoretically impossible for /proc/stat)
// reports zero rather than wrapping.
func cpuDelta(prev, next hostCPUTimes) (busy, total uint64) {
if next.total <= prev.total || next.idle < prev.idle {
return 0, 0
}
totalDelta := next.total - prev.total
idleDelta := next.idle - prev.idle
if idleDelta >= totalDelta {
return 0, totalDelta
}
return totalDelta - idleDelta, totalDelta
}
func clampPercent(value float64) float64 {
switch {
case value < 0:
return 0
case value > 100:
return 100
default:
return value
}
}
// hostNetIgnoredPrefixes are virtual interface name prefixes whose counters
// would double-count physical traffic (bridges, tunnels, vocat's own links) or
// carry no real host traffic at all.
var hostNetIgnoredPrefixes = []string{
"lo", "br-", "docker", "veth", "virbr", "vmnet", "vboxnet",
"ip6tnl", "ip6gre", "sit", "gre", "gretap", "erspan",
"tun", "tap", "utun", "vocat", "wg", "zt", "tailscale",
"ifb", "bond", "vlan", "macvlan", "dummy", "lxc", "cali", "flannel", "cni",
}
// hostNetInterfaceCounted reports whether an interface's byte counters feed the
// host-level upload/download rates.
func hostNetInterfaceCounted(name string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, prefix := range hostNetIgnoredPrefixes {
if strings.HasPrefix(name, prefix) {
return false
}
}
return true
}
// parseNetDevCounters sums rx/tx bytes across counted interfaces in
// /proc/net/dev content.
func parseNetDevCounters(content string) (rx, tx uint64) {
for _, line := range strings.Split(content, "\n") {
name, rest, found := strings.Cut(line, ":")
if !found || !hostNetInterfaceCounted(name) {
continue
}
fields := strings.Fields(rest)
if len(fields) < 9 {
continue
}
rxBytes, okRx := parseUint(fields[0])
txBytes, okTx := parseUint(fields[8])
if !okRx || !okTx {
continue
}
rx += rxBytes
tx += txBytes
}
return rx, tx
}
func parseUint(text string) (uint64, bool) {
value, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64)
return value, err == nil
}
// parseCPUTimes parses the aggregate "cpu" line of /proc/stat.
func parseCPUTimes(line string) (hostCPUTimes, bool) {
fields := strings.Fields(line)
// cpu user nice system idle iowait irq softirq steal [guest guest_nice]
if len(fields) < 9 || fields[0] != "cpu" {
return hostCPUTimes{}, false
}
var times hostCPUTimes
for index, field := range fields[1:9] {
value, ok := parseUint(field)
if !ok {
return hostCPUTimes{}, false
}
times.total += value
if index == 3 || index == 4 { // idle + iowait
times.idle += value
}
}
return times, true
}
// parseMeminfo extracts MemTotal and MemAvailable (bytes) from /proc/meminfo.
func parseMeminfo(content string) (total, available uint64, ok bool) {
for _, line := range strings.Split(content, "\n") {
key, rest, found := strings.Cut(line, ":")
if !found {
continue
}
var value uint64
switch strings.TrimSpace(key) {
case "MemTotal":
value, ok = parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB")))
if ok {
total = value * 1024
}
case "MemAvailable":
if value, parsed := parseUint(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "kB"))); parsed {
available = value * 1024
}
}
}
return total, available, total > 0
}
// parseCPUInfoModel returns the x86-style "model name" from /proc/cpuinfo, or
// an empty string on ARM hosts that only carry CPU part numbers.
func parseCPUInfoModel(content string) string {
for _, line := range strings.Split(content, "\n") {
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "model name", "Model", "Hardware":
if model := strings.TrimSpace(value); model != "" {
return model
}
}
}
return ""
}
// parseCPUInfoPart returns the first ARM "CPU part" hex identifier (e.g.
// 0xd03) and the number of processors listed.
func parseCPUInfoPart(content string) (part string, processors int) {
for _, line := range strings.Split(content, "\n") {
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "processor":
processors++
case "CPU part":
if part == "" {
part = strings.ToLower(strings.TrimSpace(value))
}
}
}
return part, processors
}
// armCPUPartNames maps ARM CPU part identifiers to marketing core names.
var armCPUPartNames = map[string]string{
"0xd03": "Cortex-A53",
"0xd04": "Cortex-A35",
"0xd05": "Cortex-A55",
"0xd06": "Cortex-A65",
"0xd07": "Cortex-A57",
"0xd08": "Cortex-A72",
"0xd09": "Cortex-A73",
"0xd0a": "Cortex-A75",
"0xd0b": "Cortex-A76",
"0xd0c": "Neoverse-N1",
"0xd0d": "Cortex-A77",
"0xd0e": "Cortex-A76AE",
"0xd40": "Neoverse-V1",
"0xd41": "Cortex-A78",
"0xd42": "Cortex-A78AE",
"0xd44": "Cortex-X1",
"0xd46": "Cortex-A510",
"0xd47": "Cortex-A710",
"0xd48": "Cortex-X2",
"0xd4b": "Cortex-A715",
"0xd4d": "Cortex-A520",
"0xd4e": "Cortex-X3",
}
// socVendorNames prettifies the vendor half of a device-tree compatible entry.
var socVendorNames = map[string]string{
"allwinner": "Allwinner",
"amlogic": "Amlogic",
"broadcom": "Broadcom",
"mediatek": "MediaTek",
"nvidia": "NVIDIA",
"qualcomm": "Qualcomm",
"raspberrypi": "Raspberry Pi",
"rockchip": "Rockchip",
"samsung": "Samsung",
"ti": "TI",
"xunlong": "Xunlong",
}
// parseCompatibleSoC extracts the SoC half of a device-tree compatible list
// (NUL-separated, most specific first): "xunlong,orangepi-zero3\0allwinner,
// sun50i-h618\0" yields "Allwinner sun50i-h618".
func parseCompatibleSoC(raw string) string {
entries := strings.FieldsFunc(raw, func(r rune) bool { return r == 0 || r == '\n' })
// The last entry is the least specific compatible, which on ARM boards is
// the SoC rather than the board.
for index := len(entries) - 1; index >= 0; index-- {
entry := strings.TrimSpace(entries[index])
vendor, soc, found := strings.Cut(entry, ",")
if !found || soc == "" {
continue
}
if pretty, ok := socVendorNames[strings.ToLower(vendor)]; ok {
vendor = pretty
} else {
vendor = strings.ToUpper(vendor[:1]) + vendor[1:]
}
return vendor + " " + soc
}
return ""
}
// composeARMCPUModel renders e.g. "Allwinner sun50i-h618 · 4× Cortex-A53".
func composeARMCPUModel(soc, part string, processors int) string {
core := armCPUPartNames[part]
var result string
switch {
case soc != "" && core != "" && processors > 0:
result = soc + " · " + strconv.Itoa(processors) + "× " + core
case soc != "" && processors > 0:
result = soc + " · " + strconv.Itoa(processors) + "× CPU"
case soc != "" && core != "":
result = soc + " · " + core
default:
result = soc
}
return result
}
// skipHostDisk reports whether a /sys/block entry is a virtual device whose
// "model" would only clutter the host card.
func skipHostDisk(name string) bool {
for _, prefix := range []string{"loop", "ram", "zram", "sr", "nbd", "dm-", "md", "mtdblock", "ubi", "ubiblock"} {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// parseDmidecodeMemory extracts a compact "8 GB DDR4 M471A1K43CB1-CRC" style
// description from `dmidecode -t 17` output, preferring the first populated
// slot. Empty when no installed module can be described.
func parseDmidecodeMemory(output string) string {
var size, memType, partNumber string
flush := func() string {
if size != "" && partNumber != "" {
return strings.TrimSpace(size + " " + memType + " " + partNumber)
}
if size != "" && memType != "" {
return strings.TrimSpace(size + " " + memType)
}
return ""
}
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(line, "Memory Device") {
if composed := flush(); composed != "" {
return composed
}
size, memType, partNumber = "", "", ""
continue
}
key, value, found := strings.Cut(trimmed, ":")
if !found {
continue
}
value = strings.TrimSpace(value)
switch strings.TrimSpace(key) {
case "Size":
if !strings.Contains(value, "No Module") && value != "" && value != "Unknown" {
size = value
}
case "Type":
if value != "Unknown" && value != "Other" && !strings.HasPrefix(value, "<OUT OF SPEC") {
memType = value
}
case "Part Number":
if value != "Unknown" && value != "None" && value != "" {
partNumber = value
}
}
}
return flush()
}
+204
View File
@@ -0,0 +1,204 @@
//go:build linux
package server
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"golang.org/x/sys/unix"
)
// probeHostStatic gathers the hardware identities shown on the dashboard host
// card. Every probe is best-effort: empty fields render as "—" in the SPA.
func probeHostStatic() hostStaticInfo {
return hostStaticInfo{
CPUModel: readHostCPUModel(),
BoardModel: readHostBoardModel(),
MemoryModel: readHostMemoryModel(),
DiskModel: readHostDiskModel(),
}
}
// readHostCPUModel prefers the x86-style "model name"; on ARM hosts it composes
// the device-tree SoC with the core count and Cortex part name.
func readHostCPUModel() string {
cpuinfo, err := os.ReadFile("/proc/cpuinfo")
if err == nil {
if model := parseCPUInfoModel(string(cpuinfo)); model != "" {
return model
}
part, processors := parseCPUInfoPart(string(cpuinfo))
if processors == 0 {
processors = runtime.NumCPU()
}
soc := ""
if compatible, err := os.ReadFile("/proc/device-tree/compatible"); err == nil {
soc = parseCompatibleSoC(string(compatible))
}
if model := composeARMCPUModel(soc, part, processors); model != "" {
return model
}
}
return runtime.GOARCH
}
// readHostBoardModel reads the device-tree model on ARM boards and the DMI
// board name on x86 machines.
func readHostBoardModel() string {
if model, err := os.ReadFile("/proc/device-tree/model"); err == nil {
if text := strings.TrimSpace(strings.TrimRight(string(model), "\x00")); text != "" {
return text
}
}
dmiDir := "/sys/devices/virtual/dmi/id"
board := readSysfsTrimmed(filepath.Join(dmiDir, "board_name"))
vendor := readSysfsTrimmed(filepath.Join(dmiDir, "board_vendor"))
if board != "" && !isPlaceholderDMI(board) {
if vendor != "" && !isPlaceholderDMI(vendor) && !strings.Contains(strings.ToLower(board), strings.ToLower(vendor)) {
return vendor + " " + board
}
return board
}
if product := readSysfsTrimmed(filepath.Join(dmiDir, "product_name")); product != "" && !isPlaceholderDMI(product) {
return product
}
return ""
}
// isPlaceholderDMI filters the well-known "we never filled this in" DMI
// strings so they do not surface as board models.
func isPlaceholderDMI(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "default string", "to be filled by o.e.m.", "to be filled by o.e.m", "none", "unknown", "n/a", "not specified", "system manufacturer":
return true
}
return false
}
// readHostMemoryModel reports the installed DIMM description when dmidecode is
// available (typical on x86 NAS/PC hosts) and falls back to total capacity,
// which is all an ARM board exposes.
func readHostMemoryModel() string {
if path, err := exec.LookPath("dmidecode"); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, path, "-t", "17").Output(); err == nil {
if model := parseDmidecodeMemory(string(output)); model != "" {
return model
}
}
}
if total, _, ok := readHostMemoryBytes(); ok {
return formatLiveBytes(float64(total))
}
return ""
}
// readHostDiskModel describes physical block devices, skipping virtual ones
// (loop, zram, device-mapper, mtd, optical). Multiple disks join with "; ".
func readHostDiskModel() string {
entries, err := os.ReadDir("/sys/block")
if err != nil {
return ""
}
var disks []string
for _, entry := range entries {
name := entry.Name()
if skipHostDisk(name) {
continue
}
base := filepath.Join("/sys/block", name)
sizeText := readSysfsTrimmed(filepath.Join(base, "size"))
sectors, ok := parseUint(sizeText)
if !ok || sectors == 0 {
// An empty card reader reports size 0 and tells us nothing.
continue
}
model := readSysfsTrimmed(filepath.Join(base, "device", "model"))
if model == "" {
// MMC/SD cards carry the product name instead of a model string.
model = readSysfsTrimmed(filepath.Join(base, "device", "name"))
}
if model == "" {
model = name
}
capacity := formatLiveBytes(float64(sectors) * 512)
disks = append(disks, model+" · "+capacity)
}
return strings.Join(disks, "; ")
}
func readSysfsTrimmed(path string) string {
raw, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(strings.TrimRight(string(raw), "\x00"))
}
// readHostCPUTimes reads the aggregate counters from /proc/stat.
func readHostCPUTimes() (hostCPUTimes, bool) {
raw, err := os.ReadFile("/proc/stat")
if err != nil {
return hostCPUTimes{}, false
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "cpu ") {
return parseCPUTimes(line)
}
}
return hostCPUTimes{}, false
}
// readHostMemoryBytes returns MemTotal and MemAvailable in bytes.
func readHostMemoryBytes() (total, available uint64, ok bool) {
raw, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0, false
}
return parseMeminfo(string(raw))
}
// readHostMemory reports used/total bytes and the used percentage.
func readHostMemory() (percent float64, used, total uint64) {
total, available, ok := readHostMemoryBytes()
if !ok || total == 0 {
return 0, 0, 0
}
used = total - available
return clampPercent(float64(used) * 100 / float64(total)), used, total
}
// readHostDisk reports root filesystem usage the way df does: usable space is
// total minus reserved blocks, and the percentage is used/(used+available).
func readHostDisk() (percent float64, used, total uint64) {
var stat unix.Statfs_t
if err := unix.Statfs("/", &stat); err != nil || stat.Blocks == 0 {
return 0, 0, 0
}
blockSize := uint64(stat.Bsize)
total = stat.Blocks * blockSize
free := stat.Bfree * blockSize
available := stat.Bavail * blockSize
used = total - free
if denominator := used + available; denominator > 0 {
percent = clampPercent(float64(used) * 100 / float64(denominator))
}
return percent, used, total
}
// readHostNetTotals sums rx/tx counters across physical host interfaces.
func readHostNetTotals() (rx, tx uint64, ok bool) {
raw, err := os.ReadFile("/proc/net/dev")
if err != nil {
return 0, 0, false
}
rx, tx = parseNetDevCounters(string(raw))
return rx, tx, true
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !linux
package server
// Host statistics are only meaningful on the Linux deployment target; on other
// platforms every probe reports empty/zero and the dashboard renders "—".
func probeHostStatic() hostStaticInfo { return hostStaticInfo{} }
func readHostCPUTimes() (hostCPUTimes, bool) { return hostCPUTimes{}, false }
func readHostNetTotals() (uint64, uint64, bool) { return 0, 0, false }
func readHostMemory() (float64, uint64, uint64) { return 0, 0, 0 }
func readHostDisk() (float64, uint64, uint64) { return 0, 0, 0 }
+184
View File
@@ -0,0 +1,184 @@
package server
import (
"testing"
)
func TestParseCPUTimes(t *testing.T) {
times, ok := parseCPUTimes("cpu 38073 0 24013 6762971 3121 0 3019 0 0 0")
if !ok {
t.Fatal("parseCPUTimes rejected a valid cpu line")
}
wantTotal := uint64(38073 + 0 + 24013 + 6762971 + 3121 + 0 + 3019 + 0)
if times.total != wantTotal {
t.Fatalf("total = %d, want %d", times.total, wantTotal)
}
if wantIdle := uint64(6762971 + 3121); times.idle != wantIdle {
t.Fatalf("idle = %d, want %d", times.idle, wantIdle)
}
if _, ok := parseCPUTimes("cpu0 1 2 3 4 5 6 7 8"); ok {
t.Fatal("per-core line must not parse as the aggregate line")
}
if _, ok := parseCPUTimes("cpu 1 2 3"); ok {
t.Fatal("truncated cpu line must not parse")
}
}
func TestCPUDelta(t *testing.T) {
prev := hostCPUTimes{idle: 100, total: 200}
next := hostCPUTimes{idle: 150, total: 300}
busy, total := cpuDelta(prev, next)
if busy != 50 || total != 100 {
t.Fatalf("cpuDelta = (%d, %d), want (50, 100)", busy, total)
}
if busy, total := cpuDelta(next, prev); busy != 0 || total != 0 {
t.Fatalf("backwards counters must report zero, got (%d, %d)", busy, total)
}
}
func TestParseMeminfo(t *testing.T) {
content := "MemTotal: 2040424 kB\nMemFree: 920864 kB\nMemAvailable: 1543480 kB\nBuffers: 315908 kB\n"
total, available, ok := parseMeminfo(content)
if !ok {
t.Fatal("parseMeminfo rejected valid content")
}
if total != 2040424*1024 {
t.Fatalf("total = %d, want %d", total, 2040424*1024)
}
if available != 1543480*1024 {
t.Fatalf("available = %d, want %d", available, 1543480*1024)
}
}
func TestParseNetDevCounters(t *testing.T) {
content := `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 10 1 0 0 0 0 0 0 20 2 0 0 0 0 0 0
eth0: 100 1 0 0 0 0 0 0 200 2 0 0 0 0 0 0
br-lan: 1000 1 0 0 0 0 0 0 2000 2 0 0 0 0 0 0
utun: 300 1 0 0 0 0 0 0 400 2 0 0 0 0 0 0
vocat50a684ceb0: 500 1 0 0 0 0 0 0 600 2 0 0 0 0 0 0
wwan0: 700 1 0 0 0 0 0 0 800 2 0 0 0 0 0 0
`
rx, tx := parseNetDevCounters(content)
// Only eth0 and wwan0 count; lo, br-lan, utun and vocat are virtual.
if rx != 800 || tx != 1000 {
t.Fatalf("rx,tx = %d,%d, want 800,1000", rx, tx)
}
}
func TestHostNetInterfaceCounted(t *testing.T) {
counted := []string{"eth0", "eth1", "wwan0", "usb0", "wlan0", "enp3s0", "pppoe-wan"}
for _, name := range counted {
if !hostNetInterfaceCounted(name) {
t.Fatalf("%s should be counted", name)
}
}
skipped := []string{"lo", "br-lan", "docker0", "veth123", "ip6tnl0", "sit0", "utun", "vocat50a684ceb0", "wg0", "tun0", "tailscale0", ""}
for _, name := range skipped {
if hostNetInterfaceCounted(name) {
t.Fatalf("%s should be skipped", name)
}
}
}
func TestParseCPUInfoModelX86(t *testing.T) {
content := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz\n"
if model := parseCPUInfoModel(content); model != "Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz" {
t.Fatalf("model = %q", model)
}
}
func TestParseCPUInfoARM(t *testing.T) {
content := "processor\t: 0\nBogoMIPS\t: 48.00\nCPU implementer\t: 0x41\nCPU part\t: 0xd03\nprocessor\t: 1\nCPU part\t: 0xd03\n"
if model := parseCPUInfoModel(content); model != "" {
t.Fatalf("ARM cpuinfo must not report an x86 model name, got %q", model)
}
part, processors := parseCPUInfoPart(content)
if part != "0xd03" || processors != 2 {
t.Fatalf("part,processors = %q,%d, want 0xd03,2", part, processors)
}
}
func TestParseCompatibleSoC(t *testing.T) {
raw := "xunlong,orangepi-zero3\x00allwinner,sun50i-h618\x00"
if soc := parseCompatibleSoC(raw); soc != "Allwinner sun50i-h618" {
t.Fatalf("soc = %q", soc)
}
if soc := parseCompatibleSoC(""); soc != "" {
t.Fatalf("empty compatible must yield empty soc, got %q", soc)
}
}
func TestComposeARMCPUModel(t *testing.T) {
model := composeARMCPUModel("Allwinner sun50i-h618", "0xd03", 4)
if model != "Allwinner sun50i-h618 · 4× Cortex-A53" {
t.Fatalf("model = %q", model)
}
if model := composeARMCPUModel("", "", 0); model != "" {
t.Fatalf("empty inputs must yield empty model, got %q", model)
}
}
func TestParseDmidecodeMemory(t *testing.T) {
output := `# dmidecode 3.3
Getting SMBIOS data from sysfs.
SMBIOS 3.0 present.
Handle 0x0010, DMI type 17, 40 bytes
Memory Device
Array Handle: 0x000F
Error Information Handle: Not Provided
Total Width: 64 bits
Data Width: 64 bits
Size: 8 GB
Form Factor: SODIMM
Type: DDR4
Speed: 2400 MT/s
Manufacturer: Samsung
Serial Number: 12345678
Part Number: M471A1K43CB1-CRC
Rank: 1
Handle 0x0011, DMI type 17, 40 bytes
Memory Device
Size: No Module Installed
Type: Unknown
`
if model := parseDmidecodeMemory(output); model != "8 GB DDR4 M471A1K43CB1-CRC" {
t.Fatalf("model = %q", model)
}
if model := parseDmidecodeMemory("Memory Device\n\tSize: No Module Installed\n"); model != "" {
t.Fatalf("unpopulated slots must yield empty model, got %q", model)
}
}
func TestClampPercent(t *testing.T) {
if clampPercent(-1) != 0 || clampPercent(101) != 100 || clampPercent(50) != 50 {
t.Fatal("clampPercent bounds violated")
}
}
func TestParseUintTrims(t *testing.T) {
if value, ok := parseUint(" 61440000 "); !ok || value != 61440000 {
t.Fatalf("parseUint = %d,%v", value, ok)
}
if _, ok := parseUint("not-a-number"); ok {
t.Fatal("parseUint accepted garbage")
}
}
func TestSkipHostDiskPrefixes(t *testing.T) {
skipped := []string{"loop0", "ram0", "zram0", "sr0", "nbd0", "dm-0", "md0", "mtdblock0", "ubiblock0_0"}
for _, name := range skipped {
if !skipHostDisk(name) {
t.Fatalf("%s should be skipped", name)
}
}
kept := []string{"sda", "nvme0n1", "mmcblk0", "vda", "sdb"}
for _, name := range kept {
if skipHostDisk(name) {
t.Fatalf("%s should be kept", name)
}
}
}
+2
View File
@@ -82,6 +82,7 @@ type Server struct {
updateApplying bool
https *httpsmode.Manager
netTraffic *liveNetTracker
hostStats *hostStatsSampler
publicIPMu sync.RWMutex
publicIPs map[string]cachedPublicIP
automaticTasks *automaticTaskScheduler
@@ -133,6 +134,7 @@ func New(options Options) (*Server, error) {
updateToken: strings.TrimSpace(options.UpdateToken),
https: options.HTTPS,
netTraffic: newLiveNetTracker(),
hostStats: newHostStatsSampler(),
publicIPs: make(map[string]cachedPublicIP),
updateCheck: update.CheckLatest,
updateApply: update.ApplyLatest,
+30 -2
View File
@@ -3,6 +3,7 @@ package store
import (
"encoding/json"
"fmt"
"maps"
"sort"
"strconv"
"strings"
@@ -94,9 +95,15 @@ func mergeConcatSegment(
}
}
}
prior, alreadyHad := parts[sequence]
changed = !alreadyHad || prior != segmentBody
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
// already-decoded text of sequence 1 followed by its own payload. Keep a
// snapshot so normalizing that representation remains idempotent on a later
// redelivery of the same segment.
previousParts := maps.Clone(parts)
normalizeCumulativeConcatParts(previousParts)
parts[sequence] = segmentBody
normalizeCumulativeConcatParts(parts)
changed = !maps.Equal(previousParts, parts)
sequences := make([]int, 0, len(parts))
for n := range parts {
@@ -130,3 +137,24 @@ func mergeConcatSegment(
}
return joined.String(), json.RawMessage(encoded), changed, nil
}
// normalizeCumulativeConcatParts converts cumulative IMS segment bodies back
// into ordinary per-segment bodies. It only removes an exact, non-empty prefix
// assembled from every preceding sequence starting at 1, and only when the
// current value also contains additional text. That deliberately leaves equal
// repeated segments and incomplete/out-of-order prefixes untouched.
func normalizeCumulativeConcatParts(parts map[int]string) {
var prefix strings.Builder
for sequence := 1; ; sequence++ {
text, ok := parts[sequence]
if !ok {
return
}
assembled := prefix.String()
if assembled != "" && len(text) > len(assembled) && strings.HasPrefix(text, assembled) {
text = strings.TrimPrefix(text, assembled)
parts[sequence] = text
}
prefix.WriteString(text)
}
}
+56
View File
@@ -101,6 +101,62 @@ func TestMergeConcatSegmentRedeliveryIsIdempotent(t *testing.T) {
}
}
func TestMergeConcatSegmentNormalizesCumulativeIMSPart(t *testing.T) {
first := strings.Repeat("安全提醒", 17)
want := first + "请通过官方渠道核实。"
_, extra, _, err := mergeConcatSegment(nil, first, concatExtra(t, 8, 2, 1))
if err != nil {
t.Fatal(err)
}
body, normalized, changed, err := mergeConcatSegment(extra, want, concatExtra(t, 8, 2, 2))
if err != nil || !changed {
t.Fatalf("cumulative segment: body=%q changed=%v err=%v", body, changed, err)
}
if body != want {
t.Fatalf("body = %q, want cumulative text once %q", body, want)
}
// Redelivering the cumulative wire representation must compare equal to the
// normalized stored representation and must not churn the durable row id.
body, _, changed, err = mergeConcatSegment(normalized, want, concatExtra(t, 8, 2, 2))
if err != nil {
t.Fatal(err)
}
if changed || body != want {
t.Fatalf("redelivery: body=%q changed=%v, want %q/false", body, changed, want)
}
}
func TestMergeConcatSegmentNormalizesCumulativeIMSPartOutOfOrder(t *testing.T) {
first := strings.Repeat("甲", 67)
want := first + "尾段"
_, extra, _, err := mergeConcatSegment(nil, want, concatExtra(t, 12, 2, 2))
if err != nil {
t.Fatal(err)
}
body, _, changed, err := mergeConcatSegment(extra, first, concatExtra(t, 12, 2, 1))
if err != nil || !changed {
t.Fatalf("out-of-order segment: body=%q changed=%v err=%v", body, changed, err)
}
if body != want {
t.Fatalf("body = %q, want cumulative text once %q", body, want)
}
}
func TestMergeConcatSegmentKeepsEqualRepeatedPart(t *testing.T) {
_, extra, _, err := mergeConcatSegment(nil, "重复", concatExtra(t, 13, 2, 1))
if err != nil {
t.Fatal(err)
}
body, _, _, err := mergeConcatSegment(extra, "重复", concatExtra(t, 13, 2, 2))
if err != nil {
t.Fatal(err)
}
if body != "重复重复" {
t.Fatalf("body = %q, want intentional equal segments preserved", body)
}
}
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
if err != nil {
@@ -0,0 +1,40 @@
import { ServerRegular } from "@fluentui/react-icons";
import type { DashboardHostInfo } from "../../types";
import { useI18n } from "../../lib/i18n";
interface Row {
label: string;
value: string;
}
// 宿主机信息卡:CPU / 主板 / 内存 / 硬盘型号,后端一次性探测后缓存。
export function HostInfoCard({ info }: { info?: DashboardHostInfo | null }) {
const { t } = useI18n();
const rows: Row[] = [
{ label: t("CPU 型号"), value: info?.cpuModel || "" },
{ label: t("主板型号"), value: info?.boardModel || "" },
{ label: t("内存型号"), value: info?.memoryModel || "" },
{ label: t("硬盘型号"), value: info?.diskModel || "" },
];
return (
<div className="ui-panel p-4">
<div className="mb-3 flex items-center gap-2">
<ServerRegular className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("宿主机信息")}</h3>
</div>
<div className="space-y-2.5">
{rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-3">
<span className="flex-shrink-0 text-xs text-gray-400">{row.label}</span>
<span
className="min-w-0 flex-1 truncate text-right text-xs font-medium text-gray-700 dark:text-gray-300"
title={row.value || undefined}
>
{row.value || "—"}
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import { ArrowDownRegular, ArrowUpRegular, GaugeRegular } from "@fluentui/react-icons";
import type { DashboardHostPerf } from "../../types";
import { useI18n } from "../../lib/i18n";
import { cx, formatBytes } from "../../lib/utils";
function percentText(value: number) {
return `${value.toFixed(1)}%`;
}
// 利用率越高颜色越危险:<70 绿,<90 黄,其余红。
function barColor(percent: number) {
if (percent >= 90) return "bg-red-500";
if (percent >= 70) return "bg-amber-500";
return "bg-emerald-500";
}
function textColor(percent: number) {
if (percent >= 90) return "text-red-600 dark:text-red-400";
if (percent >= 70) return "text-amber-600 dark:text-amber-400";
return "text-emerald-600 dark:text-emerald-400";
}
function UsageBar({ label, percent, detail }: { label: string; percent: number; detail?: string }) {
const clamped = Math.min(100, Math.max(0, percent || 0));
return (
<div>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs text-gray-400">{label}</span>
<span className="flex items-baseline gap-1.5">
{detail ? <span className="text-[10px] text-gray-400">{detail}</span> : null}
<span className={cx("text-xs font-bold tabular-nums", textColor(clamped))}>{percentText(clamped)}</span>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10">
<div
className={cx("h-full rounded-full transition-all duration-500", barColor(clamped))}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
// 性能信息卡:CPU / 内存 / 硬盘进度条 + 实时网络上下行速率。
export function HostPerfCard({ perf }: { perf?: DashboardHostPerf | null }) {
const { t } = useI18n();
return (
<div className="ui-panel p-4">
<div className="mb-3 flex items-center gap-2">
<GaugeRegular className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("性能信息")}</h3>
</div>
<div className="space-y-2.5">
<UsageBar label={t("CPU 使用率")} percent={perf?.cpuPercent ?? 0} />
<UsageBar
label={t("内存使用率")}
percent={perf?.memoryPercent ?? 0}
detail={perf && perf.memoryTotalBytes > 0 ? `${formatBytes(perf.memoryUsedBytes)} / ${formatBytes(perf.memoryTotalBytes)}` : undefined}
/>
<UsageBar
label={t("硬盘使用率")}
percent={perf?.diskPercent ?? 0}
detail={perf && perf.diskTotalBytes > 0 ? `${formatBytes(perf.diskUsedBytes)} / ${formatBytes(perf.diskTotalBytes)}` : undefined}
/>
<div className="flex items-center justify-between gap-2 pt-0.5">
<span className="text-xs text-gray-400">{t("网络")}</span>
<span className="flex items-center gap-3 text-xs font-semibold tabular-nums">
<span className="flex items-center gap-1 text-sky-600 dark:text-sky-400" title={t("实时上传速率")}>
<ArrowUpRegular className="h-3.5 w-3.5" />
{formatBytes(perf?.netTxBps ?? 0)}/s
</span>
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400" title={t("实时下载速率")}>
<ArrowDownRegular className="h-3.5 w-3.5" />
{formatBytes(perf?.netRxBps ?? 0)}/s
</span>
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { PlugConnectedRegular } from "@fluentui/react-icons";
import { useI18n, tf } from "../../lib/i18n";
import { cx } from "../../lib/utils";
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
type RateLevel = "green" | "yellow" | "orange" | "red";
function rateLevel(percent: number): RateLevel {
if (percent >= 100) return "green";
if (percent >= 80) return "yellow";
if (percent >= 50) return "orange";
return "red";
}
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
};
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
const { t } = useI18n();
const percent = total > 0 ? Math.round((online / total) * 100) : null;
const level = percent === null ? null : rateLevel(percent);
const styles = level ? LEVEL_STYLES[level] : null;
return (
<div className="ui-panel p-4">
<div className="mb-1 flex items-center gap-2">
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
</div>
<div className="flex items-center justify-center py-1">
{percent === null ? (
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
) : (
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
{percent}
<span className="text-2xl">%</span>
</div>
)}
</div>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
{styles ? (
<span className="flex items-center gap-1">
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
{t(styles.labelKey)}
</span>
) : null}
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { CalendarClockRegular } from "@fluentui/react-icons";
import { useNavigate } from "react-router-dom";
import type { DashboardUpcomingTask } from "../../types";
import { useI18n } from "../../lib/i18n";
function formatRunAt(value: string): string {
if (!value || value.startsWith("0001-")) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString(undefined, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
// 将要执行的定时任务卡:按 nextRunAt 升序取前 3 条已启用任务。
export function UpcomingTasksCard({ tasks }: { tasks: DashboardUpcomingTask[] }) {
const { t } = useI18n();
const navigate = useNavigate();
return (
<div className="ui-panel p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarClockRegular className="h-4 w-4 text-sky-500" />
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("将要执行的定时任务")}</h3>
</div>
<button
type="button"
onClick={() => navigate("/automatic-tasks")}
className="text-xs font-medium text-sky-600 transition-colors hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
>
{t("查看全部")}
</button>
</div>
{tasks.length === 0 ? (
<div className="flex h-[4.5rem] items-center justify-center text-xs text-gray-400">
{t("暂无定时任务")}
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-white/5">
{tasks.map((task) => (
<div key={task.id} className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700 dark:text-gray-300" title={task.name}>
{task.name}
</span>
<span className="flex-shrink-0 font-mono text-xs tabular-nums text-gray-500 dark:text-gray-400">
{formatRunAt(task.nextRunAt)}
</span>
</div>
))}
</div>
)}
</div>
);
}
+18
View File
@@ -291,6 +291,24 @@ export const EN_DICT: Record<string, string> = {
: "Retry",
: "No devices connected",
: "Add or adopt a device on the Devices page first",
宿: "Host Hardware",
"CPU 型号": "CPU Model",
: "Motherboard",
: "Memory",
: "Disk",
: "Performance",
"CPU 使用率": "CPU Usage",
使: "Memory Usage",
使: "Disk Usage",
: "Upcoming Scheduled Tasks",
: "View All",
: "No scheduled tasks",
线: "Module Online Rate",
: "Excellent",
: "Good",
: "Fair",
: "Poor",
"{online}/{total} 台在线": "{online}/{total} online",
// ---- 设置页:通用 ----
: "Manage gateway parameters and runtime info",
+41 -11
View File
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import type { DashboardDevice } from "../types";
import type { DashboardDevice, DashboardHost, DashboardUpcomingTask } from "../types";
import { usePolling } from "../lib/usePolling";
import { useI18n } from "../lib/i18n";
import { PageHeader } from "../components/ui/PageHeader";
@@ -10,9 +10,17 @@ import { ErrorState } from "../components/ui/ErrorState";
import { ListSkeleton } from "../components/ui/ListSkeleton";
import { EmptyState } from "../components/ui/EmptyState";
import { DeviceCard } from "../components/DeviceCard";
import { HostInfoCard } from "../components/dashboard/HostInfoCard";
import { HostPerfCard } from "../components/dashboard/HostPerfCard";
import { UpcomingTasksCard } from "../components/dashboard/UpcomingTasksCard";
import { OnlineRateCard } from "../components/dashboard/OnlineRateCard";
interface LoadError { message: string; status?: number; method?: string; url?: string }
// 任务卡只展示最近的三条;定时任务变化慢,轮询间隔比设备/性能数据更宽。
const UPCOMING_TASK_COUNT = 3;
const TASKS_POLL_INTERVAL = 15000;
export default function DashboardPage() {
const { t } = useI18n();
const navigate = useNavigate();
@@ -20,7 +28,8 @@ export default function DashboardPage() {
const [devicesLoading, setDevicesLoading] = useState(false);
const [devicesError, setDevicesError] = useState<LoadError | null>(null);
const [devicesOkAt, setDevicesOkAt] = useState<number | null>(null);
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
const [host, setHost] = useState<DashboardHost | null>(null);
const [upcomingTasks, setUpcomingTasks] = useState<DashboardUpcomingTask[]>([]);
const fetchDevices = useCallback(async () => {
setDevicesLoading(true);
@@ -28,9 +37,7 @@ export default function DashboardPage() {
const list = await api<DashboardDevice[]>("/dashboard/devices");
setDevices(list || []);
setDevicesError(null);
const now = Date.now();
setDevicesOkAt(now);
setLastRefresh(now);
setDevicesOkAt(Date.now());
} catch (e: any) {
setDevicesError({ message: e?.message || t("加载失败"), status: e?.status });
} finally {
@@ -38,11 +45,34 @@ export default function DashboardPage() {
}
}, []);
// 宿主机信息 + 性能数据:2s 轮询让网络速率足够"实时"。
const fetchHost = useCallback(async () => {
try {
setHost(await api<DashboardHost>("/dashboard/host"));
} catch {
/* 宿主机数据失败不打断设备监控;保留上一次成功值。 */
}
}, []);
const fetchUpcomingTasks = useCallback(async () => {
try {
const data = await api<{ tasks?: DashboardUpcomingTask[] }>("/automatic-tasks");
const upcoming = (data.tasks || [])
.filter((task) => task.enabled && task.nextRunAt && !task.nextRunAt.startsWith("0001-"))
.sort((a, b) => new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime())
.slice(0, UPCOMING_TASK_COUNT);
setUpcomingTasks(upcoming);
} catch {
/* 任务列表加载失败时保留旧数据。 */
}
}, []);
usePolling(fetchDevices, 5000);
usePolling(fetchHost, 2000);
usePolling(fetchUpcomingTasks, TASKS_POLL_INTERVAL);
const total = devices.length;
const online = devices.filter((d) => d?.healthy).length;
const offline = Math.max(0, total - online);
const openDevice = (id: string) => navigate(`/devices?device=${encodeURIComponent(id)}&tab=overview`);
return (
@@ -52,11 +82,11 @@ export default function DashboardPage() {
subtitle={t("实时监测模组检测状态与出口连通性")}
actions={<RefreshButton loading={devicesLoading} onClick={fetchDevices} />}
/>
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("设备总数")}</div><div className="mt-1 text-2xl font-extrabold">{total}</div></div>
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("在线")}</div><div className="mt-1 text-2xl font-extrabold text-green-600 dark:text-green-400">{online}</div></div>
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("离线")}</div><div className="mt-1 text-2xl font-extrabold text-red-600 dark:text-red-400">{offline}</div></div>
<div className="ui-panel p-4"><div className="text-xs text-gray-400">{t("最近刷新")}</div><div className="mt-2 font-mono text-sm text-gray-600 dark:text-gray-300">{lastRefresh ? new Date(lastRefresh).toLocaleTimeString() : "--:--:--"}</div></div>
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<HostInfoCard info={host?.host} />
<HostPerfCard perf={host?.perf} />
<UpcomingTasksCard tasks={upcomingTasks} />
<OnlineRateCard online={online} total={total} />
</div>
{devicesError ? (
<ErrorState className="mb-6" title={t("设备列表加载失败")} message={devicesError.message} statusCode={devicesError.status} requestMethod={devicesError.method} requestUrl={devicesError.url} lastSuccessAt={devicesOkAt} retryText={t("重试")} onRetry={fetchDevices} />
+1 -1
View File
@@ -398,7 +398,7 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
deviceType: d.deviceType || (isReader ? "usb_sim_reader" : prev.deviceType),
esimTransport: isReader ? "pcsc" : backend,
};
});
+33
View File
@@ -144,6 +144,38 @@ export interface DashboardDevice {
model?: string;
}
export interface DashboardHostInfo {
cpuModel: string;
boardModel: string;
memoryModel: string;
diskModel: string;
}
export interface DashboardHostPerf {
cpuPercent: number;
memoryPercent: number;
memoryUsedBytes: number;
memoryTotalBytes: number;
diskPercent: number;
diskUsedBytes: number;
diskTotalBytes: number;
netRxBps: number;
netTxBps: number;
}
export interface DashboardHost {
host: DashboardHostInfo;
perf: DashboardHostPerf;
}
// 仪表盘定时任务卡只关心名字与下次执行时间。
export interface DashboardUpcomingTask {
id: number;
name: string;
enabled: boolean;
nextRunAt: string;
}
export interface DeviceOverview extends DeviceListItem {
atPort?: string;
audioDevice?: string;
@@ -174,6 +206,7 @@ export interface DeviceStatus {
export interface DiscoveredDevice {
hardwareKind?: string;
readerName?: string;
deviceType?: DeviceType;
discoveryKey: string;
controlPath: string;
netInterface: string;