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
264 lines
6.9 KiB
Go
264 lines
6.9 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|