mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2177a6e9a | ||
|
|
115598757a | ||
|
|
297d2c1839 | ||
|
|
7ba30132f9 | ||
|
|
82494f519b |
@@ -11,7 +11,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const quectelVendorID = "2c7c"
|
const (
|
||||||
|
quectelVendorID = "2c7c"
|
||||||
|
djiVendorID = "2ca3"
|
||||||
|
dji4GProductID = "4006"
|
||||||
|
)
|
||||||
|
|
||||||
type SysFSDiscoverer struct {
|
type SysFSDiscoverer struct {
|
||||||
SysRoot string
|
SysRoot string
|
||||||
@@ -70,13 +74,13 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
resolvedDevice = devicePath
|
resolvedDevice = devicePath
|
||||||
}
|
}
|
||||||
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor")))
|
||||||
if vendorID != quectelVendorID {
|
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
||||||
|
if !isSupportedUSBModem(vendorID, productID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
state := devices[deviceName]
|
state := devices[deviceName]
|
||||||
if state == nil {
|
if state == nil {
|
||||||
productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct")))
|
|
||||||
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial"))
|
||||||
state = &discoveredUSBDevice{
|
state = &discoveredUSBDevice{
|
||||||
candidate: Candidate{
|
candidate: Candidate{
|
||||||
@@ -141,6 +145,19 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
|||||||
return result, nil
|
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 {
|
type discoveredWWANDevice struct {
|
||||||
index string
|
index string
|
||||||
ports []Port
|
ports []Port
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ package modem
|
|||||||
|
|
||||||
import "testing"
|
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) {
|
func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) {
|
||||||
ports := []Port{
|
ports := []Port{
|
||||||
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
{Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp
|
|||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
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()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,7 @@ func (session *Session) executePromptLocked(
|
|||||||
session.poisonLocked()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("write %s: %w", command, err)
|
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()
|
session.poisonLocked()
|
||||||
return response, fmt.Errorf("drain %s: %w", command, err)
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +204,7 @@ func (session *Session) executePromptLocked(
|
|||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
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()
|
session.poisonLocked()
|
||||||
response.Duration = time.Since(started)
|
response.Duration = time.Since(started)
|
||||||
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
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)
|
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(
|
func (session *Session) readFinalLocked(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
started time.Time,
|
started time.Time,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,8 @@ type transcriptTransport struct {
|
|||||||
unexpected error
|
unexpected error
|
||||||
writePartial bool
|
writePartial bool
|
||||||
writeEvents chan string
|
writeEvents chan string
|
||||||
|
drainErrors []error
|
||||||
|
drainCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
func (transport *transcriptTransport) Write(payload []byte) (int, error) {
|
||||||
@@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) {
|
|||||||
return 0, nil
|
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 {
|
func (transport *transcriptTransport) ResetInputBuffer() error {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
@@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error {
|
|||||||
return nil
|
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) {
|
func TestSessionSeparatesInterleavedURCs(t *testing.T) {
|
||||||
transport := &transcriptTransport{steps: []transportStep{{
|
transport := &transcriptTransport{steps: []transportStep{{
|
||||||
write: "AT+CSQ\r",
|
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()})
|
writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()})
|
||||||
return true
|
return true
|
||||||
|
case "dashboard/host":
|
||||||
|
s.handleDashboardHost(w, r)
|
||||||
|
return true
|
||||||
case "devices":
|
case "devices":
|
||||||
return s.handleDevices(w, r)
|
return s.handleDevices(w, r)
|
||||||
case "devices/discovered":
|
case "devices/discovered":
|
||||||
@@ -387,6 +390,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
|||||||
result = append(result, map[string]any{
|
result = append(result, map[string]any{
|
||||||
"hardware_kind": candidate.HardwareKind,
|
"hardware_kind": candidate.HardwareKind,
|
||||||
"reader_name": candidate.ReaderName,
|
"reader_name": candidate.ReaderName,
|
||||||
|
"device_type": discoveredDeviceType(candidate),
|
||||||
"discovery_key": entry.ID,
|
"discovery_key": entry.ID,
|
||||||
"control_path": controlPath,
|
"control_path": controlPath,
|
||||||
"net_interface": candidate.NetworkInterface,
|
"net_interface": candidate.NetworkInterface,
|
||||||
@@ -1913,6 +1917,8 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
|||||||
config.NetworkEnabled = false
|
config.NetworkEnabled = false
|
||||||
config.SMSEnabled = true
|
config.SMSEnabled = true
|
||||||
config.VoWiFiEnabled = true
|
config.VoWiFiEnabled = true
|
||||||
|
} else if modem.IsDJI4GUSB(candidate.VendorID, candidate.ProductID) {
|
||||||
|
config.DeviceType = store.DeviceTypeDJI4G
|
||||||
}
|
}
|
||||||
if config.Interface == "" {
|
if config.Interface == "" {
|
||||||
config.Interface = candidate.NetworkInterface
|
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 {
|
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
|||||||
@@ -6,10 +6,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/device"
|
"vocat/internal/device"
|
||||||
|
"vocat/internal/modem"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
"vocat/internal/vowifi"
|
"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) {
|
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||||
database, err := store.Open(context.Background(), ":memory:")
|
database, err := store.Open(context.Background(), ":memory:")
|
||||||
if err != nil {
|
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
|
updateApplying bool
|
||||||
https *httpsmode.Manager
|
https *httpsmode.Manager
|
||||||
netTraffic *liveNetTracker
|
netTraffic *liveNetTracker
|
||||||
|
hostStats *hostStatsSampler
|
||||||
publicIPMu sync.RWMutex
|
publicIPMu sync.RWMutex
|
||||||
publicIPs map[string]cachedPublicIP
|
publicIPs map[string]cachedPublicIP
|
||||||
automaticTasks *automaticTaskScheduler
|
automaticTasks *automaticTaskScheduler
|
||||||
@@ -133,6 +134,7 @@ func New(options Options) (*Server, error) {
|
|||||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||||
https: options.HTTPS,
|
https: options.HTTPS,
|
||||||
netTraffic: newLiveNetTracker(),
|
netTraffic: newLiveNetTracker(),
|
||||||
|
hostStats: newHostStatsSampler(),
|
||||||
publicIPs: make(map[string]cachedPublicIP),
|
publicIPs: make(map[string]cachedPublicIP),
|
||||||
updateCheck: update.CheckLatest,
|
updateCheck: update.CheckLatest,
|
||||||
updateApply: update.ApplyLatest,
|
updateApply: update.ApplyLatest,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -94,9 +95,15 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prior, alreadyHad := parts[sequence]
|
// Some IMS stacks hand us a cumulative segment: sequence 2 contains the
|
||||||
changed = !alreadyHad || prior != segmentBody
|
// 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
|
parts[sequence] = segmentBody
|
||||||
|
normalizeCumulativeConcatParts(parts)
|
||||||
|
changed = !maps.Equal(previousParts, parts)
|
||||||
|
|
||||||
sequences := make([]int, 0, len(parts))
|
sequences := make([]int, 0, len(parts))
|
||||||
for n := range parts {
|
for n := range parts {
|
||||||
@@ -130,3 +137,24 @@ func mergeConcatSegment(
|
|||||||
}
|
}
|
||||||
return joined.String(), json.RawMessage(encoded), changed, nil
|
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) {
|
func TestMergeConcatSegmentWithoutHeaderPassesThrough(t *testing.T) {
|
||||||
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
extra, err := json.Marshal(map[string]any{"encoding": "gsm7"})
|
||||||
if err != nil {
|
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",
|
重试: "Retry",
|
||||||
暂无设备接入: "No devices connected",
|
暂无设备接入: "No devices connected",
|
||||||
请先在设备管理中添加或接管设备: "Add or adopt a device on the Devices page first",
|
请先在设备管理中添加或接管设备: "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",
|
管理网关参数与运行信息: "Manage gateway parameters and runtime info",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { DashboardDevice } from "../types";
|
import type { DashboardDevice, DashboardHost, DashboardUpcomingTask } from "../types";
|
||||||
import { usePolling } from "../lib/usePolling";
|
import { usePolling } from "../lib/usePolling";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
import { PageHeader } from "../components/ui/PageHeader";
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
@@ -10,9 +10,17 @@ import { ErrorState } from "../components/ui/ErrorState";
|
|||||||
import { ListSkeleton } from "../components/ui/ListSkeleton";
|
import { ListSkeleton } from "../components/ui/ListSkeleton";
|
||||||
import { EmptyState } from "../components/ui/EmptyState";
|
import { EmptyState } from "../components/ui/EmptyState";
|
||||||
import { DeviceCard } from "../components/DeviceCard";
|
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 }
|
interface LoadError { message: string; status?: number; method?: string; url?: string }
|
||||||
|
|
||||||
|
// 任务卡只展示最近的三条;定时任务变化慢,轮询间隔比设备/性能数据更宽。
|
||||||
|
const UPCOMING_TASK_COUNT = 3;
|
||||||
|
const TASKS_POLL_INTERVAL = 15000;
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -20,7 +28,8 @@ export default function DashboardPage() {
|
|||||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
const [devicesError, setDevicesError] = useState<LoadError | null>(null);
|
const [devicesError, setDevicesError] = useState<LoadError | null>(null);
|
||||||
const [devicesOkAt, setDevicesOkAt] = useState<number | 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 () => {
|
const fetchDevices = useCallback(async () => {
|
||||||
setDevicesLoading(true);
|
setDevicesLoading(true);
|
||||||
@@ -28,9 +37,7 @@ export default function DashboardPage() {
|
|||||||
const list = await api<DashboardDevice[]>("/dashboard/devices");
|
const list = await api<DashboardDevice[]>("/dashboard/devices");
|
||||||
setDevices(list || []);
|
setDevices(list || []);
|
||||||
setDevicesError(null);
|
setDevicesError(null);
|
||||||
const now = Date.now();
|
setDevicesOkAt(Date.now());
|
||||||
setDevicesOkAt(now);
|
|
||||||
setLastRefresh(now);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setDevicesError({ message: e?.message || t("加载失败"), status: e?.status });
|
setDevicesError({ message: e?.message || t("加载失败"), status: e?.status });
|
||||||
} finally {
|
} 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(fetchDevices, 5000);
|
||||||
|
usePolling(fetchHost, 2000);
|
||||||
|
usePolling(fetchUpcomingTasks, TASKS_POLL_INTERVAL);
|
||||||
|
|
||||||
const total = devices.length;
|
const total = devices.length;
|
||||||
const online = devices.filter((d) => d?.healthy).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`);
|
const openDevice = (id: string) => navigate(`/devices?device=${encodeURIComponent(id)}&tab=overview`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,11 +82,11 @@ export default function DashboardPage() {
|
|||||||
subtitle={t("实时监测模组检测状态与出口连通性")}
|
subtitle={t("实时监测模组检测状态与出口连通性")}
|
||||||
actions={<RefreshButton loading={devicesLoading} onClick={fetchDevices} />}
|
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="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl: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>
|
<HostInfoCard info={host?.host} />
|
||||||
<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>
|
<HostPerfCard perf={host?.perf} />
|
||||||
<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>
|
<UpcomingTasksCard tasks={upcomingTasks} />
|
||||||
<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>
|
<OnlineRateCard online={online} total={total} />
|
||||||
</div>
|
</div>
|
||||||
{devicesError ? (
|
{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} />
|
<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 || "",
|
modemImei: d.imei || "",
|
||||||
usbPath: d.usbPath || "",
|
usbPath: d.usbPath || "",
|
||||||
deviceBackend: backend,
|
deviceBackend: backend,
|
||||||
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
|
deviceType: d.deviceType || (isReader ? "usb_sim_reader" : prev.deviceType),
|
||||||
esimTransport: isReader ? "pcsc" : backend,
|
esimTransport: isReader ? "pcsc" : backend,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,6 +144,38 @@ export interface DashboardDevice {
|
|||||||
model?: string;
|
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 {
|
export interface DeviceOverview extends DeviceListItem {
|
||||||
atPort?: string;
|
atPort?: string;
|
||||||
audioDevice?: string;
|
audioDevice?: string;
|
||||||
@@ -174,6 +206,7 @@ export interface DeviceStatus {
|
|||||||
export interface DiscoveredDevice {
|
export interface DiscoveredDevice {
|
||||||
hardwareKind?: string;
|
hardwareKind?: string;
|
||||||
readerName?: string;
|
readerName?: string;
|
||||||
|
deviceType?: DeviceType;
|
||||||
discoveryKey: string;
|
discoveryKey: string;
|
||||||
controlPath: string;
|
controlPath: string;
|
||||||
netInterface: string;
|
netInterface: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user