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:
1 parent
1922f87dd2
commit
b1ddec1b8a
12 files changed
+1212
-27
No files matched your search
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user