Feature: Offline device history with delete and remote uninstall

Persist every device that has ever logged in to the Go server in a small
JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by
YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json
convention: atomic temp-file write with fsync and rename, a 500 ms debounce
for bursts of reconnects, first_seen preserved across logins, and pruning
that evicts offline records before devices that are still connected.
Devices connected at shutdown are stamped offline before the final flush so
last_seen stays accurate across restarts.

The server advertises an offlineDevices capability and device_list now
appends historical devices that are not online, marked with online:false and
last_seen. The web UI shows an Offline checkbox after Total/Showing (state
kept in localStorage), renders offline cards dimmed and dashed, and always
lists them in the screen-wall sidebar. Older servers without the capability
keep the online-only list.

Grid and List cards gain a delete button after the terminal button, admin
only, behind a confirmation dialog. Deleting an offline device removes its
history record; deleting an online device first sends the single-byte
TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then
removes the record too, so the device does not come back as a dead offline
entry. The agent's own disconnect then runs the existing cleanup path:
screen and terminal sub-connections are closed, the hub entry is removed,
and browsers receive device_offline to release their watch sets, wall
sessions, and stream references. Wall mode exposes no delete action.

Tests cover history persistence/pruning/recovery, offline ordering, the
device_list merge, and all three delete outcomes (offline removal, online
uninstall with a single 0x3F byte, and viewer rejection).

Co-Authored-By: deepseek-flash
This commit is contained in:
yuanyuanxianganddeepseek-flash committed 2026-10-18 17:54:30 +02:00
1 parent 1922f87dd2
commit b1ddec1b8a
12 files changed
+1212 -27

No files matched your search

