From b1ddec1b8ac009d0a01d4192e676e905f78872c5 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Sun, 18 Oct 2026 17:54:30 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + server/go/cmd/main.go | 42 ++ server/go/devicehistory/devicehistory.go | 373 ++++++++++++++++++ server/go/devicehistory/devicehistory_test.go | 263 ++++++++++++ server/go/hub/hub.go | 14 + server/go/protocol/commands.go | 1 + server/go/web/delete_device_test.go | 131 ++++++ server/go/web/device_list_test.go | 53 +++ server/go/web/server.go | 12 +- server/go/web/ws.go | 7 +- server/go/web/ws_handlers.go | 89 ++++- server/web/index.html | 253 ++++++++++-- 12 files changed, 1212 insertions(+), 27 deletions(-) create mode 100644 server/go/devicehistory/devicehistory.go create mode 100644 server/go/devicehistory/devicehistory_test.go create mode 100644 server/go/web/delete_device_test.go create mode 100644 server/go/web/device_list_test.go diff --git a/.gitignore b/.gitignore index e457359..c9683cc 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ Bin/* nul server/go/web/assets/index.html server/go/users.json +server/go/devices.json server/go/build/ server/go/.claude/settings.json android/app/.cxx/ diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index d0d403c..62f3c9a 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -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") } diff --git a/server/go/devicehistory/devicehistory.go b/server/go/devicehistory/devicehistory.go new file mode 100644 index 0000000..f612808 --- /dev/null +++ b/server/go/devicehistory/devicehistory.go @@ -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 +} diff --git a/server/go/devicehistory/devicehistory_test.go b/server/go/devicehistory/devicehistory_test.go new file mode 100644 index 0000000..87a4099 --- /dev/null +++ b/server/go/devicehistory/devicehistory_test.go @@ -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()) + } + } +} diff --git a/server/go/hub/hub.go b/server/go/hub/hub.go index 65124b5..c9f3d0a 100644 --- a/server/go/hub/hub.go +++ b/server/go/hub/hub.go @@ -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). diff --git a/server/go/protocol/commands.go b/server/go/protocol/commands.go index d9129e6..0308969 100644 --- a/server/go/protocol/commands.go +++ b/server/go/protocol/commands.go @@ -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 diff --git a/server/go/web/delete_device_test.go b/server/go/web/delete_device_test.go new file mode 100644 index 0000000..40e7814 --- /dev/null +++ b/server/go/web/delete_device_test.go @@ -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") + } +} diff --git a/server/go/web/device_list_test.go b/server/go/web/device_list_test.go new file mode 100644 index 0000000..94dc8fc --- /dev/null +++ b/server/go/web/device_list_test.go @@ -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) + } +} diff --git a/server/go/web/server.go b/server/go/web/server.go index 4fea6e6..d15dc4f 100644 --- a/server/go/web/server.go +++ b/server/go/web/server.go @@ -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) diff --git a/server/go/web/ws.go b/server/go/web/ws.go index da99764..a98f6fc 100644 --- a/server/go/web/ws.go +++ b/server/go/web/ws.go @@ -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), diff --git a/server/go/web/ws_handlers.go b/server/go/web/ws_handlers.go index 91813fe..a6c896c 100644 --- a/server/go/web/ws_handlers.go +++ b/server/go/web/ws_handlers.go @@ -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) { diff --git a/server/web/index.html b/server/web/index.html index 363d97e..07dfa3e 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -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 @@
Total: 0
Showing: 0
+
@@ -1660,6 +1718,26 @@ + + + @@ -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 @@ '' + '
OS: ' + escapeHtml(d.os || '-') + '
' + '
' + - 'RTT: ' + escapeHtml(rtt) + '' + + (offline + ? 'Offline' + : 'RTT: ' + escapeHtml(rtt) + '') + 'Ver: ' + escapeHtml(d.version || '-') + '' + '' + screenInfo + '' + '
' + - (activeWin ? '
' + + (offline ? '
Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '
' : '') + + (!offline && activeWin ? '
' + escapeHtml(activeWin) + '
' : ''); } @@ -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 = '

No devices found

Try adjusting your search or filter

'; } 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 '
' + - '' + + // 操作图标:在线卡片有终端;删除对所有卡片开放(离线=删记录, + // 在线=卸载主机),仅管理员可用。Wall 模式不提供删除。 + const actions = []; + if (!offline) { + actions.push( + ''); + } + if (canDeleteDevices()) { + actions.push( + ''); + } + const actionsHtml = actions.length + ? '
' + actions.join('') + '
' : ''; + return '
' + + actionsHtml + '

' + escapeHtml(displayName(d)) + '

' + '
' + '
IP: ' + escapeHtml(d.ip || '-') + '
' + @@ -3895,11 +4059,14 @@ '
' + '
OS: ' + escapeHtml(d.os || '-') + '
' + '
' + - 'RTT: ' + escapeHtml(rtt) + '' + + (offline + ? 'Offline' + : 'RTT: ' + escapeHtml(rtt) + '') + 'Ver: ' + escapeHtml(ver) + '' + '' + screenInfo + '' + '
' + - (activeWin ? '
' + escapeHtml(activeWin) + '
' : '') + + (offline ? '
Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '
' : '') + + (!offline && activeWin ? '
' + escapeHtml(activeWin) + '
' : '') + '
'; }).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 ' + escapeHtml(name) + ' from the server\'s device history?' + + '
This cannot be undone.' + : 'Send the uninstall command to ' + escapeHtml(name) + '?' + + '
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';