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
+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