mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-17 21:33:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2177a6e9a | ||
|
|
115598757a | ||
|
|
297d2c1839 | ||
|
|
7ba30132f9 | ||
|
|
82494f519b |
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user