Feature: Offline device history with delete and remote uninstall

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

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

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

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

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

No files matched your search

@@ -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())
}
}
}