+42
View File
@@ -18,6 +18,7 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
@@ -931,6 +932,34 @@ func main() {
// the HTTP server reads from it.
deviceHub := hub.New()
// Persistent device history: every device that has ever logged in, so the
// web UI can keep showing it after it goes offline. Same working-directory
// convention as users.json. Retention is capped by count (newest last_seen
// wins) so the file and the device_list payload stay bounded.
devicesFile := os.Getenv("YAMA_DEVICES_FILE")
if devicesFile == "" {
devicesFile = "devices.json"
rememberDefault("YAMA_DEVICES_FILE", devicesFile)
}
devicesMax := 1000
if v := os.Getenv("YAMA_DEVICES_MAX"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
devicesMax = n
} else {
log.Warn("Invalid YAMA_DEVICES_MAX=%q (want a positive integer); using %d", v, devicesMax)
}
}
deviceHistory, histErr := devicehistory.Open(devicesFile, devicesMax,
log.WithPrefix("History"))
if histErr != nil {
// Keep the store: live recording still works, and the next flush
// replaces the unreadable file. Losing old history is preferable to
// refusing to start the server.
log.Warn("Failed to load %s: %v (starting with empty history)", devicesFile, histErr)
}
deviceHub.Subscribe(deviceHistory)
log.Info("Device history: %s (max %d)", devicesFile, devicesMax)
// Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
@@ -1154,6 +1183,7 @@ func main() {
// Start HTTP server for web UI. Hub gives it read-only access to the
// device registry; the authenticator owns user accounts and session tokens.
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
WithDeviceHistory(deviceHistory).
WithConfig(webCfg)
if err := httpSrv.Start(); err != nil {
log.Fatal("Failed to start HTTP server: %v", err)
@@ -1227,5 +1257,17 @@ func main() {
for _, srv := range servers {
srv.Stop()
}
// Devices still connected at shutdown never fire OnDeviceOffline (their
// TCP handlers are stopped above), so stamp them now. Otherwise a device
// that had been online for days would show a stale "last seen" after the
// restart until it reconnects.
for _, d := range deviceHub.ListDevices() {
deviceHistory.OnDeviceOffline(d.ID)
}
// Last-chance flush: the debounce timer may hold the final offline
// timestamps from devices that dropped while we were shutting down.
if err := deviceHistory.Close(); err != nil {
log.Warn("device history flush: %v", err)
}
log.Info("Server stopped")
}
+373
View File
@@ -0,0 +1,373 @@
// Package devicehistory persists the set of devices that have ever logged in
// to the Go server, so the web UI can keep listing them after they go offline.
//
// It implements hub.EventHandler and is subscribed to the hub, which means the
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
// the record (refreshing the display fields the client reports at login) and
// offline events merely refresh last_seen.
//
// Storage is a small JSON file, mirroring the wsauth users.json convention:
// atomic temp-file + fsync + rename, loaded once at startup and written back
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
package devicehistory
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
)
const (
// fileVersion lets a future release migrate the on-disk shape. Unknown
// versions are still parsed best-effort; only structurally invalid JSON
// makes Open fail.
fileVersion = 1
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
// a server restart makes every agent reconnect at once; without the
// debounce that would be one file write per device.
flushDelay = 500 * time.Millisecond
)
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
// offline list can be rendered with the same front-end card code; only the
// fields that are stable across sessions are kept (RTT / active window are
// live-only and would be stale noise on an offline card).
type Record struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Version string `json:"version,omitempty"`
Capability string `json:"capability,omitempty"`
OS string `json:"os,omitempty"`
CPU string `json:"cpu,omitempty"`
FilePath string `json:"file_path,omitempty"`
InstallTime string `json:"install_time,omitempty"`
Location string `json:"location,omitempty"`
PeerIP string `json:"peer_ip,omitempty"`
IP string `json:"ip,omitempty"`
Screen string `json:"screen,omitempty"`
FirstSeen int64 `json:"first_seen"`
LastSeen int64 `json:"last_seen"`
}
type fileShape struct {
Version int `json:"version"`
Devices map[string]*Record `json:"devices"`
}
// Store is the in-memory device history plus its backing file. Safe for
// concurrent use.
type Store struct {
path string
max int // 0 = unlimited
log *logger.Logger
mu sync.Mutex
devices map[string]*Record
// online is derived from hub events and used only by pruning: a device
// that is still connected must keep its record even if its last login is
// older than the 1000th offline device's. Not persisted — after a restart
// every device is offline until it reconnects.
online map[string]bool
dirty bool
timer *time.Timer
}
// Open loads the history at path. A missing file is an empty history, not an
// error. If the file exists but cannot be parsed, the returned store is still
// usable (empty) along with the error, so callers can log and continue — the
// next flush will replace the corrupt file.
func Open(path string, max int, log *logger.Logger) (*Store, error) {
s := newStore(path, max, log)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
return s, err
}
var f fileShape
if err := json.Unmarshal(data, &f); err != nil {
return s, fmt.Errorf("parse %s: %w", path, err)
}
for id, r := range f.Devices {
if r == nil {
continue
}
// The map key is authoritative; tolerate records written by a
// future/older shape that omitted or disagreed with the id field.
r.ID = id
s.devices[id] = r
}
s.pruneLocked()
return s, nil
}
// New returns an empty store for path without touching disk. Useful when Open
// failed and the caller still wants live recording to work.
func New(path string, max int, log *logger.Logger) *Store {
return newStore(path, max, log)
}
func newStore(path string, max int, log *logger.Logger) *Store {
return &Store{
path: path,
max: max,
log: log,
devices: make(map[string]*Record),
online: make(map[string]bool),
}
}
// OnDeviceOnline records a device that just logged in. It refreshes every
// display field because the agent reports its current name/group/version at
// each login, and preserves first_seen across reconnects.
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
s.upsertAt(d, time.Now())
}
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
if d.ID == "" {
return
}
now := at.Unix()
s.mu.Lock()
rec, ok := s.devices[d.ID]
if !ok {
rec = &Record{ID: d.ID, FirstSeen: now}
s.devices[d.ID] = rec
}
if rec.FirstSeen == 0 {
rec.FirstSeen = now
}
rec.Name = d.Name
rec.Group = d.Group
rec.Version = d.Version
rec.Capability = d.Capability
rec.OS = d.OS
rec.CPU = d.CPU
rec.FilePath = d.FilePath
rec.InstallTime = d.InstallTime
rec.Location = d.Location
rec.PeerIP = d.PeerIP
rec.IP = d.IP
rec.Screen = d.Screen
rec.LastSeen = now
s.online[d.ID] = true
s.pruneLocked()
s.markDirtyLocked()
s.mu.Unlock()
}
// OnDeviceOffline refreshes last_seen. The record was created when the device
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
// reaches the hub).
func (s *Store) OnDeviceOffline(id string) {
if id == "" {
return
}
s.offlineAt(id, time.Now())
}
func (s *Store) offlineAt(id string, at time.Time) {
s.mu.Lock()
delete(s.online, id)
if rec, ok := s.devices[id]; ok {
rec.LastSeen = at.Unix()
s.markDirtyLocked()
}
s.mu.Unlock()
}
// The remaining EventHandler methods are not relevant to device history.
func (s *Store) OnDeviceUpdate(string, int, string) {}
func (s *Store) OnScreenFrame(string, []byte, bool) {}
func (s *Store) OnResolutionChange(string, int, int) {}
func (s *Store) OnCursorChange(string, byte) {}
func (s *Store) OnClipboardText(string, string) {}
func (s *Store) OnTerminalReady(string, bool) {}
func (s *Store) OnTerminalData(string, []byte) {}
func (s *Store) OnTerminalClosed(string, string) {}
// Offline returns the records whose IDs are not in online, newest last_seen
// first, converted to the hub's device payload shape with Online=false.
//
// Callers pass the hub's current online snapshot; the store deliberately does
// not know which device is online, so a reconnect that re-registers the same
// ID can never produce a duplicate row.
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
s.mu.Lock()
out := make([]hub.DeviceInfo, 0, len(s.devices))
for id, rec := range s.devices {
if _, ok := online[id]; ok {
continue
}
out = append(out, hub.DeviceInfo{
ID: rec.ID,
Name: rec.Name,
Group: rec.Group,
Version: rec.Version,
Capability: rec.Capability,
OS: rec.OS,
CPU: rec.CPU,
FilePath: rec.FilePath,
InstallTime: rec.InstallTime,
Location: rec.Location,
IP: rec.IP,
PeerIP: rec.PeerIP,
Screen: rec.Screen,
LastSeen: rec.LastSeen,
Online: false,
})
}
s.mu.Unlock()
sort.Slice(out, func(i, j int) bool {
if out[i].LastSeen != out[j].LastSeen {
return out[i].LastSeen > out[j].LastSeen
}
return out[i].ID < out[j].ID
})
return out
}
// Remove deletes the persisted record for id and reports whether one existed.
// This is the web UI's explicit "delete" for an offline device: an offline
// record has no live agent to uninstall, so forgetting it is all "delete" can
// mean. The caller decides when removal is appropriate; the store does not
// refuse to drop a record that happens to be marked online.
func (s *Store) Remove(id string) bool {
if id == "" {
return false
}
s.mu.Lock()
_, existed := s.devices[id]
if existed {
delete(s.devices, id)
delete(s.online, id)
s.markDirtyLocked()
}
s.mu.Unlock()
return existed
}
// Flush writes the current state synchronously. Normally the debounced timer
// does this; Flush exists for tests and graceful shutdown.
func (s *Store) Flush() error {
s.mu.Lock()
if s.timer != nil {
s.timer.Stop()
s.timer = nil
}
if !s.dirty {
s.mu.Unlock()
return nil
}
s.dirty = false
data, err := s.marshalLocked()
s.mu.Unlock()
if err != nil {
return err
}
if err := writeAtomic(s.path, data); err != nil {
s.mu.Lock()
s.dirty = true
s.mu.Unlock()
return err
}
return nil
}
// Close stops the debounce timer and flushes pending changes.
func (s *Store) Close() error {
return s.Flush()
}
func (s *Store) markDirtyLocked() {
s.dirty = true
if s.timer == nil {
s.timer = time.AfterFunc(flushDelay, s.flush)
}
}
func (s *Store) flush() {
if err := s.Flush(); err != nil && s.log != nil {
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
}
}
func (s *Store) marshalLocked() ([]byte, error) {
f := fileShape{Version: fileVersion, Devices: s.devices}
return json.MarshalIndent(f, "", " ")
}
// pruneLocked keeps the max most-recently-seen records. It runs after every
// online upsert, which is the only path that can grow the map; the O(n log n)
// sort only happens when the cap is actually exceeded.
func (s *Store) pruneLocked() {
if s.max <= 0 || len(s.devices) <= s.max {
return
}
ids := make([]string, 0, len(s.devices))
for id := range s.devices {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool {
oi, oj := s.online[ids[i]], s.online[ids[j]]
if oi != oj {
return !oi // evict offline records before online ones
}
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
})
for _, id := range ids[:len(ids)-s.max] {
delete(s.devices, id)
}
}
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
// fsync, then rename over the destination.
func writeAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return err
}
return nil
}
@@ -0,0 +1,263 @@
package devicehistory
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
)
func testDevice(id, name string) hub.DeviceInfo {
return hub.DeviceInfo{
ID: id,
Name: name,
Group: "office",
Version: "1.2.3",
OS: "Windows 11",
IP: "203.0.113.7",
PeerIP: "198.51.100.9",
Screen: "1:1920*1080",
Online: true,
}
}
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
h := hub.New()
h.Subscribe(s)
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
// The device is online, so it must not appear in the offline list.
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
t.Fatalf("offline while online = %d, want 0", len(got))
}
h.Unregister("dev-1")
off := s.Offline(nil)
if len(off) != 1 {
t.Fatalf("offline entries = %d, want 1", len(off))
}
d := off[0]
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
t.Fatalf("offline device = %+v", d)
}
if d.Online {
t.Fatal("offline entry marked online")
}
if d.LastSeen == 0 {
t.Fatal("last_seen not set")
}
}
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
first := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("dev-1", "old-name"), first)
// A later login refreshes display fields and last_seen but keeps first_seen.
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
_ = s.Close()
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
reopened.mu.Lock()
rec := reopened.devices["dev-1"]
reopened.mu.Unlock()
if rec == nil {
t.Fatal("record not persisted")
}
if rec.Name != "new-name" {
t.Fatalf("name = %q, want new-name", rec.Name)
}
if rec.FirstSeen != first.Unix() {
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
}
if rec.LastSeen != first.Add(time.Hour).Unix() {
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
}
}
func TestStorePrunesOldest(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 2, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("a", "A"), base)
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
s.mu.Lock()
_, hasA := s.devices["a"]
_, hasC := s.devices["c"]
n := len(s.devices)
s.mu.Unlock()
if n != 2 || hasA || !hasC {
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
}
}
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 2, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("online-old", "Old"), base)
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
s.offlineAt("offline-new", base.Add(2*time.Minute))
// This upsert exceeds the cap; the offline record must be evicted before
// the still-connected device, even though its last_seen is newer.
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
s.mu.Lock()
_, hasOld := s.devices["online-old"]
_, hasGone := s.devices["offline-new"]
_, hasNew := s.devices["online-2"]
n := len(s.devices)
s.mu.Unlock()
if n != 2 || !hasOld || hasGone || !hasNew {
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
}
}
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("old", "Old"), base)
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
off := s.Offline(nil)
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
t.Fatalf("offline order = %+v, want new then old", off)
}
}
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path, 1000, nil)
if err == nil {
t.Fatal("Open corrupt file: want error, got nil")
}
if len(s.devices) != 0 {
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
}
t.Cleanup(func() { _ = s.Close() })
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
if err := s.Flush(); err != nil {
t.Fatalf("Flush after corrupt load: %v", err)
}
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if len(reopened.devices) != 1 {
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
}
}
func TestStoreRemovePersistsDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("gone", "Gone"), base)
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
if !s.Remove("gone") {
t.Fatal("Remove existing record = false, want true")
}
if s.Remove("gone") {
t.Fatal("Remove missing record = true, want false")
}
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
t.Fatalf("offline after remove = %+v, want only kept", got)
}
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
_ = s.Close()
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if _, ok := reopened.devices["gone"]; ok {
t.Fatal("removed record came back after reload")
}
if _, ok := reopened.devices["kept"]; !ok {
t.Fatal("kept record missing after reload")
}
}
func TestFlushLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("leftover temp file: %s", e.Name())
}
}
}
+14
View File
@@ -165,6 +165,9 @@ type DeviceInfo struct {
ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"`
Online bool `json:"online"`
// LastSeen is unix seconds of the last login/disconnect. Only populated
// for offline entries in device_list; online devices leave it zero.
LastSeen int64 `json:"last_seen,omitempty"`
}
// EventHandler receives notifications about device lifecycle, per-tick live
@@ -240,6 +243,17 @@ func (h *Hub) SetSender(fn SendFunc) {
h.sender = fn
}
// IsOnline reports whether id is currently registered with a live main
// connection. Callers that need to choose between an online-only command and
// an offline bookkeeping action should check this first; SendToDevice can
// still turn into ErrDeviceOffline if the device drops in between.
func (h *Hub) IsOnline(id string) bool {
h.mu.RLock()
d, ok := h.devices[id]
h.mu.RUnlock()
return ok && d.conn != nil
}
// SendToDevice forwards an already-formed command payload to the device's
// main connection. data should be the raw command bytes (the sender takes
// care of framing / compression at the protocol layer).
+1
View File
@@ -110,6 +110,7 @@ const (
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
CommandUninstall byte = 63 // TOKEN_UNINSTALL - uninstall the host agent (single byte)
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
CommandScreenKeyframeReq byte = 169 // COMMAND_SCREEN_KEYFRAME_REQ - force the client to emit an IDR immediately
+131
View File
@@ -0,0 +1,131 @@
package web
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
t.Helper()
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
auth := wsauth.New()
wh := &wsHub{
auth: auth,
devices: h,
history: hist,
log: logger.New(logger.Config{}),
clients: make(map[*wsClient]struct{}),
}
return wh, h, hist
}
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
tok, err := wh.auth.IssueToken("tester", role)
if err != nil {
panic(err)
}
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
wh.register(c)
return c, tok
}
func deleteReply(t *testing.T, c *wsClient) map[string]any {
t.Helper()
for {
select {
case m := <-c.send:
var out map[string]any
if err := json.Unmarshal(m.data, &out); err != nil {
t.Fatalf("decode reply: %v", err)
}
if out["cmd"] == "delete_device_result" {
return out
}
default:
t.Fatal("no delete_device_result reply")
return nil
}
}
}
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "removed" {
t.Fatalf("reply = %+v, want ok/removed", rep)
}
if hist.Remove("gone") {
t.Fatal("history record still present after delete")
}
}
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
wh, h, hist := deleteTestHub(t)
var sent []byte
h.SetSender(func(_ *connection.Context, data []byte) error {
sent = append([]byte(nil), data...)
return nil
})
h.Register(&hub.Device{ID: "live", Name: "Live"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "uninstall" {
t.Fatalf("reply = %+v, want ok/uninstall", rep)
}
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
}
// The history record is dropped along with the uninstall, so the device
// does not reappear as a dead offline entry after it disconnects.
if hist.Remove("live") {
t.Fatal("online device's history record survived the uninstall")
}
}
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "viewer")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != false {
t.Fatalf("reply = %+v, want ok:false", rep)
}
if !hist.Remove("gone") {
t.Fatal("viewer delete removed the history record")
}
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"path/filepath"
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
)
func TestListDevicesWithOffline(t *testing.T) {
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
h.Register(&hub.Device{ID: "online", Name: "Online"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
h.Unregister("gone")
wh := &wsHub{devices: h, history: hist}
got := wh.listDevicesWithOffline()
if len(got) != 2 {
t.Fatalf("devices = %d, want 2 (1 online + 1 offline)", len(got))
}
if got[0].ID != "online" || !got[0].Online {
t.Fatalf("first = %+v, want online device", got[0])
}
if got[1].ID != "gone" || got[1].Online {
t.Fatalf("second = %+v, want offline device", got[1])
}
if got[1].LastSeen == 0 {
t.Fatal("offline device missing last_seen")
}
}
func TestListDevicesWithoutHistoryIsOnlineOnly(t *testing.T) {
h := hub.New()
h.Register(&hub.Device{ID: "online", Name: "Online"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
wh := &wsHub{devices: h}
got := wh.listDevicesWithOffline()
if len(got) != 1 || got[0].ID != "online" {
t.Fatalf("devices = %+v, want just the online device", got)
}
}
+11 -1
View File
@@ -16,6 +16,7 @@ import (
"unicode"
"unicode/utf8"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
@@ -30,6 +31,7 @@ type Server struct {
srv *http.Server
hub *hub.Hub
auth *wsauth.Authenticator
history *devicehistory.Store // nil → no offline devices
ws *wsHub
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
loginIPLimit *wsauth.RateLimiter
@@ -86,6 +88,14 @@ func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *
return &Server{port: port, log: log, hub: h, auth: auth}
}
// WithDeviceHistory attaches the persistent device-history store. When set,
// device_list also returns devices that have logged in before but are offline
// now. Passing nil keeps the online-only behaviour. Safe to call before Start.
func (s *Server) WithDeviceHistory(h *devicehistory.Store) *Server {
s.history = h
return s
}
// WithConfig applies hardening configuration. Returns the receiver for
// chainable setup. Safe to call before Start; ignored thereafter.
func (s *Server) WithConfig(cfg Config) *Server {
@@ -107,7 +117,7 @@ func (s *Server) Start() error {
return nil
}
s.ws = newWSHub(s.auth, s.hub, s.log).
s.ws = newWSHub(s.auth, s.hub, s.history, s.log).
withOriginAllowlist(s.allowedOrigins).
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
withTrustForwardedFor(s.trustForwardedFor)
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
@@ -96,7 +97,7 @@ func (c *wsClient) close() {
// challengeCaps advertises optional protocol features. It is appended to every
// challenge, including the rotation after a failed login, because the browser
// overwrites its capability state on each challenge. Old frontends ignore it.
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true}`
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true}`
// maxWatchPerClient bounds the number of devices one WebSocket may stream at
// once. The wall tops out at 25 tiles; the slack covers a focus switch racing
@@ -116,6 +117,7 @@ func (c *wsClient) isWatching(deviceID string) bool {
type wsHub struct {
auth *wsauth.Authenticator
devices *hub.Hub
history *devicehistory.Store // nil → device_list stays online-only
log *logger.Logger
mu sync.RWMutex
@@ -138,10 +140,11 @@ type wsHub struct {
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
}
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, history *devicehistory.Store, log *logger.Logger) *wsHub {
h := &wsHub{
auth: auth,
devices: devices,
history: history,
log: log,
clients: make(map[*wsClient]struct{}),
screenCloseTimers: make(map[string]*time.Timer),
+87 -2
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
"errors"
"sort"
"time"
@@ -82,6 +83,8 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleTermClose(c, raw)
// Admin operations (Phase 7).
case "delete_device":
h.handleDeleteDevice(c, raw)
case "create_user":
h.handleCreateUser(c, raw)
case "delete_user":
@@ -576,14 +579,29 @@ func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) {
if !h.requireAuth(c, raw, "device_list") {
return
}
devices := h.devices.ListDevices()
c.queue(mustJSON(map[string]any{
"cmd": "device_list",
"ok": true,
"devices": devices,
"devices": h.listDevicesWithOffline(),
}))
}
// listDevicesWithOffline returns the online snapshot followed by historical
// devices that are not currently online. The online-ID set is derived from the
// same snapshot, so a device reconnecting between the two lookups cannot appear
// twice. With no history store wired up this is just the online list.
func (h *wsHub) listDevicesWithOffline() []hub.DeviceInfo {
devices := h.devices.ListDevices()
if h.history == nil {
return devices
}
online := make(map[string]struct{}, len(devices))
for _, d := range devices {
online[d.ID] = struct{}{}
}
return append(devices, h.history.Offline(online)...)
}
// requireAuth validates the token embedded in raw against the authenticator's
// session store (not against c.token). Tokens live independently of WS
// connections — the browser may reconnect after a visibility/network blip and
@@ -841,6 +859,73 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
}
// handleDeleteDevice implements the device list's "Delete" action.
//
// The meaning depends on liveness, matching the MFC client's
// OnOnlineUninstall: an online agent is told to uninstall itself with the
// single-byte TOKEN_UNINSTALL command; an offline device has no agent to
// talk to. Either way the persisted history record is removed as well, so
// the deleted device does not linger in the offline list.
//
// Admin-only: this is destructive and irreversible.
func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
const replyCmd = "delete_device_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Invalid device id",
}))
return
}
if h.devices.IsOnline(in.ID) {
err := h.devices.SendToDevice(in.ID, []byte{protocol.CommandUninstall})
if err == nil {
// Drop the record too: after the agent uninstalls itself it will
// never log in again, so keeping it would leave a dead entry in
// the offline list. The disconnect event that follows finds no
// record and is a no-op.
if h.history != nil {
h.history.Remove(in.ID)
}
h.log.Info("device uninstall requested by %s: id=%s (record removed)", c.role, in.ID)
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall",
}))
return
}
// Lost the race with a disconnect between IsOnline and the send:
// fall through to removing the (now offline) history record.
if !errors.Is(err, hub.ErrDeviceOffline) {
h.log.Warn("device uninstall failed for %s: %v", in.ID, err)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": err.Error(),
}))
return
}
}
if h.history == nil || !h.history.Remove(in.ID) {
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Device not found",
}))
return
}
h.log.Info("device record removed by %s: id=%s", c.role, in.ID)
// Other browsers (and this one) refresh their lists; the reply below
// still gives the initiator a precise result/toast.
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "removed",
}))
}
// handleDeleteUser removes an account and revokes any of its live sessions.
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
+231 -22
View File
@@ -455,11 +455,43 @@
border-color: rgba(233, 69, 96, 0.3);
}
.device-card:hover::before { opacity: 1; }
/* 终端图标按钮:右上角,独立于卡片本体点击事件(onclick stopPropagation) */
.device-card .card-term-btn {
/* 离线设备:只展示,无点击/终端操作。虚线边框 + 灰显与在线卡片区分。 */
.device-card.offline {
opacity: 0.55;
cursor: not-allowed;
border-style: dashed;
}
.device-card.offline:hover {
transform: none;
box-shadow: none;
border-color: rgba(255,255,255,0.05);
}
.device-card.offline::before { display: none; }
.device-card.offline h3 { color: #999; }
.device-card .last-seen {
margin-top: 8px;
font-size: 12px;
color: #777;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-grid.list-view .device-card .last-seen {
margin-top: 0;
max-width: 200px;
flex-shrink: 1;
}
/* 卡片操作图标(终端 / 删除):右上角,独立于卡片本体点击事件
(onclick stopPropagation)。List 视图卡片是一行,改为垂直居中。 */
.device-card .card-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
z-index: 1;
}
.device-card .card-icon-btn {
width: 32px;
height: 32px;
border: none;
@@ -471,11 +503,16 @@
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 1;
}
.device-card .card-icon-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
.device-card .card-icon-btn:active { transform: scale(0.92); }
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
.device-card .card-term-btn:active { transform: scale(0.92); }
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
.device-card .card-del-btn:hover { background: rgba(231, 76, 60, 0.25); color: #e74c3c; }
.device-card h3 { padding-right: 86px; } /* 给右上角两个操作图标让位 */
.device-grid.list-view .device-card .card-actions {
top: 50%;
transform: translateY(-50%);
}
/* ====== 终端页面 ====== */
#term-page {
@@ -605,9 +642,11 @@
display: flex;
align-items: center;
padding: 12px 16px;
padding-right: 100px; /* 给垂直居中的操作图标列让位 */
gap: 20px;
}
.device-grid.list-view .device-card::before { display: none; }
.device-grid.list-view .device-card h3 { padding-right: 0; }
.device-grid.list-view .device-card h3 {
min-width: 180px;
margin-bottom: 0;
@@ -669,6 +708,20 @@
}
.stats-bar .stat { color: #888; }
.stats-bar .stat strong { color: #e94560; }
/* 离线设备开关:服务端宣告 offlineDevices 能力后才显示(C++ 服务端没有该能力位)。 */
.stats-bar .stat.offline-toggle {
display: none;
align-items: center;
gap: 6px;
margin-left: auto;
cursor: pointer;
user-select: none;
}
.stats-bar .stat.offline-toggle.visible { display: flex; }
.stats-bar .stat.offline-toggle input {
accent-color: #e94560;
cursor: pointer;
}
#screen-page {
position: fixed;
@@ -1476,6 +1529,11 @@
<div id="stats-bar" class="stats-bar">
<div class="stat">Total: <strong id="stat-total">0</strong></div>
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
<label class="stat offline-toggle" id="offline-toggle"
title="Show devices that have connected before but are offline now">
<input type="checkbox" id="show-offline" onchange="onOfflineToggle(this.checked)">
Offline
</label>
</div>
<div id="device-list" class="device-grid"></div>
<div id="pagination" class="pagination"></div>
@@ -1660,6 +1718,26 @@
</div>
</div>
<!-- Delete Device Confirmation Modal(离线=删除历史记录;在线=下发卸载主机) -->
<div class="modal-overlay" id="delete-device-modal" onclick="if(event.target===this)cancelDeleteDevice()">
<div class="confirm-modal-content">
<div class="confirm-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</div>
<h3 id="delete-device-title">Delete Device</h3>
<p id="delete-device-msg"></p>
<div class="confirm-modal-actions">
<button class="cancel-btn" onclick="cancelDeleteDevice()">Cancel</button>
<button class="danger-btn" id="delete-device-confirm" onclick="confirmDeleteDevice()">Delete</button>
</div>
</div>
</div>
<script src="__BASE_PATH__/static/xterm.js"></script>
<script src="__BASE_PATH__/static/xterm-fit.js"></script>
@@ -1703,7 +1781,15 @@
};
// 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。
let serverCaps = { multiWatch: false, keyframeRequest: false };
let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false };
// 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。
// 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。
const SHOW_OFFLINE_STORAGE_KEY = 'showOfflineDevices';
let showOffline = false;
try {
showOffline = localStorage.getItem(SHOW_OFFLINE_STORAGE_KEY) === '1';
} catch (e) { /* 隐私模式下 localStorage 可能不可用 */ }
let wall = {
active: false,
layout: isTouchDevice ? 4 : 9,
@@ -2120,9 +2206,11 @@
// 能力协商:旧 C++ / Go 服务端没有 caps → 隐藏 Wall,保持单设备行为。
serverCaps = {
multiWatch: !!(msg.caps && msg.caps.multiWatch),
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest)
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest),
offlineDevices: !!(msg.caps && msg.caps.offlineDevices)
};
updateWallAvailability();
applyOfflineCapability();
console.log('Received challenge nonce; caps=', serverCaps);
break;
case 'salt':
@@ -2370,6 +2458,15 @@
showUserMsg(msg.msg || 'Failed to delete user', true);
}
break;
case 'delete_device_result':
if (msg.ok) {
// mode=uninstall:命令已下发,设备稍后自行断开;
// mode=removed:历史记录已删除,刷新后卡片消失。
getDevices();
} else {
alert(msg.msg || 'Failed to delete device');
}
break;
case 'list_users_result':
if (msg.ok) {
renderUsersList(msg.users);
@@ -3091,6 +3188,7 @@
function wallTooltipHtml(d) {
// 与 Grid 模式 device-card 的字段/顺序保持一致。
const offline = d.online === false;
const screenInfo = escapeHtml(d.screen || '-');
const rtt = d.rtt || '-';
const activeWin = d.activeWindow || '';
@@ -3101,11 +3199,14 @@
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
(offline
? '<span class="meta-item offline-badge">Offline</span>'
: '<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>') +
'<span class="meta-item">Ver: ' + escapeHtml(d.version || '-') + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
(!offline && activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
escapeHtml(activeWin) + '</div>' : '');
}
@@ -3749,8 +3850,14 @@
}
}
function scopeDevices() {
// Total / Showing 都基于当前范围:未勾选 Offline 时只统计在线设备。
if (showOffline) return devices;
return devices.filter(d => d.online !== false);
}
function getFilteredDevices() {
let filtered = devices;
let filtered = scopeDevices();
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(d =>
@@ -3766,6 +3873,37 @@
return filtered;
}
function applyOfflineCapability() {
const label = document.getElementById('offline-toggle');
const cb = document.getElementById('show-offline');
if (!label || !cb) return;
const supported = !!serverCaps.offlineDevices;
label.classList.toggle('visible', supported);
if (!supported) showOffline = false;
cb.checked = showOffline;
renderDeviceList();
}
function onOfflineToggle(checked) {
showOffline = !!checked;
try {
localStorage.setItem(SHOW_OFFLINE_STORAGE_KEY, showOffline ? '1' : '0');
} catch (e) { /* ignore */ }
currentPage = 1;
renderDeviceList();
}
function formatLastSeen(ts) {
if (!ts) return 'unknown';
const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (secs < 60) return 'just now';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
const days = Math.floor(secs / 86400);
if (days < 30) return days + 'd ago';
return new Date(ts * 1000).toLocaleDateString();
}
function onSearchChange() {
searchQuery = document.getElementById('search-input').value;
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
@@ -3865,9 +4003,11 @@
const start = (currentPage - 1) * ps;
const pageDevices = filtered.slice(start, start + ps);
// Update stats
document.getElementById('device-count').textContent = devices.length;
document.getElementById('stat-total').textContent = devices.length;
// Update stats. Total is the current scope (online-only unless the
// Offline toggle is on); Showing is after the search filter.
const scope = scopeDevices();
document.getElementById('device-count').textContent = scope.length;
document.getElementById('stat-total').textContent = scope.length;
document.getElementById('stat-showing').textContent = filtered.length;
// Render devices
@@ -3876,18 +4016,42 @@
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
} else {
list.innerHTML = pageDevices.map(d => {
const offline = d.online === false;
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
const loc = d.location || '-';
const rtt = d.rtt || '-';
const ver = d.version || '-';
const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>' +
// 操作图标:在线卡片有终端;删除对所有卡片开放(离线=删记录,
// 在线=卸载主机),仅管理员可用。Wall 模式不提供删除。
const actions = [];
if (!offline) {
actions.push(
'<button class="card-icon-btn card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>');
}
if (canDeleteDevices()) {
actions.push(
'<button class="card-icon-btn card-del-btn" title="' +
(offline ? 'Delete record' : 'Uninstall host') + '" aria-label="Delete" ' +
'onclick="event.stopPropagation();askDeleteDevice(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="3 6 5 6 21 6"/>' +
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
'<path d="M10 11v6"/><path d="M14 11v6"/>' +
'<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
'</svg>' +
'</button>');
}
const actionsHtml = actions.length
? '<div class="card-actions">' + actions.join('') + '</div>' : '';
return '<div class="device-card' + (offline ? ' offline' : '') + '"' +
(offline ? '' : ' onclick="connectDevice(\'' + d.id + '\')"') + '>' +
actionsHtml +
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
@@ -3895,11 +4059,14 @@
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
(offline
? '<span class="meta-item offline-badge">Offline</span>'
: '<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>') +
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
(!offline && activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
'</div>';
}).join('');
}
@@ -4042,6 +4209,48 @@
document.getElementById('users-btn').classList.remove('visible');
}
// ===== 删除设备 =====
// 两种情况都删除服务端 devices.json 中的历史记录;在线设备额外按 MFC
// 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。
// 仅管理员可见/可用。
let pendingDeleteDeviceId = null;
function canDeleteDevices() {
return currentUserRole === 'admin';
}
function askDeleteDevice(id) {
if (!canDeleteDevices()) return;
const d = devices.find(x => String(x.id) === String(id));
if (!d) { alert('Device not found'); return; }
const offline = d.online === false;
const name = displayName(d);
pendingDeleteDeviceId = String(id);
document.getElementById('delete-device-title').textContent =
offline ? 'Delete Device Record' : 'Uninstall Host';
document.getElementById('delete-device-msg').innerHTML = offline
? 'Remove <strong>' + escapeHtml(name) + '</strong> from the server\'s device history?' +
'<br>This cannot be undone.'
: 'Send the uninstall command to <strong>' + escapeHtml(name) + '</strong>?' +
'<br>The remote agent will uninstall itself, disconnect, and its record will be deleted.';
document.getElementById('delete-device-confirm').textContent =
offline ? 'Delete' : 'Uninstall';
document.getElementById('delete-device-modal').classList.add('active');
}
function cancelDeleteDevice() {
pendingDeleteDeviceId = null;
document.getElementById('delete-device-modal').classList.remove('active');
}
function confirmDeleteDevice() {
const id = pendingDeleteDeviceId;
document.getElementById('delete-device-modal').classList.remove('active');
pendingDeleteDeviceId = null;
if (!id || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ cmd: 'delete_device', token, id }));
}
// User Management Functions
let currentUserRole = 'viewer';