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

+231 -22
View File
@@ -455,11 +455,43 @@
border-color: rgba(233, 69, 96, 0.3);
}
.device-card:hover::before { opacity: 1; }
/* 终端图标按钮:右上角,独立于卡片本体点击事件(onclick stopPropagation) */
.device-card .card-term-btn {
/* 离线设备:只展示,无点击/终端操作。虚线边框 + 灰显与在线卡片区分。 */
.device-card.offline {
opacity: 0.55;
cursor: not-allowed;
border-style: dashed;
}
.device-card.offline:hover {
transform: none;
box-shadow: none;
border-color: rgba(255,255,255,0.05);
}
.device-card.offline::before { display: none; }
.device-card.offline h3 { color: #999; }
.device-card .last-seen {
margin-top: 8px;
font-size: 12px;
color: #777;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-grid.list-view .device-card .last-seen {
margin-top: 0;
max-width: 200px;
flex-shrink: 1;
}
/* 卡片操作图标(终端 / 删除):右上角,独立于卡片本体点击事件
(onclick stopPropagation)。List 视图卡片是一行,改为垂直居中。 */
.device-card .card-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
z-index: 1;
}
.device-card .card-icon-btn {
width: 32px;
height: 32px;
border: none;
@@ -471,11 +503,16 @@
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 1;
}
.device-card .card-icon-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
.device-card .card-icon-btn:active { transform: scale(0.92); }
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
.device-card .card-term-btn:active { transform: scale(0.92); }
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
.device-card .card-del-btn:hover { background: rgba(231, 76, 60, 0.25); color: #e74c3c; }
.device-card h3 { padding-right: 86px; } /* 给右上角两个操作图标让位 */
.device-grid.list-view .device-card .card-actions {
top: 50%;
transform: translateY(-50%);
}
/* ====== 终端页面 ====== */
#term-page {
@@ -605,9 +642,11 @@
display: flex;
align-items: center;
padding: 12px 16px;
padding-right: 100px; /* 给垂直居中的操作图标列让位 */
gap: 20px;
}
.device-grid.list-view .device-card::before { display: none; }
.device-grid.list-view .device-card h3 { padding-right: 0; }
.device-grid.list-view .device-card h3 {
min-width: 180px;
margin-bottom: 0;
@@ -669,6 +708,20 @@
}
.stats-bar .stat { color: #888; }
.stats-bar .stat strong { color: #e94560; }
/* 离线设备开关:服务端宣告 offlineDevices 能力后才显示(C++ 服务端没有该能力位)。 */
.stats-bar .stat.offline-toggle {
display: none;
align-items: center;
gap: 6px;
margin-left: auto;
cursor: pointer;
user-select: none;
}
.stats-bar .stat.offline-toggle.visible { display: flex; }
.stats-bar .stat.offline-toggle input {
accent-color: #e94560;
cursor: pointer;
}
#screen-page {
position: fixed;
@@ -1476,6 +1529,11 @@
<div id="stats-bar" class="stats-bar">
<div class="stat">Total: <strong id="stat-total">0</strong></div>
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
<label class="stat offline-toggle" id="offline-toggle"
title="Show devices that have connected before but are offline now">
<input type="checkbox" id="show-offline" onchange="onOfflineToggle(this.checked)">
Offline
</label>
</div>
<div id="device-list" class="device-grid"></div>
<div id="pagination" class="pagination"></div>
@@ -1660,6 +1718,26 @@
</div>
</div>
<!-- Delete Device Confirmation Modal(离线=删除历史记录;在线=下发卸载主机) -->
<div class="modal-overlay" id="delete-device-modal" onclick="if(event.target===this)cancelDeleteDevice()">
<div class="confirm-modal-content">
<div class="confirm-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</div>
<h3 id="delete-device-title">Delete Device</h3>
<p id="delete-device-msg"></p>
<div class="confirm-modal-actions">
<button class="cancel-btn" onclick="cancelDeleteDevice()">Cancel</button>
<button class="danger-btn" id="delete-device-confirm" onclick="confirmDeleteDevice()">Delete</button>
</div>
</div>
</div>
<script src="__BASE_PATH__/static/xterm.js"></script>
<script src="__BASE_PATH__/static/xterm-fit.js"></script>
@@ -1703,7 +1781,15 @@
};
// 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。
let serverCaps = { multiWatch: false, keyframeRequest: false };
let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false };
// 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。
// 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。
const SHOW_OFFLINE_STORAGE_KEY = 'showOfflineDevices';
let showOffline = false;
try {
showOffline = localStorage.getItem(SHOW_OFFLINE_STORAGE_KEY) === '1';
} catch (e) { /* 隐私模式下 localStorage 可能不可用 */ }
let wall = {
active: false,
layout: isTouchDevice ? 4 : 9,
@@ -2120,9 +2206,11 @@
// 能力协商:旧 C++ / Go 服务端没有 caps → 隐藏 Wall,保持单设备行为。
serverCaps = {
multiWatch: !!(msg.caps && msg.caps.multiWatch),
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest)
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest),
offlineDevices: !!(msg.caps && msg.caps.offlineDevices)
};
updateWallAvailability();
applyOfflineCapability();
console.log('Received challenge nonce; caps=', serverCaps);
break;
case 'salt':
@@ -2370,6 +2458,15 @@
showUserMsg(msg.msg || 'Failed to delete user', true);
}
break;
case 'delete_device_result':
if (msg.ok) {
// mode=uninstall:命令已下发,设备稍后自行断开;
// mode=removed:历史记录已删除,刷新后卡片消失。
getDevices();
} else {
alert(msg.msg || 'Failed to delete device');
}
break;
case 'list_users_result':
if (msg.ok) {
renderUsersList(msg.users);
@@ -3091,6 +3188,7 @@
function wallTooltipHtml(d) {
// 与 Grid 模式 device-card 的字段/顺序保持一致。
const offline = d.online === false;
const screenInfo = escapeHtml(d.screen || '-');
const rtt = d.rtt || '-';
const activeWin = d.activeWindow || '';
@@ -3101,11 +3199,14 @@
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
(offline
? '<span class="meta-item offline-badge">Offline</span>'
: '<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>') +
'<span class="meta-item">Ver: ' + escapeHtml(d.version || '-') + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
(!offline && activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '">' +
escapeHtml(activeWin) + '</div>' : '');
}
@@ -3749,8 +3850,14 @@
}
}
function scopeDevices() {
// Total / Showing 都基于当前范围:未勾选 Offline 时只统计在线设备。
if (showOffline) return devices;
return devices.filter(d => d.online !== false);
}
function getFilteredDevices() {
let filtered = devices;
let filtered = scopeDevices();
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(d =>
@@ -3766,6 +3873,37 @@
return filtered;
}
function applyOfflineCapability() {
const label = document.getElementById('offline-toggle');
const cb = document.getElementById('show-offline');
if (!label || !cb) return;
const supported = !!serverCaps.offlineDevices;
label.classList.toggle('visible', supported);
if (!supported) showOffline = false;
cb.checked = showOffline;
renderDeviceList();
}
function onOfflineToggle(checked) {
showOffline = !!checked;
try {
localStorage.setItem(SHOW_OFFLINE_STORAGE_KEY, showOffline ? '1' : '0');
} catch (e) { /* ignore */ }
currentPage = 1;
renderDeviceList();
}
function formatLastSeen(ts) {
if (!ts) return 'unknown';
const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (secs < 60) return 'just now';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
const days = Math.floor(secs / 86400);
if (days < 30) return days + 'd ago';
return new Date(ts * 1000).toLocaleDateString();
}
function onSearchChange() {
searchQuery = document.getElementById('search-input').value;
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
@@ -3865,9 +4003,11 @@
const start = (currentPage - 1) * ps;
const pageDevices = filtered.slice(start, start + ps);
// Update stats
document.getElementById('device-count').textContent = devices.length;
document.getElementById('stat-total').textContent = devices.length;
// Update stats. Total is the current scope (online-only unless the
// Offline toggle is on); Showing is after the search filter.
const scope = scopeDevices();
document.getElementById('device-count').textContent = scope.length;
document.getElementById('stat-total').textContent = scope.length;
document.getElementById('stat-showing').textContent = filtered.length;
// Render devices
@@ -3876,18 +4016,42 @@
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
} else {
list.innerHTML = pageDevices.map(d => {
const offline = d.online === false;
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
const loc = d.location || '-';
const rtt = d.rtt || '-';
const ver = d.version || '-';
const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>' +
// 操作图标:在线卡片有终端;删除对所有卡片开放(离线=删记录,
// 在线=卸载主机),仅管理员可用。Wall 模式不提供删除。
const actions = [];
if (!offline) {
actions.push(
'<button class="card-icon-btn card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>');
}
if (canDeleteDevices()) {
actions.push(
'<button class="card-icon-btn card-del-btn" title="' +
(offline ? 'Delete record' : 'Uninstall host') + '" aria-label="Delete" ' +
'onclick="event.stopPropagation();askDeleteDevice(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="3 6 5 6 21 6"/>' +
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
'<path d="M10 11v6"/><path d="M14 11v6"/>' +
'<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
'</svg>' +
'</button>');
}
const actionsHtml = actions.length
? '<div class="card-actions">' + actions.join('') + '</div>' : '';
return '<div class="device-card' + (offline ? ' offline' : '') + '"' +
(offline ? '' : ' onclick="connectDevice(\'' + d.id + '\')"') + '>' +
actionsHtml +
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
@@ -3895,11 +4059,14 @@
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
(offline
? '<span class="meta-item offline-badge">Offline</span>'
: '<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>') +
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
(offline ? '<div class="last-seen">Last seen: ' + escapeHtml(formatLastSeen(d.last_seen)) + '</div>' : '') +
(!offline && activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
'</div>';
}).join('');
}
@@ -4042,6 +4209,48 @@
document.getElementById('users-btn').classList.remove('visible');
}
// ===== 删除设备 =====
// 两种情况都删除服务端 devices.json 中的历史记录;在线设备额外按 MFC
// 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。
// 仅管理员可见/可用。
let pendingDeleteDeviceId = null;
function canDeleteDevices() {
return currentUserRole === 'admin';
}
function askDeleteDevice(id) {
if (!canDeleteDevices()) return;
const d = devices.find(x => String(x.id) === String(id));
if (!d) { alert('Device not found'); return; }
const offline = d.online === false;
const name = displayName(d);
pendingDeleteDeviceId = String(id);
document.getElementById('delete-device-title').textContent =
offline ? 'Delete Device Record' : 'Uninstall Host';
document.getElementById('delete-device-msg').innerHTML = offline
? 'Remove <strong>' + escapeHtml(name) + '</strong> from the server\'s device history?' +
'<br>This cannot be undone.'
: 'Send the uninstall command to <strong>' + escapeHtml(name) + '</strong>?' +
'<br>The remote agent will uninstall itself, disconnect, and its record will be deleted.';
document.getElementById('delete-device-confirm').textContent =
offline ? 'Delete' : 'Uninstall';
document.getElementById('delete-device-modal').classList.add('active');
}
function cancelDeleteDevice() {
pendingDeleteDeviceId = null;
document.getElementById('delete-device-modal').classList.remove('active');
}
function confirmDeleteDevice() {
const id = pendingDeleteDeviceId;
document.getElementById('delete-device-modal').classList.remove('active');
pendingDeleteDeviceId = null;
if (!id || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ cmd: 'delete_device', token, id }));
}
// User Management Functions
let currentUserRole = 'viewer';