Files
BetterDesk/web-nodejs/public/js/widget-plugins.js
T
UNITRONIX 0f161181f1 feat(installers): run services under unprivileged accounts by default
Privilege separation across all installers so the long-running services no longer run with full administrative rights:

betterdesk.sh: installer keeps root but systemd units now run as a dedicated unprivileged 'betterdesk' system account by default (auto-created via ensure_service_user). Added full systemd hardening for the Go server (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, ReadWritePaths) and light hardening for the Node.js console. chown migrates existing root-owned data to the service account on update. Opt-out via --run-as-root / BETTERDESK_RUN_AS_ROOT=1; custom account via BETTERDESK_SERVICE_USER. Minimal mode covered too.

betterdesk.ps1: NSSM services now run under their per-service low-privilege virtual accounts (NT SERVICE\<service>) instead of LocalSystem, with scoped icacls grants on the install/data dirs (Set-ServiceLeastPrivilege helper). Applied to the Go server, Node.js console and minimal-mode service. Opt-out via -RunAsRoot / BETTERDESK_RUN_AS_ROOT=1.

Docker: verified already privilege-separated (supervisord drops both programs to user=betterdesk; multi-container images drop via su-exec).

Also bundles in-progress changes to the Go server API, Node.js console services and Docker compose/Dockerfiles.

This commit was made possible thanks to Insolve.
2026-05-31 00:35:28 +02:00

1810 lines
94 KiB
JavaScript

/**
* BetterDesk Console — Widget Plugin System
* Registry + 12 built-in widget types for the desktop dashboard.
* Depends: desktop-widgets.js, Utils, _ (i18n)
*/
(function () {
'use strict';
var _registry = new Map();
var t = function (k) { return typeof _ === 'function' ? _(k) : k; };
function esc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// ============ Registry API ============
function register(type, config) {
if (!type || !config) return;
config.type = type;
_registry.set(type, config);
}
function get(type) { return _registry.get(type); }
function list() {
var arr = [];
_registry.forEach(function (v) { arr.push(v); });
return arr;
}
// ============ Helpers ============
function apiGet(url) {
if (typeof Utils !== 'undefined' && Utils.api) {
return Utils.api(url).then(function (res) {
if (res && typeof res.json === 'function') return res.json();
return res;
});
}
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.json(); });
}
function buildGaugeSVG(pct, size) {
size = size || 100;
var r = (size - 8) / 2;
var circ = 2 * Math.PI * r;
var off = circ * (1 - pct / 100);
var cls = pct > 85 ? 'danger' : (pct > 65 ? 'warning' : '');
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 ' + size + ' ' + size + '">' +
'<circle class="gauge-bg" cx="' + (size / 2) + '" cy="' + (size / 2) + '" r="' + r + '"/>' +
'<circle class="gauge-fill ' + cls + '" cx="' + (size / 2) + '" cy="' + (size / 2) + '" r="' + r + '" ' +
'stroke-dasharray="' + circ + '" stroke-dashoffset="' + off + '"/>' +
'</svg>';
}
function timeSince(date) {
var seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h';
return Math.floor(seconds / 86400) + 'd';
}
// ============ Built-in Widgets ============
// 1. Clock
register('clock', {
name: t('desktop.widget_clock'),
icon: 'schedule',
color: '#58a6ff',
category: 'general',
description: 'Digital clock with date',
defaultSize: { w: 240, h: 160 },
minSize: { w: 180, h: 120 },
_interval: null,
render: function (body) {
body.innerHTML = '<div class="widget-clock-time"></div><div class="widget-clock-date"></div>';
this._tick(body);
var self = this;
body._clockInterval = setInterval(function () { self._tick(body); }, 1000);
},
_tick: function (body) {
var now = new Date();
var timeEl = body.querySelector('.widget-clock-time');
var dateEl = body.querySelector('.widget-clock-date');
if (timeEl) {
var h = String(now.getHours()).padStart(2, '0');
var m = String(now.getMinutes()).padStart(2, '0');
var s = String(now.getSeconds()).padStart(2, '0');
timeEl.innerHTML = h + ':' + m + '<span class="widget-clock-seconds">:' + s + '</span>';
}
if (dateEl) {
dateEl.textContent = now.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
},
destroy: function (body) {
if (body && body._clockInterval) {
clearInterval(body._clockInterval);
body._clockInterval = null;
}
}
});
// 2. Device Status (counters)
register('device-status', {
name: t('desktop.widget_device_status'),
icon: 'devices',
color: '#3fb950',
category: 'monitoring',
description: 'Online / offline / total device counters',
defaultSize: { w: 320, h: 160 },
minSize: { w: 240, h: 130 },
updateInterval: 15000,
render: function (body) {
body.innerHTML = '<div class="widget-status-counters">' +
'<div class="widget-status-counter online"><div class="widget-status-counter-value">-</div><div class="widget-status-counter-label">' + t('desktop.label_online') + '</div></div>' +
'<div class="widget-status-counter offline"><div class="widget-status-counter-value">-</div><div class="widget-status-counter-label">' + t('desktop.label_offline') + '</div></div>' +
'<div class="widget-status-counter total"><div class="widget-status-counter-value">-</div><div class="widget-status-counter-label">' + t('desktop.label_total') + '</div></div>' +
'</div>';
},
update: function (body) {
apiGet('/api/stats').then(function (data) {
if (!data || !data.devices) return;
var d = data.devices;
var online = body.querySelector('.online .widget-status-counter-value');
var offline = body.querySelector('.offline .widget-status-counter-value');
var total = body.querySelector('.total .widget-status-counter-value');
if (online) online.textContent = d.online || 0;
if (offline) offline.textContent = d.offline || 0;
if (total) total.textContent = d.total || 0;
}).catch(function () {});
}
});
// 3. Server Info (combined health + ports + uptime)
register('server-health', {
name: t('desktop.widget_server_health'),
icon: 'dns',
color: '#f0883e',
category: 'monitoring',
description: 'Services, ports, and uptime overview',
defaultSize: { w: 380, h: 280 },
minSize: { w: 300, h: 220 },
updateInterval: 20000,
render: function (body) {
body.innerHTML = '<div class="widget-server-info">' +
'<div class="widget-si-services">' +
'<div class="widget-si-svc" data-svc="signal"><div class="widget-si-dot unknown"></div><span>Signal</span></div>' +
'<div class="widget-si-svc" data-svc="relay"><div class="widget-si-dot unknown"></div><span>Relay</span></div>' +
'<div class="widget-si-svc" data-svc="console"><div class="widget-si-dot up"></div><span>Console</span></div>' +
'</div>' +
'<div class="widget-si-uptime" data-si="uptime">' + t('desktop.label_uptime') + ': -</div>' +
'<div class="widget-si-ports">' +
'<div class="widget-si-port-header"><span>' + t('desktop.label_service') + '</span><span>' + t('desktop.label_port') + '</span></div>' +
'</div></div>';
},
update: function (body) {
apiGet('/api/server/status').then(function (data) {
if (!data) return;
// Services
var sig = (data.signal || data.hbbs || {}).status || 'unknown';
var rel = (data.hbbr || {}).status || 'unknown';
var sigEl = body.querySelector('[data-svc="signal"]');
var relEl = body.querySelector('[data-svc="relay"]');
if (sigEl) { var d = sigEl.querySelector('.widget-si-dot'); if (d) d.className = 'widget-si-dot ' + (sig === 'running' ? 'up' : 'down'); }
if (relEl) { var d = relEl.querySelector('.widget-si-dot'); if (d) d.className = 'widget-si-dot ' + (rel === 'running' ? 'up' : 'down'); }
// Uptime
var ut = body.querySelector('[data-si="uptime"]');
if (ut && data.uptime != null) {
var s = data.uptime;
var days = Math.floor(s / 86400); s %= 86400;
var hrs = Math.floor(s / 3600); s %= 3600;
var mins = Math.floor(s / 60);
var parts = [];
if (days) parts.push(days + 'd');
if (hrs) parts.push(hrs + 'h');
parts.push(mins + 'm');
ut.textContent = t('desktop.label_uptime') + ': ' + parts.join(' ');
}
// Ports
var pc = body.querySelector('.widget-si-ports');
if (!pc) return;
var rows = [
{ name: 'Signal', port: data.signal_port || 21116, up: sig === 'running' },
{ name: 'Relay', port: data.relay_port || 21117, up: rel === 'running' },
{ name: 'WS Signal', port: data.ws_signal_port || 21118, up: sig === 'running' },
{ name: 'WS Relay', port: data.ws_relay_port || 21119, up: rel === 'running' },
{ name: 'API', port: data.api_port || 21121, up: true },
{ name: 'Client API', port: data.client_api_port || 21121, up: true },
{ name: 'Console', port: data.console_port || 5000, up: true }
];
var html = '<div class="widget-si-port-header"><span>' + t('desktop.label_service') + '</span><span>' + t('desktop.label_port') + '</span></div>';
rows.forEach(function (r) {
html += '<div class="widget-si-port-row"><span class="widget-si-port-dot ' + (r.up ? 'up' : 'down') + '"></span><span class="widget-si-port-name">' + esc(r.name) + '</span><span class="widget-si-port-num">' + r.port + '</span></div>';
});
pc.innerHTML = html;
}).catch(function () {});
}
});
// 4. Device Stats Gauges
register('system-stats', {
name: t('desktop.widget_system_stats'),
icon: 'speed',
color: '#bc8cff',
category: 'monitoring',
description: 'Online, offline, and blocked device ratios',
defaultSize: { w: 360, h: 200 },
minSize: { w: 300, h: 160 },
updateInterval: 10000,
render: function (body) {
body.innerHTML = '<div style="display:flex;gap:16px;justify-content:center;align-items:center;height:100%;">' +
'<div class="widget-gauge" id="gauge-cpu"><div class="widget-gauge-ring">' + buildGaugeSVG(0) + '<div class="widget-gauge-value">-</div></div><div class="widget-gauge-label">' + t('desktop.label_online') + '</div></div>' +
'<div class="widget-gauge" id="gauge-mem"><div class="widget-gauge-ring">' + buildGaugeSVG(0) + '<div class="widget-gauge-value">-</div></div><div class="widget-gauge-label">' + t('desktop.label_offline') + '</div></div>' +
'<div class="widget-gauge" id="gauge-disk"><div class="widget-gauge-ring">' + buildGaugeSVG(0) + '<div class="widget-gauge-value">-</div></div><div class="widget-gauge-label">' + t('desktop.label_blocked') + '</div></div>' +
'</div>';
},
update: function (body) {
apiGet('/api/stats').then(function (data) {
if (!data || !data.devices) return;
var d = data.devices;
var total = d.total || 1;
var onlinePct = Math.round(((d.online || 0) / total) * 100);
var offlinePct = Math.round(((d.offline || 0) / total) * 100);
var blockedPct = Math.round(((d.banned || d.blocked || 0) / total) * 100);
_updateGauge(body, 'gauge-cpu', onlinePct);
_updateGauge(body, 'gauge-mem', offlinePct);
_updateGauge(body, 'gauge-disk', blockedPct);
}).catch(function () {});
}
});
function _updateGauge(body, id, pct) {
var gauge = body.querySelector('#' + id);
if (!gauge) return;
var ring = gauge.querySelector('.widget-gauge-ring');
var val = gauge.querySelector('.widget-gauge-value');
if (ring) {
// Replace SVG
var svg = ring.querySelector('svg');
var newRing = document.createElement('div');
newRing.innerHTML = buildGaugeSVG(pct);
if (svg) ring.replaceChild(newRing.firstChild, svg);
}
if (val) val.textContent = Math.round(pct) + '%';
}
// 5. Device List (mini table)
register('device-list', {
name: t('desktop.widget_device_list'),
icon: 'list',
color: '#79c0ff',
category: 'devices',
description: 'Scrollable list of all devices',
defaultSize: { w: 360, h: 300 },
minSize: { w: 280, h: 200 },
updateInterval: 20000,
render: function (body) {
body.innerHTML = '<div class="widget-device-list"><div class="widget-loading" style="height:40px"></div></div>';
body.style.overflow = 'auto';
},
update: function (body) {
apiGet('/api/devices').then(function (data) {
var peers = [];
if (data && data.devices) peers = data.devices;
else if (Array.isArray(data)) peers = data;
if (!peers.length) return;
var list = body.querySelector('.widget-device-list');
if (!list) return;
if (!peers.length) {
list.innerHTML = '<div class="widget-empty"><span class="material-icons">devices</span><span>' + t('desktop.label_no_devices') + '</span></div>';
return;
}
var html = '';
peers.slice(0, 50).forEach(function (p) {
var online = p.live_online || p.online || false;
var status = online ? 'online' : 'offline';
html += '<div class="widget-device-row">' +
'<div class="widget-device-dot ' + status + '"></div>' +
'<div class="widget-device-id">' + esc(p.id) + '</div>' +
'<div class="widget-device-name">' + esc(p.hostname || p.name || '-') + '</div>' +
'<div class="widget-device-platform">' + esc(p.platform || p.os || '') + '</div>' +
'</div>';
});
list.innerHTML = html;
}).catch(function () {});
}
});
// 6. Quick Actions
register('quick-actions', {
name: t('desktop.widget_quick_actions'),
icon: 'flash_on',
color: '#d29922',
category: 'tools',
description: 'Common management shortcuts',
defaultSize: { w: 260, h: 210 },
minSize: { w: 200, h: 160 },
render: function (body) {
var actions = [
{ icon: 'add', label: t('desktop.action_add_widget'), action: 'add-widget', color: '#58a6ff' },
{ icon: 'wallpaper', label: t('desktop.wallpaper'), action: 'wallpaper', color: '#bc8cff' },
{ icon: 'devices', label: t('nav.devices'), action: 'nav', route: '/devices', color: '#3fb950' },
{ icon: 'dashboard', label: t('nav.dashboard'), action: 'nav', route: '/', color: '#f0883e' },
{ icon: 'vpn_key', label: t('nav.keys'), action: 'nav', route: '/keys', color: '#d29922' },
{ icon: 'settings', label: t('nav.settings'), action: 'nav', route: '/settings', color: '#8b949e' }
];
var html = '<div class="widget-actions-grid">';
actions.forEach(function (a) {
html += '<div class="widget-action-btn" data-action="' + esc(a.action) + '" data-route="' + esc(a.route || '') + '">' +
'<span class="material-icons" style="color:' + esc(a.color) + '">' + esc(a.icon) + '</span>' +
'<span>' + esc(a.label) + '</span>' +
'</div>';
});
html += '</div>';
body.innerHTML = html;
body.querySelectorAll('.widget-action-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.dataset.action;
if (action === 'nav' && btn.dataset.route) {
window.location.href = btn.dataset.route;
} else if (action === 'wallpaper' && window.DesktopWidgets) {
window.DesktopWidgets.openWallpaperPicker();
} else if (action === 'add-widget' && window.DesktopWidgets) {
window.DesktopWidgets.openPicker();
}
});
});
}
});
// 7. Recent Activity (feed)
register('recent-activity', {
name: t('desktop.widget_recent_activity'),
icon: 'history',
color: '#f778ba',
category: 'monitoring',
description: 'Latest audit events',
defaultSize: { w: 320, h: 280 },
minSize: { w: 240, h: 180 },
updateInterval: 15000,
render: function (body) {
body.innerHTML = '<div class="widget-feed-list"><div class="widget-loading" style="height:40px"></div></div>';
body.style.overflow = 'auto';
},
update: function (body) {
apiGet('/api/audit/conn?limit=20').then(function (data) {
var events = data;
if (data && data.data) events = data.data;
if (!Array.isArray(events)) return;
var list = body.querySelector('.widget-feed-list');
if (!list) return;
if (!events.length) {
list.innerHTML = '<div class="widget-empty"><span class="material-icons">history</span><span>' + t('desktop.label_no_activity') + '</span></div>';
return;
}
var html = '';
events.slice(0, 15).forEach(function (ev) {
var time = ev.created_at || ev.timestamp || '';
html += '<div class="widget-feed-item">' +
'<div class="widget-feed-time">' + esc(timeSince(time)) + '</div>' +
'<div class="widget-feed-text">' + esc(ev.action || ev.event_type || '') + ' — ' + esc(ev.details || ev.description || '') + '</div>' +
'</div>';
});
list.innerHTML = html;
}).catch(function () {});
}
});
// 8. Notes (sticky)
register('notes', {
name: t('desktop.widget_notes'),
icon: 'sticky_note_2',
color: '#d29922',
category: 'general',
description: 'Personal sticky note',
defaultSize: { w: 260, h: 240 },
minSize: { w: 180, h: 140 },
render: function (body, config) {
body.innerHTML = '<textarea class="widget-notes-textarea" placeholder="' + esc(t('desktop.notes_placeholder')) + '"></textarea>';
var ta = body.querySelector('.widget-notes-textarea');
ta.value = config.text || '';
var debounce = null;
ta.addEventListener('input', function () {
clearTimeout(debounce);
debounce = setTimeout(function () {
config.text = ta.value;
// Trigger layout save through widget engine
if (window.DesktopWidgets && window.DesktopWidgets.getWidgets) {
// save is triggered by config change
localStorage.setItem('bd_widget_layout', JSON.stringify(
Array.from(window.DesktopWidgets.getWidgets().values())
));
}
}, 500);
});
},
configForm: function (config) {
return [
{ key: 'text', label: 'Note text', type: 'textarea' }
];
}
});
// 9. Network Monitor
register('network-monitor', {
name: t('desktop.widget_network_monitor'),
icon: 'wifi',
color: '#3fb950',
category: 'monitoring',
description: 'Network target ping status',
defaultSize: { w: 300, h: 240 },
minSize: { w: 240, h: 180 },
updateInterval: 30000,
render: function (body) {
body.innerHTML = '<div class="widget-net-targets"><div class="widget-loading" style="height:40px"></div></div>';
},
update: function (body) {
apiGet('/api/network/targets').then(function (data) {
var targets = data;
if (data && data.data) targets = data.data;
if (!Array.isArray(targets)) { targets = []; }
var container = body.querySelector('.widget-net-targets');
if (!container) return;
if (!targets.length) {
container.innerHTML = '<div class="widget-empty"><span class="material-icons">wifi</span><span>' + t('desktop.label_no_targets') + '</span></div>';
return;
}
var html = '';
targets.slice(0, 10).forEach(function (tgt) {
var status = tgt.is_up ? 'up' : 'down';
html += '<div class="widget-net-target">' +
'<div class="widget-net-status ' + status + '"></div>' +
'<div class="widget-net-name">' + esc(tgt.name || tgt.host) + '</div>' +
'<div class="widget-net-latency">' + (tgt.avg_latency != null ? tgt.avg_latency + 'ms' : '-') + '</div>' +
'</div>';
});
container.innerHTML = html;
}).catch(function () {
var container = body.querySelector('.widget-net-targets');
if (container) container.innerHTML = '<div class="widget-empty"><span class="material-icons">wifi_off</span><span>' + t('desktop.label_unavailable') + '</span></div>';
});
}
});
// 10. Tickets Summary
register('tickets-summary', {
name: t('desktop.widget_tickets_summary'),
icon: 'confirmation_number',
color: '#f85149',
category: 'tools',
description: 'Open and resolved ticket counts',
defaultSize: { w: 260, h: 190 },
minSize: { w: 200, h: 150 },
updateInterval: 30000,
render: function (body) {
body.innerHTML = '<div>' +
'<div class="widget-tickets-row"><span class="widget-tickets-label">' + t('desktop.label_open') + '</span><span class="widget-tickets-count" style="color:#f85149" id="tickets-open">-</span></div>' +
'<div class="widget-tickets-row"><span class="widget-tickets-label">' + t('desktop.label_in_progress') + '</span><span class="widget-tickets-count" style="color:#d29922" id="tickets-progress">-</span></div>' +
'<div class="widget-tickets-row"><span class="widget-tickets-label">' + t('desktop.label_resolved') + '</span><span class="widget-tickets-count" style="color:#3fb950" id="tickets-resolved">-</span></div>' +
'</div>';
},
update: function (body) {
apiGet('/api/tickets/stats').then(function (data) {
if (!data) return;
var open = body.querySelector('#tickets-open');
var prog = body.querySelector('#tickets-progress');
var res = body.querySelector('#tickets-resolved');
if (open) open.textContent = data.open_count || data.open || 0;
if (prog) prog.textContent = data.in_progress_count || data.in_progress || 0;
if (res) res.textContent = data.resolved_count || data.resolved || 0;
}).catch(function () {});
}
});
// 11. iFrame
register('iframe', {
name: t('desktop.widget_iframe'),
icon: 'language',
color: '#8b949e',
category: 'general',
description: 'Embed any URL in a widget',
defaultSize: { w: 400, h: 300 },
minSize: { w: 200, h: 150 },
render: function (body, config) {
var url = config.url || '';
if (url) {
body.style.padding = '0';
body.innerHTML = '<iframe class="widget-iframe" src="' + esc(url) + '" sandbox="allow-same-origin allow-scripts"></iframe>';
} else {
body.innerHTML = '<div class="widget-empty"><span class="material-icons">language</span><span>' + t('desktop.label_set_url') + '</span></div>';
}
},
configForm: function (config) {
return [
{ key: 'url', label: 'URL', type: 'text' }
];
}
});
// 12. CDAP Integration Widget
register('cdap-devices', {
name: t('desktop.widget_cdap_devices'),
icon: 'developer_board',
color: '#bc8cff',
category: 'devices',
description: 'Connected CDAP devices & widgets',
defaultSize: { w: 320, h: 260 },
minSize: { w: 240, h: 180 },
updateInterval: 15000,
render: function (body) {
body.innerHTML = '<div class="widget-device-list"><div class="widget-loading" style="height:40px"></div></div>';
body.style.overflow = 'auto';
},
update: function (body) {
apiGet('/api/cdap/devices').then(function (data) {
var devices = data;
if (data && data.data) devices = data.data;
if (!Array.isArray(devices)) devices = [];
var list = body.querySelector('.widget-device-list');
if (!list) return;
if (!devices.length) {
list.innerHTML = '<div class="widget-empty"><span class="material-icons">developer_board</span><span>' + t('desktop.label_no_cdap_devices') + '</span></div>';
return;
}
var html = '';
devices.forEach(function (d) {
var status = d.connected ? 'online' : 'offline';
html += '<div class="widget-device-row">' +
'<div class="widget-device-dot ' + status + '"></div>' +
'<div class="widget-device-id">' + esc(d.id || d.device_id || '') + '</div>' +
'<div class="widget-device-name">' + esc(d.name || d.hostname || '-') + '</div>' +
'<div class="widget-device-platform">' + esc(d.type || d.device_type || '') + '</div>' +
'</div>';
});
list.innerHTML = html;
}).catch(function () {
var list = body.querySelector('.widget-device-list');
if (list) list.innerHTML = '<div class="widget-empty"><span class="material-icons">developer_board</span><span>' + t('desktop.label_unavailable') + '</span></div>';
});
}
});
// ============ 13. Uptime Timer ============
register('uptime', {
name: t('desktop.widget_uptime'),
icon: 'timer',
color: '#58a6ff',
category: 'monitoring',
description: 'Server uptime counter',
defaultSize: { w: 200, h: 160 },
minSize: { w: 160, h: 120 },
render: function (body) {
body.innerHTML = '<div class="widget-uptime">' +
'<div class="widget-uptime-value">--:--:--</div>' +
'<div class="widget-uptime-label">' + t('desktop.label_server_uptime') + '</div></div>';
body._uptimeStart = null;
body._uptimeBase = 0;
var self = this;
this._fetch(body);
body._uptimeTimer = setInterval(function () { self._tick(body); }, 1000);
},
_fetch: function (body) {
apiGet('/api/server/status').then(function (d) {
if (d && d.uptime) { body._uptimeBase = parseInt(d.uptime, 10) || 0; body._uptimeStart = Date.now(); }
else if (d && d.started_at) { body._uptimeStart = Date.now(); body._uptimeBase = Math.floor((Date.now() - new Date(d.started_at).getTime()) / 1000); }
}).catch(function () {});
},
_tick: function (body) {
var s = body._uptimeBase;
if (body._uptimeStart) s = body._uptimeBase + Math.floor((Date.now() - body._uptimeStart) / 1000);
if (!s && s !== 0) return;
var h = String(Math.floor(s / 3600)).padStart(2, '0');
var m = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
var sec = String(s % 60).padStart(2, '0');
var el = body.querySelector('.widget-uptime-value');
if (el) el.textContent = h + ':' + m + ':' + sec;
},
update: function (body) { this._fetch(body); },
destroy: function (body) {
if (body && body._uptimeTimer) { clearInterval(body._uptimeTimer); body._uptimeTimer = null; }
}
});
// ============ 14. Port Status Table ============
// ============ 14. Port Status (deprecated — merged into server-health) ============
register('port-status', {
name: t('desktop.widget_port_status'),
icon: 'lan',
color: '#79c0ff',
category: 'monitoring',
description: 'Network port status (use Server Info instead)',
defaultSize: { w: 380, h: 220 },
minSize: { w: 300, h: 160 },
updateInterval: 20000,
render: function (body) {
body.innerHTML = '<div class="widget-empty"><span class="material-icons">info</span><span>' + t('desktop.label_merged_server_info') + '</span></div>';
},
update: function () {}
});
// ============ 15. Device Icon Grid ============
register('device-grid', {
name: t('desktop.widget_device_grid'),
icon: 'grid_view',
color: '#3fb950',
category: 'devices',
description: 'Device icons with status indicators',
defaultSize: { w: 340, h: 240 },
minSize: { w: 260, h: 160 },
updateInterval: 15000,
render: function (body) {
body.innerHTML = '<div class="widget-device-grid-container"><div class="widget-loading" style="height:40px"></div></div>';
},
update: function (body) {
apiGet('/api/devices').then(function (data) {
var peers = data && data.devices ? data.devices : (Array.isArray(data) ? data : []);
var c = body.querySelector('.widget-device-grid-container');
if (!c) return;
if (!peers.length) {
c.innerHTML = '<div class="widget-empty"><span class="material-icons">devices</span><span>' + t('desktop.label_no_devices') + '</span></div>';
return;
}
var pi = { windows: 'laptop_windows', linux: 'computer', mac: 'laptop_mac', macos: 'laptop_mac', android: 'phone_android', ios: 'phone_iphone' };
var html = '';
peers.slice(0, 24).forEach(function (p) {
var on = p.live_online || p.online || false;
var plat = (p.platform || p.os || '').toLowerCase();
var icon = 'devices';
for (var k in pi) { if (plat.indexOf(k) !== -1) { icon = pi[k]; break; } }
html += '<div class="widget-dg-card ' + (on ? 'online' : 'offline') + '" title="' + esc(p.id + ' — ' + (p.hostname || '')) + '">' +
'<span class="material-icons widget-dg-icon">' + icon + '</span>' +
'<div class="widget-dg-name">' + esc(p.hostname || p.id || '-') + '</div>' +
'<div class="widget-dg-status">' + (on ? t('desktop.label_online') : t('desktop.label_offline')) + '</div></div>';
});
c.innerHTML = html;
}).catch(function () {});
}
});
// ============ 16. Multi-Gauge Cluster ============
register('multi-gauge', {
name: t('desktop.widget_multi_gauge'),
icon: 'donut_large',
color: '#f0883e',
category: 'monitoring',
description: 'Multiple resource gauges',
defaultSize: { w: 400, h: 180 },
minSize: { w: 320, h: 140 },
updateInterval: 10000,
render: function (body) {
var names = [t('desktop.label_online'), t('desktop.label_offline'), t('desktop.label_banned'), t('desktop.label_active'), t('desktop.label_total')];
var html = '<div class="widget-multi-gauge">';
names.forEach(function (n, i) {
html += '<div class="widget-mg-item" data-idx="' + i + '">' +
'<div class="widget-mg-ring">' + buildGaugeSVG(0, 64) + '<div class="widget-mg-value">-</div></div>' +
'<div class="widget-mg-label">' + n + '</div></div>';
});
body.innerHTML = html + '</div>';
},
update: function (body) {
apiGet('/api/stats').then(function (data) {
if (!data || !data.devices) return;
var d = data.devices;
var t = d.total || 1;
var vals = [
{ idx: 0, pct: Math.round(((d.online || 0) / t) * 100), raw: d.online || 0 },
{ idx: 1, pct: Math.round(((d.offline || 0) / t) * 100), raw: d.offline || 0 },
{ idx: 2, pct: Math.round(((d.banned || 0) / t) * 100), raw: d.banned || 0 },
{ idx: 3, pct: Math.round(((d.withNotes || 0) / t) * 100), raw: d.withNotes || 0 },
{ idx: 4, pct: 100, raw: d.total || 0 }
];
vals.forEach(function (v) {
var item = body.querySelector('[data-idx="' + v.idx + '"]');
if (!item) return;
var ring = item.querySelector('.widget-mg-ring');
var val = item.querySelector('.widget-mg-value');
if (ring) {
var svg = ring.querySelector('svg');
var tmp = document.createElement('div');
tmp.innerHTML = buildGaugeSVG(v.pct, 64);
if (svg && tmp.firstChild) ring.replaceChild(tmp.firstChild, svg);
}
if (val) val.textContent = v.raw;
});
}).catch(function () {});
}
});
// ============ 17. Weekly Activity Chart ============
register('weekly-chart', {
name: t('desktop.widget_weekly_chart'),
icon: 'bar_chart',
color: '#bc8cff',
category: 'monitoring',
description: 'Daily device activity bars',
defaultSize: { w: 340, h: 200 },
minSize: { w: 260, h: 160 },
updateInterval: 60000,
render: function (body) {
body.innerHTML = '<div class="widget-weekly"><div class="widget-weekly-bars"></div></div>';
this._draw(body);
},
_draw: function (body) {
var c = body.querySelector('.widget-weekly-bars');
if (!c) return;
var days = [t('time.day_mon'), t('time.day_tue'), t('time.day_wed'), t('time.day_thu'), t('time.day_fri'), t('time.day_sat'), t('time.day_sun')];
var dow = new Date().getDay();
var todayIdx = dow === 0 ? 6 : dow - 1;
var html = '';
days.forEach(function (d, i) {
var h1 = 30 + (i * 13) % 60;
var h2 = 20 + ((i + 3) * 17) % 50;
html += '<div class="widget-wb-col">' +
'<div class="widget-wb-bars">' +
'<div class="widget-wb-bar online" style="height:' + h1 + '%"></div>' +
'<div class="widget-wb-bar offline" style="height:' + h2 + '%"></div>' +
'</div>' +
'<div class="widget-wb-label' + (i === todayIdx ? ' today' : '') + '">' + d + '</div></div>';
});
c.innerHTML = html;
},
update: function (body) { this._draw(body); }
});
// ============ 18. Quick Controls ============
register('quick-controls', {
name: t('desktop.widget_quick_controls'),
icon: 'tune',
color: '#d29922',
category: 'devices',
description: 'Device management shortcuts',
defaultSize: { w: 400, h: 280 },
minSize: { w: 300, h: 200 },
updateInterval: 15000,
render: function (body) {
body.innerHTML = '<div class="widget-qc"><div class="widget-qc-filter">' +
'<input type="text" class="widget-qc-search" placeholder="' + t('desktop.label_filter_devices') + '">' +
'<div class="widget-qc-pills">' +
'<button class="widget-qc-pill active" data-filter="all">' + t('desktop.label_all') + '</button>' +
'<button class="widget-qc-pill" data-filter="online">' + t('desktop.label_online') + '</button>' +
'<button class="widget-qc-pill" data-filter="offline">' + t('desktop.label_offline') + '</button>' +
'</div></div>' +
'<div class="widget-qc-list"><div class="widget-loading" style="height:40px"></div></div></div>';
// Filter pills
body.querySelector('.widget-qc-pills').addEventListener('click', function (e) {
var pill = e.target.closest('.widget-qc-pill');
if (!pill) return;
body.querySelectorAll('.widget-qc-pill').forEach(function (p) { p.classList.remove('active'); });
pill.classList.add('active');
_filterQC(body);
});
// Search
body.querySelector('.widget-qc-search').addEventListener('input', function () { _filterQC(body); });
},
update: function (body) {
apiGet('/api/devices').then(function (data) {
var peers = (data && data.devices ? data.devices : []);
var c = body.querySelector('.widget-qc-list');
if (!c) return;
if (!peers.length) {
c.innerHTML = '<div class="widget-empty"><span class="material-icons">devices</span><span>' + t('desktop.label_no_devices') + '</span></div>';
return;
}
var pi = { windows: 'laptop_windows', linux: 'computer', mac: 'laptop_mac', macos: 'laptop_mac', android: 'phone_android', ios: 'phone_iphone' };
var html = '';
peers.forEach(function (p) {
var on = p.live_online || p.online || false;
var banned = p.banned || false;
var plat = (p.platform || p.os || '').toLowerCase();
var icon = 'devices';
for (var k in pi) { if (plat.indexOf(k) !== -1) { icon = pi[k]; break; } }
var name = p.hostname || p.id || '-';
html += '<div class="widget-qc-row' + (on ? ' online' : ' offline') + (banned ? ' banned' : '') + '" data-id="' + esc(p.id) + '" data-status="' + (on ? 'online' : 'offline') + '" data-name="' + esc(name.toLowerCase()) + '">' +
'<span class="material-icons widget-qc-icon">' + icon + '</span>' +
'<div class="widget-qc-info">' +
'<div class="widget-qc-name">' + esc(name) + '</div>' +
'<div class="widget-qc-id">' + esc(p.id) + (banned ? ' <span class="widget-qc-badge ban">' + t('desktop.label_banned') + '</span>' : '') + '</div>' +
'</div>' +
'<div class="widget-qc-actions">' +
'<button class="widget-qc-btn" data-action="toggle-ban" title="' + (banned ? t('desktop.label_unban') : t('desktop.label_ban')) + '"><span class="material-icons">' + (banned ? 'lock_open' : 'block') + '</span></button>' +
(on ? '<button class="widget-qc-btn connect" data-action="connect" title="' + t('desktop.label_connect') + '"><span class="material-icons">launch</span></button>' : '') +
'</div></div>';
});
c.innerHTML = html;
_filterQC(body);
// Action handlers
c.querySelectorAll('.widget-qc-btn').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var row = btn.closest('.widget-qc-row');
var id = row ? row.dataset.id : '';
var action = btn.dataset.action;
if (action === 'connect' && id) {
window.open('rustdesk://' + id, '_blank');
} else if (action === 'toggle-ban' && id) {
var isBanned = row.classList.contains('banned');
var url = '/api/devices/' + encodeURIComponent(id) + (isBanned ? '/unban' : '/ban');
fetch(url, { method: 'POST', headers: { 'X-CSRF-Token': (window.BetterDesk || {}).csrfToken || '' } })
.then(function () { btn.closest('.widget-qc-row').classList.toggle('banned'); })
.catch(function () {});
}
});
});
}).catch(function () {});
}
});
function _filterQC(body) {
var search = (body.querySelector('.widget-qc-search') || {}).value || '';
search = search.toLowerCase();
var activePill = body.querySelector('.widget-qc-pill.active');
var filter = activePill ? activePill.dataset.filter : 'all';
body.querySelectorAll('.widget-qc-row').forEach(function (row) {
var matchFilter = filter === 'all' || row.dataset.status === filter;
var matchSearch = !search || (row.dataset.name || '').indexOf(search) !== -1 || (row.dataset.id || '').toLowerCase().indexOf(search) !== -1;
row.style.display = (matchFilter && matchSearch) ? '' : 'none';
});
}
// ============ 19. Bandwidth Display ============
register('bandwidth', {
name: t('desktop.widget_bandwidth'),
icon: 'speed',
color: '#58a6ff',
category: 'monitoring',
description: 'Relay bandwidth & session metrics',
defaultSize: { w: 280, h: 200 },
minSize: { w: 220, h: 160 },
updateInterval: 10000,
_prevBytes: 0,
_prevTime: 0,
render: function (body) {
body.innerHTML = '<div class="widget-bandwidth">' +
'<div class="widget-bw-item">' +
'<div class="widget-bw-header"><span class="material-icons" style="color:#3fb950">swap_vert</span><span>' + t('desktop.label_throughput') + '</span></div>' +
'<div class="widget-bw-value" data-bw="speed">-</div>' +
'<div class="widget-bw-bar"><div class="widget-bw-fill download" data-bar="speed" style="width:0%"></div></div></div>' +
'<div class="widget-bw-item">' +
'<div class="widget-bw-header"><span class="material-icons" style="color:#58a6ff">cable</span><span>' + t('desktop.label_active_relays') + '</span></div>' +
'<div class="widget-bw-value" data-bw="relays">-</div>' +
'<div class="widget-bw-bar"><div class="widget-bw-fill upload" data-bar="relays" style="width:0%"></div></div></div>' +
'<div style="display:flex;gap:12px;margin-top:4px;">' +
'<div class="widget-bw-stat"><span class="widget-bw-stat-label">' + t('desktop.label_total_relayed') + '</span><span class="widget-bw-stat-val" data-bw="total">-</span></div>' +
'<div class="widget-bw-stat"><span class="widget-bw-stat-label">' + t('desktop.label_total_bytes') + '</span><span class="widget-bw-stat-val" data-bw="bytes">-</span></div>' +
'</div></div>';
},
update: function (body) {
var self = this;
apiGet('/api/server/bandwidth').then(function (data) {
if (!data) return;
var relays = data.relay_active || 0;
var bytes = data.bytes_transferred || 0;
var sessions = data.active_sessions || 0;
var totalRelayed = data.total_relayed || 0;
// Calculate speed (bytes/sec delta)
var now = Date.now();
var speed = 0;
if (self._prevTime && self._prevBytes) {
var dt = (now - self._prevTime) / 1000;
if (dt > 0) speed = Math.max(0, (bytes - self._prevBytes) / dt);
}
self._prevBytes = bytes;
self._prevTime = now;
// Format speed
var speedStr;
if (speed >= 1073741824) speedStr = (speed / 1073741824).toFixed(1) + ' GB/s';
else if (speed >= 1048576) speedStr = (speed / 1048576).toFixed(1) + ' MB/s';
else if (speed >= 1024) speedStr = (speed / 1024).toFixed(1) + ' KB/s';
else speedStr = Math.round(speed) + ' B/s';
// Format total bytes
var bytesStr;
if (bytes >= 1073741824) bytesStr = (bytes / 1073741824).toFixed(1) + ' GB';
else if (bytes >= 1048576) bytesStr = (bytes / 1048576).toFixed(1) + ' MB';
else if (bytes >= 1024) bytesStr = (bytes / 1024).toFixed(1) + ' KB';
else bytesStr = bytes + ' B';
// Update DOM
var sv = body.querySelector('[data-bw="speed"]');
var rv = body.querySelector('[data-bw="relays"]');
var tv = body.querySelector('[data-bw="total"]');
var bv = body.querySelector('[data-bw="bytes"]');
var sb = body.querySelector('[data-bar="speed"]');
var rb = body.querySelector('[data-bar="relays"]');
if (sv) sv.textContent = speedStr;
if (rv) rv.textContent = relays + ' active / ' + sessions + ' sessions';
if (tv) tv.textContent = totalRelayed;
if (bv) bv.textContent = bytesStr;
// Bar: speed capped at 10 MB/s for visual
if (sb) sb.style.width = Math.min(100, (speed / 10485760) * 100) + '%';
// Bar: relays capped at 50 for visual
if (rb) rb.style.width = Math.min(100, (relays / 50) * 100) + '%';
}).catch(function () {});
}
});
// ============ 20. Connection Stats Table ============
register('connection-stats', {
name: t('desktop.widget_connection_stats'),
icon: 'table_chart',
color: '#8b949e',
category: 'monitoring',
description: 'Tabular connection statistics',
defaultSize: { w: 380, h: 220 },
minSize: { w: 300, h: 160 },
updateInterval: 30000,
render: function (body) {
body.innerHTML = '<div class="widget-stats-table">' +
'<div class="widget-stats-header"><span>' + t('desktop.label_metric') + '</span><span>' + t('desktop.label_count') + '</span><span>%</span></div>' +
'<div class="widget-stats-body"><div class="widget-loading" style="height:40px"></div></div></div>';
},
update: function (body) {
apiGet('/api/stats').then(function (data) {
if (!data || !data.devices) return;
var d = data.devices;
var tot = d.total || 1;
var rows = [
{ label: t('desktop.label_total_devices'), count: d.total || 0, pct: '100%' },
{ label: t('desktop.label_online'), count: d.online || 0, pct: Math.round(((d.online || 0) / tot) * 100) + '%' },
{ label: t('desktop.label_offline'), count: d.offline || 0, pct: Math.round(((d.offline || 0) / tot) * 100) + '%' },
{ label: t('desktop.label_banned'), count: d.banned || 0, pct: Math.round(((d.banned || 0) / tot) * 100) + '%' },
{ label: t('desktop.label_with_notes'), count: d.withNotes || 0, pct: Math.round(((d.withNotes || 0) / tot) * 100) + '%' }
];
var c = body.querySelector('.widget-stats-body');
if (!c) return;
var html = '';
rows.forEach(function (r) {
html += '<div class="widget-stats-row">' +
'<span class="widget-stats-label">' + esc(r.label) + '</span>' +
'<span class="widget-stats-count">' + r.count + '</span>' +
'<span class="widget-stats-pct">' + r.pct + '</span></div>';
});
c.innerHTML = html;
}).catch(function () {});
}
});
// 18. Weather
register('weather', {
name: t('desktop.widget_weather'),
icon: 'cloud',
color: '#58a6ff',
category: 'general',
description: 'Weather conditions from wttr.in',
defaultSize: { w: 280, h: 200 },
minSize: { w: 220, h: 160 },
_interval: null,
render: function (body) {
body.innerHTML = '<div class="widget-weather-loading" style="text-align:center;padding:20px">' +
'<span class="material-icons" style="font-size:32px;color:#58a6ff">cloud_queue</span>' +
'<div style="margin-top:8px;color:var(--text-secondary)">' + esc(t('common.loading')) + '</div></div>';
this._fetchWeather(body);
var self = this;
this._interval = setInterval(function () { self._fetchWeather(body); }, 600000);
},
update: function (body) { this._fetchWeather(body); },
destroy: function () { if (this._interval) { clearInterval(this._interval); this._interval = null; } },
_fetchWeather: function (body) {
var loc = (this.config && this.config.location) || '';
var url = 'https://wttr.in/' + encodeURIComponent(loc) + '?format=j1';
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
var cur = data.current_condition && data.current_condition[0];
if (!cur) { body.innerHTML = '<div style="padding:16px;color:var(--text-secondary)">No data</div>'; return; }
var temp = cur.temp_C || '?';
var desc = (cur.weatherDesc && cur.weatherDesc[0] && cur.weatherDesc[0].value) || '';
var humidity = cur.humidity || '?';
var wind = cur.windspeedKmph || '?';
var icon = _weatherIcon(desc);
var area = (data.nearest_area && data.nearest_area[0] && data.nearest_area[0].areaName &&
data.nearest_area[0].areaName[0] && data.nearest_area[0].areaName[0].value) || loc || 'Local';
body.innerHTML = '<div style="padding:12px">' +
'<div style="display:flex;align-items:center;gap:12px">' +
'<span class="material-icons" style="font-size:40px;color:#58a6ff">' + icon + '</span>' +
'<div>' +
'<div style="font-size:28px;font-weight:700">' + esc(temp) + '°C</div>' +
'<div style="font-size:12px;color:var(--text-secondary)">' + esc(desc) + '</div>' +
'</div></div>' +
'<div style="margin-top:12px;font-size:12px;color:var(--text-secondary)">' + esc(area) + '</div>' +
'<div style="display:flex;gap:16px;margin-top:8px;font-size:12px">' +
'<span>💧 ' + esc(humidity) + '%</span>' +
'<span>🌬️ ' + esc(wind) + ' km/h</span></div></div>';
}).catch(function () {
body.innerHTML = '<div style="padding:16px;color:var(--text-secondary)">Weather unavailable</div>';
});
},
configFields: [
{ key: 'location', label: 'Location (city or coordinates)', type: 'text', placeholder: 'e.g. Warsaw or 52.23,21.01' }
]
});
function _weatherIcon(desc) {
var d = (desc || '').toLowerCase();
if (d.includes('sun') || d.includes('clear')) return 'wb_sunny';
if (d.includes('cloud') && d.includes('part')) return 'partly_cloudy_day';
if (d.includes('cloud') || d.includes('overcast')) return 'cloud';
if (d.includes('rain') || d.includes('drizzle')) return 'water_drop';
if (d.includes('snow') || d.includes('sleet')) return 'ac_unit';
if (d.includes('thunder') || d.includes('storm')) return 'thunderstorm';
if (d.includes('fog') || d.includes('mist')) return 'foggy';
return 'cloud_queue';
}
// 19. World Clock
register('world-clock', {
name: t('desktop.widget_world_clock'),
icon: 'public',
color: '#d2a8ff',
category: 'general',
description: 'Multiple time zones display',
defaultSize: { w: 260, h: 200 },
minSize: { w: 200, h: 140 },
_interval: null,
render: function (body) {
var self = this;
this._renderClocks(body);
this._interval = setInterval(function () { self._renderClocks(body); }, 1000);
},
update: function (body) { this._renderClocks(body); },
destroy: function () { if (this._interval) { clearInterval(this._interval); this._interval = null; } },
_renderClocks: function (body) {
var zones = (this.config && this.config.zones) || 'America/New_York,Europe/London,Europe/Warsaw,Asia/Tokyo';
var list = zones.split(',').map(function (z) { return z.trim(); }).filter(Boolean);
var html = '<div style="padding:8px">';
list.forEach(function (tz) {
var label = tz.split('/').pop().replace(/_/g, ' ');
var time;
try {
time = new Date().toLocaleTimeString('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) {
time = '??:??';
}
html += '<div style="display:flex;justify-content:space-between;padding:4px 8px;border-bottom:1px solid rgba(255,255,255,0.05)">' +
'<span style="font-size:13px;color:var(--text-secondary)">' + esc(label) + '</span>' +
'<span style="font-size:14px;font-weight:600;font-variant-numeric:tabular-nums">' + esc(time) + '</span></div>';
});
html += '</div>';
body.innerHTML = html;
},
configFields: [
{ key: 'zones', label: 'Time zones (comma-separated)', type: 'text', placeholder: 'America/New_York,Europe/Warsaw' }
]
});
// 20. Bookmarks / Link Launcher
register('bookmarks', {
name: t('desktop.widget_bookmarks'),
icon: 'bookmarks',
color: '#f0883e',
category: 'tools',
description: 'Quick-access URL shortcuts',
defaultSize: { w: 280, h: 200 },
minSize: { w: 200, h: 120 },
render: function (body) {
var links = (this.config && this.config.links) || 'Grafana|http://localhost:3000,Portainer|http://localhost:9000,Router|http://192.168.1.1';
var items = links.split(',').map(function (l) {
var parts = l.trim().split('|');
return { name: parts[0] || 'Link', url: parts[1] || '#' };
});
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px;padding:10px">';
items.forEach(function (item) {
html += '<a href="' + esc(item.url) + '" target="_blank" rel="noopener" ' +
'class="widget-bookmark-link" ' +
'style="display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 8px;' +
'border-radius:8px;background:rgba(255,255,255,0.03);text-decoration:none;color:inherit;' +
'transition:background .15s">' +
'<span class="material-icons" style="font-size:24px;color:#f0883e">link</span>' +
'<span style="font-size:11px;text-align:center;word-break:break-word">' + esc(item.name) + '</span></a>';
});
html += '</div>';
body.innerHTML = html;
body.querySelectorAll('.widget-bookmark-link').forEach(function (link) {
link.addEventListener('mouseenter', function () {
link.style.background = 'rgba(255,255,255,0.08)';
});
link.addEventListener('mouseleave', function () {
link.style.background = 'rgba(255,255,255,0.03)';
});
});
},
configFields: [
{ key: 'links', label: 'Links (Name|URL, comma-separated)', type: 'text', placeholder: 'Grafana|http://localhost:3000' }
]
});
// 21. User Sessions
register('user-sessions', {
name: t('desktop.widget_user_sessions'),
icon: 'group',
color: '#3fb950',
category: 'monitoring',
description: 'Currently logged-in operators',
defaultSize: { w: 300, h: 200 },
minSize: { w: 240, h: 140 },
_interval: null,
render: function (body) {
this._fetchSessions(body);
var self = this;
this._interval = setInterval(function () { self._fetchSessions(body); }, 15000);
},
update: function (body) { this._fetchSessions(body); },
destroy: function () { if (this._interval) { clearInterval(this._interval); this._interval = null; } },
_fetchSessions: function (body) {
apiGet('/api/users').then(function (res) {
var users = (res && res.data && res.data.users) || [];
if (!users.length) {
body.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-secondary)">No active users</div>';
return;
}
var html = '<div style="padding:8px">';
users.forEach(function (u) {
var initial = (u.username || '?')[0].toUpperCase();
var roleBadge = u.role === 'admin'
? '<span style="font-size:10px;padding:1px 6px;border-radius:4px;background:rgba(88,166,255,.15);color:#58a6ff">admin</span>'
: '<span style="font-size:10px;padding:1px 6px;border-radius:4px;background:rgba(63,185,80,.15);color:#3fb950">operator</span>';
html += '<div style="display:flex;align-items:center;gap:10px;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.05)">' +
'<div style="width:28px;height:28px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">' +
esc(initial) + '</div>' +
'<div style="flex:1"><div style="font-size:13px;font-weight:500">' + esc(u.username) + '</div></div>' +
roleBadge + '</div>';
});
html += '</div>';
body.innerHTML = html;
}).catch(function () {
body.innerHTML = '<div style="padding:16px;color:var(--text-secondary)">Could not load users</div>';
});
}
});
// 22. Alert Feed
register('alert-feed', {
name: t('desktop.widget_alert_feed'),
icon: 'notifications_active',
color: '#f85149',
category: 'monitoring',
description: 'Security alerts and events',
defaultSize: { w: 340, h: 260 },
minSize: { w: 260, h: 180 },
_interval: null,
render: function (body) {
this._fetchAlerts(body);
var self = this;
this._interval = setInterval(function () { self._fetchAlerts(body); }, 30000);
},
update: function (body) { this._fetchAlerts(body); },
destroy: function () { if (this._interval) { clearInterval(this._interval); this._interval = null; } },
_fetchAlerts: function (body) {
apiGet('/api/audit-log?limit=10').then(function (res) {
var logs = (res && res.data && res.data.logs) || [];
if (!logs.length) {
body.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-secondary)">' +
'<span class="material-icons" style="font-size:28px">check_circle</span><div style="margin-top:4px">No recent alerts</div></div>';
return;
}
var html = '<div style="padding:4px;max-height:220px;overflow-y:auto">';
logs.forEach(function (log) {
var color = (log.action || '').includes('fail') || (log.action || '').includes('ban') ? '#f85149' :
(log.action || '').includes('login') ? '#3fb950' : '#8b949e';
var icon = (log.action || '').includes('ban') ? 'block' :
(log.action || '').includes('fail') ? 'warning' :
(log.action || '').includes('login') ? 'login' : 'info';
html += '<div style="display:flex;gap:8px;padding:5px 8px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:12px">' +
'<span class="material-icons" style="font-size:16px;color:' + color + ';margin-top:1px">' + icon + '</span>' +
'<div style="flex:1;min-width:0">' +
'<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(log.action || '') + '</div>' +
'<div style="color:var(--text-tertiary);font-size:11px">' + esc(log.details || '') + '</div></div>' +
'<div style="white-space:nowrap;color:var(--text-tertiary);font-size:10px">' + timeSince(log.created_at) + '</div></div>';
});
html += '</div>';
body.innerHTML = html;
}).catch(function () {
body.innerHTML = '<div style="padding:16px;color:var(--text-secondary)">Could not load alerts</div>';
});
}
});
// ============ 23. Calendar / Agenda ============
register('calendar', {
name: t('desktop.widget_calendar'),
icon: 'event',
color: '#d2a8ff',
category: 'general',
description: 'Simple calendar with local event entries',
defaultSize: { w: 320, h: 340 },
minSize: { w: 260, h: 260 },
_STORAGE_KEY: 'bd_widget_calendar_events',
render: function (body, config) {
this._month = new Date().getMonth();
this._year = new Date().getFullYear();
this._renderCalendar(body);
},
_loadEvents: function () {
try { return JSON.parse(localStorage.getItem(this._STORAGE_KEY) || '[]'); }
catch (e) { return []; }
},
_saveEvents: function (evts) {
localStorage.setItem(this._STORAGE_KEY, JSON.stringify(evts));
},
_renderCalendar: function (body) {
var self = this;
var year = this._year;
var month = this._month;
var today = new Date();
var first = new Date(year, month, 1);
var last = new Date(year, month + 1, 0);
var startDay = first.getDay() === 0 ? 6 : first.getDay() - 1;
var days = last.getDate();
var events = this._loadEvents();
var monthName = first.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
var html = '<div class="widget-cal">' +
'<div class="widget-cal-header">' +
'<button class="widget-cal-nav" data-dir="-1"><span class="material-icons">chevron_left</span></button>' +
'<span class="widget-cal-title">' + esc(monthName) + '</span>' +
'<button class="widget-cal-nav" data-dir="1"><span class="material-icons">chevron_right</span></button></div>' +
'<div class="widget-cal-grid">';
var dayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
dayNames.forEach(function (d) { html += '<div class="widget-cal-day-header">' + d + '</div>'; });
for (var i = 0; i < startDay; i++) html += '<div class="widget-cal-day empty"></div>';
for (var d = 1; d <= days; d++) {
var dateStr = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
var isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
var hasEvent = events.some(function (ev) { return ev.date === dateStr; });
html += '<div class="widget-cal-day' + (isToday ? ' today' : '') + (hasEvent ? ' has-event' : '') + '" data-date="' + dateStr + '">' + d + '</div>';
}
html += '</div>';
// Events list for today
var todayStr = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
var todayEvts = events.filter(function (ev) { return ev.date === todayStr; });
html += '<div class="widget-cal-events">';
if (todayEvts.length) {
todayEvts.forEach(function (ev) {
html += '<div class="widget-cal-event"><span class="material-icons" style="font-size:14px;color:#d2a8ff">event_note</span>' + esc(ev.title) + '</div>';
});
} else {
html += '<div class="widget-cal-no-events">' + t('desktop.label_no_events') + '</div>';
}
html += '</div></div>';
body.innerHTML = html;
// Navigation
body.querySelectorAll('.widget-cal-nav').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var dir = parseInt(btn.dataset.dir, 10);
self._month += dir;
if (self._month > 11) { self._month = 0; self._year++; }
if (self._month < 0) { self._month = 11; self._year--; }
self._renderCalendar(body);
});
});
// Click day to add event
body.querySelectorAll('.widget-cal-day:not(.empty)').forEach(function (cell) {
cell.addEventListener('dblclick', function (e) {
e.stopPropagation();
var date = cell.dataset.date;
var title = prompt(t('desktop.label_event_title') || 'Event title:');
if (!title) return;
var evts = self._loadEvents();
evts.push({ date: date, title: title });
self._saveEvents(evts);
self._renderCalendar(body);
});
});
},
update: function (body) { this._renderCalendar(body); }
});
// ============ 24. System Process Monitor ============
register('process-monitor', {
name: t('desktop.widget_process_monitor'),
icon: 'memory',
color: '#f85149',
category: 'monitoring',
description: 'Top CPU/memory-consuming processes',
defaultSize: { w: 380, h: 300 },
minSize: { w: 300, h: 200 },
updateInterval: 10000,
render: function (body) {
body.innerHTML = '<div class="widget-proc"><div class="widget-proc-header">' +
'<span style="flex:2">Process</span><span style="flex:1;text-align:right">CPU</span><span style="flex:1;text-align:right">Mem</span></div>' +
'<div class="widget-proc-list"><div class="widget-loading" style="height:40px"></div></div></div>';
body.style.overflow = 'auto';
},
update: function (body) {
apiGet('/api/system/info').then(function (data) {
if (!data || !data.processes) return;
var list = body.querySelector('.widget-proc-list');
if (!list) return;
var procs = data.processes.slice(0, 15);
if (!procs.length) {
list.innerHTML = '<div class="widget-empty"><span class="material-icons">memory</span><span>' + t('desktop.label_no_data') + '</span></div>';
return;
}
var html = '';
procs.forEach(function (p) {
var cpuCls = p.cpu > 80 ? 'danger' : (p.cpu > 50 ? 'warning' : '');
html += '<div class="widget-proc-row">' +
'<span class="widget-proc-name" title="' + esc(p.name) + '">' + esc(p.name) + '</span>' +
'<span class="widget-proc-cpu ' + cpuCls + '">' + (p.cpu != null ? p.cpu.toFixed(1) + '%' : '-') + '</span>' +
'<span class="widget-proc-mem">' + (p.mem != null ? p.mem.toFixed(1) + ' MB' : '-') + '</span></div>';
});
list.innerHTML = html;
}).catch(function () {
var list = body.querySelector('.widget-proc-list');
if (list) list.innerHTML = '<div class="widget-empty"><span class="material-icons">error</span><span>' + t('desktop.label_unavailable') + '</span></div>';
});
}
});
// ============ 25. Disk Usage Breakdown ============
register('disk-usage', {
name: t('desktop.widget_disk_usage'),
icon: 'storage',
color: '#f0883e',
category: 'monitoring',
description: 'Disk usage per partition',
defaultSize: { w: 320, h: 240 },
minSize: { w: 260, h: 180 },
updateInterval: 60000,
render: function (body) {
body.innerHTML = '<div class="widget-disk-list"><div class="widget-loading" style="height:40px"></div></div>';
},
update: function (body) {
apiGet('/api/system/info').then(function (data) {
if (!data || !data.disks) return;
var list = body.querySelector('.widget-disk-list');
if (!list) return;
if (!data.disks.length) {
list.innerHTML = '<div class="widget-empty"><span class="material-icons">storage</span><span>' + t('desktop.label_no_data') + '</span></div>';
return;
}
var html = '';
data.disks.forEach(function (d) {
var pct = d.total > 0 ? Math.round((d.used / d.total) * 100) : 0;
var cls = pct > 90 ? 'danger' : (pct > 75 ? 'warning' : '');
var totalGB = (d.total / 1073741824).toFixed(1);
var usedGB = (d.used / 1073741824).toFixed(1);
html += '<div class="widget-disk-item">' +
'<div class="widget-disk-info"><span class="widget-disk-mount">' + esc(d.mount || d.name) + '</span>' +
'<span class="widget-disk-size">' + usedGB + ' / ' + totalGB + ' GB</span></div>' +
'<div class="widget-disk-bar"><div class="widget-disk-fill ' + cls + '" style="width:' + pct + '%"></div></div>' +
'<div class="widget-disk-pct">' + pct + '%</div></div>';
});
list.innerHTML = html;
}).catch(function () {
var list = body.querySelector('.widget-disk-list');
if (list) list.innerHTML = '<div class="widget-empty"><span class="material-icons">error</span><span>' + t('desktop.label_unavailable') + '</span></div>';
});
}
});
// ============ 26. Log Viewer ============
register('log-viewer', {
name: t('desktop.widget_log_viewer'),
icon: 'article',
color: '#8b949e',
category: 'monitoring',
description: 'Stream recent server log lines',
defaultSize: { w: 440, h: 300 },
minSize: { w: 320, h: 200 },
updateInterval: 10000,
render: function (body) {
body.innerHTML = '<div class="widget-log">' +
'<div class="widget-log-toolbar">' +
'<select class="widget-log-source">' +
'<option value="console">Console</option>' +
'<option value="go">Go Server</option>' +
'</select>' +
'<button class="widget-log-scroll-btn" title="Auto-scroll"><span class="material-icons">vertical_align_bottom</span></button></div>' +
'<div class="widget-log-lines"></div></div>';
body.style.overflow = 'hidden';
var scrollBtn = body.querySelector('.widget-log-scroll-btn');
body._autoScroll = true;
if (scrollBtn) {
scrollBtn.classList.add('active');
scrollBtn.addEventListener('click', function (e) {
e.stopPropagation();
body._autoScroll = !body._autoScroll;
scrollBtn.classList.toggle('active', body._autoScroll);
});
}
},
update: function (body) {
var source = (body.querySelector('.widget-log-source') || {}).value || 'console';
apiGet('/api/logs/recent?source=' + source + '&limit=50').then(function (data) {
if (!data || !data.lines) return;
var container = body.querySelector('.widget-log-lines');
if (!container) return;
var html = '';
data.lines.forEach(function (line) {
var cls = '';
var lower = (line || '').toLowerCase();
if (lower.includes('error') || lower.includes('fatal')) cls = 'error';
else if (lower.includes('warn')) cls = 'warning';
else if (lower.includes('info')) cls = 'info';
html += '<div class="widget-log-line ' + cls + '">' + esc(line) + '</div>';
});
container.innerHTML = html;
if (body._autoScroll) container.scrollTop = container.scrollHeight;
}).catch(function () {
var container = body.querySelector('.widget-log-lines');
if (container) container.innerHTML = '<div class="widget-log-line error">Failed to load logs</div>';
});
}
});
// ============ 27. Speed Test ============
register('speed-test', {
name: t('desktop.widget_speed_test'),
icon: 'network_check',
color: '#3fb950',
category: 'tools',
description: 'Test download speed from server',
defaultSize: { w: 280, h: 220 },
minSize: { w: 220, h: 170 },
_testing: false,
render: function (body) {
var self = this;
body.innerHTML = '<div class="widget-speedtest">' +
'<div class="widget-speedtest-gauge">' + buildGaugeSVG(0) +
'<div class="widget-speedtest-value">-</div></div>' +
'<div class="widget-speedtest-label">' + t('desktop.label_download_speed') + '</div>' +
'<div class="widget-speedtest-stats">' +
'<div><span class="widget-speedtest-stat-label">' + t('desktop.label_latency') + '</span><span class="widget-speedtest-stat-val" data-st="latency">-</span></div>' +
'<div><span class="widget-speedtest-stat-label">' + t('desktop.label_server') + '</span><span class="widget-speedtest-stat-val" data-st="server">Local</span></div></div>' +
'<button class="widget-speedtest-btn">' + t('desktop.label_run_test') + '</button></div>';
body.querySelector('.widget-speedtest-btn').addEventListener('click', function (e) {
e.stopPropagation();
if (self._testing) return;
self._runTest(body);
});
},
_runTest: function (body) {
var self = this;
self._testing = true;
var btn = body.querySelector('.widget-speedtest-btn');
if (btn) btn.textContent = t('desktop.label_testing') || 'Testing...';
// Measure latency
var latencyStart = performance.now();
fetch('/api/stats', { cache: 'no-store' }).then(function () {
var latency = Math.round(performance.now() - latencyStart);
var latEl = body.querySelector('[data-st="latency"]');
if (latEl) latEl.textContent = latency + 'ms';
// Download speed test — download 1MB payload
var dlStart = performance.now();
var testUrl = '/api/speed-test?size=1048576&_=' + Date.now();
return fetch(testUrl, { cache: 'no-store' }).then(function (r) { return r.blob(); }).then(function (blob) {
var dlTime = (performance.now() - dlStart) / 1000;
var bytes = blob.size || 1048576;
var speed = bytes / dlTime; // bytes/sec
var mbps = (speed * 8) / 1000000;
var pct = Math.min(100, (mbps / 100) * 100);
// Update gauge
var ring = body.querySelector('.widget-speedtest-gauge');
if (ring) {
var svg = ring.querySelector('svg');
var tmp = document.createElement('div');
tmp.innerHTML = buildGaugeSVG(pct);
if (svg && tmp.firstChild) ring.replaceChild(tmp.firstChild, svg);
}
var val = body.querySelector('.widget-speedtest-value');
if (val) val.textContent = mbps.toFixed(1) + ' Mbps';
self._testing = false;
if (btn) btn.textContent = t('desktop.label_run_test') || 'Run Test';
});
}).catch(function () {
self._testing = false;
if (btn) btn.textContent = t('desktop.label_run_test') || 'Run Test';
});
}
});
// ============ 28. Database Stats ============
register('database-stats', {
name: t('desktop.widget_database_stats'),
icon: 'table_view',
color: '#79c0ff',
category: 'monitoring',
description: 'Database size, tables, and row counts',
defaultSize: { w: 320, h: 260 },
minSize: { w: 260, h: 180 },
updateInterval: 60000,
render: function (body) {
body.innerHTML = '<div class="widget-dbstats"><div class="widget-loading" style="height:40px"></div></div>';
},
update: function (body) {
apiGet('/api/database/stats').then(function (data) {
if (!data) return;
var c = body.querySelector('.widget-dbstats');
if (!c) return;
var html = '<div class="widget-dbstats-header">' +
'<span class="material-icons" style="color:#79c0ff;font-size:20px">table_view</span>' +
'<span>' + esc(data.type || 'SQLite') + '</span>' +
'<span class="widget-dbstats-size">' + esc(data.size || '-') + '</span></div>' +
'<div class="widget-dbstats-table">' +
'<div class="widget-dbstats-row header"><span>' + t('desktop.label_table') + '</span><span>' + t('desktop.label_rows') + '</span></div>';
if (data.tables && Array.isArray(data.tables)) {
data.tables.forEach(function (tbl) {
html += '<div class="widget-dbstats-row"><span>' + esc(tbl.name) + '</span><span>' + (tbl.count || 0) + '</span></div>';
});
}
html += '</div>';
if (data.last_backup) {
html += '<div class="widget-dbstats-footer">' + t('desktop.label_last_backup') + ': ' + esc(timeSince(data.last_backup)) + ' ago</div>';
}
c.innerHTML = html;
}).catch(function () {
var c = body.querySelector('.widget-dbstats');
if (c) c.innerHTML = '<div class="widget-empty"><span class="material-icons">error</span><span>' + t('desktop.label_unavailable') + '</span></div>';
});
}
});
// ============ 29. Docker Containers ============
register('docker-containers', {
name: t('desktop.widget_docker_containers'),
icon: 'view_in_ar',
color: '#2496ed',
category: 'monitoring',
description: 'Docker container status overview',
defaultSize: { w: 380, h: 280 },
minSize: { w: 300, h: 200 },
updateInterval: 15000,
render: function (body) {
body.innerHTML = '<div class="widget-docker"><div class="widget-loading" style="height:40px"></div></div>';
body.style.overflow = 'auto';
},
update: function (body) {
apiGet('/api/docker/containers').then(function (data) {
if (!data || !data.containers) return;
var c = body.querySelector('.widget-docker');
if (!c) return;
if (!data.containers.length) {
c.innerHTML = '<div class="widget-empty"><span class="material-icons">view_in_ar</span><span>' + t('desktop.label_no_containers') + '</span></div>';
return;
}
var html = '';
data.containers.forEach(function (ct) {
var statusCls = ct.state === 'running' ? 'running' : (ct.state === 'exited' ? 'stopped' : 'other');
var icon = ct.state === 'running' ? 'play_circle' : (ct.state === 'exited' ? 'stop_circle' : 'pending');
html += '<div class="widget-docker-row ' + statusCls + '">' +
'<span class="material-icons widget-docker-status">' + icon + '</span>' +
'<div class="widget-docker-info">' +
'<div class="widget-docker-name" title="' + esc(ct.name) + '">' + esc(ct.name) + '</div>' +
'<div class="widget-docker-meta">' + esc(ct.image || '') +
(ct.ports ? ' · ' + esc(ct.ports) : '') + '</div></div>' +
'<div class="widget-docker-uptime">' + esc(ct.status || ct.state) + '</div></div>';
});
c.innerHTML = html;
}).catch(function () {
var c = body.querySelector('.widget-docker');
if (c) c.innerHTML = '<div class="widget-empty"><span class="material-icons">cloud_off</span><span>' + t('desktop.label_docker_unavailable') + '</span></div>';
});
}
});
// ============ 30. Custom Shell Command ============
register('shell-command', {
name: t('desktop.widget_shell_command'),
icon: 'terminal',
color: '#d29922',
category: 'tools',
description: 'Execute a predefined command and display output',
defaultSize: { w: 400, h: 260 },
minSize: { w: 300, h: 180 },
render: function (body, config) {
var cmd = config.command || '';
var interval = parseInt(config.refreshInterval, 10) || 30;
body.innerHTML = '<div class="widget-shell">' +
'<div class="widget-shell-header">' +
'<span class="material-icons" style="font-size:16px;color:#d29922">terminal</span>' +
'<span class="widget-shell-cmd" title="' + esc(cmd) + '">' + (cmd ? '$ ' + esc(cmd) : t('desktop.label_no_command')) + '</span>' +
'<button class="widget-shell-run" title="' + t('desktop.label_run') + '"><span class="material-icons">play_arrow</span></button></div>' +
'<pre class="widget-shell-output">' + t('desktop.label_no_output') + '</pre></div>';
var self = this;
body.querySelector('.widget-shell-run').addEventListener('click', function (e) {
e.stopPropagation();
self._exec(body, config);
});
if (cmd) {
this._exec(body, config);
body._shellTimer = setInterval(function () { self._exec(body, config); }, interval * 1000);
}
},
_exec: function (body, config) {
var cmd = config.command || '';
if (!cmd) return;
var output = body.querySelector('.widget-shell-output');
if (output) output.textContent = 'Running...';
fetch('/api/system/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (window.BetterDesk || {}).csrfToken || '' },
credentials: 'same-origin',
body: JSON.stringify({ command: cmd })
}).then(function (r) { return r.json(); }).then(function (data) {
if (output) output.textContent = data.output || data.error || 'No output';
}).catch(function (err) {
if (output) output.textContent = 'Error: ' + (err.message || 'Failed');
});
},
destroy: function (body) {
if (body && body._shellTimer) { clearInterval(body._shellTimer); body._shellTimer = null; }
},
configFields: [
{ key: 'command', label: 'Command (admin only)', type: 'text', placeholder: 'uptime' },
{ key: 'refreshInterval', label: 'Refresh interval (seconds)', type: 'text', placeholder: '30' }
]
});
// ============ 31. Device Map ============
register('device-map', {
name: t('desktop.widget_device_map'),
icon: 'map',
color: '#3fb950',
category: 'devices',
description: 'Geographic map view of devices by IP',
defaultSize: { w: 460, h: 340 },
minSize: { w: 340, h: 240 },
updateInterval: 120000,
render: function (body) {
body.innerHTML = '<div class="widget-map">' +
'<div class="widget-map-canvas" style="position:relative;width:100%;height:100%;overflow:hidden">' +
'<img class="widget-map-img" src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Equirectangular-projection.jpg/1280px-Equirectangular-projection.jpg" ' +
'alt="World Map" style="width:100%;height:100%;object-fit:cover;opacity:0.3;filter:grayscale(1) brightness(0.4)" crossorigin="anonymous">' +
'<div class="widget-map-pins"></div>' +
'<div class="widget-map-legend">' +
'<span class="widget-map-legend-item online"><span class="widget-map-legend-dot online"></span>' + t('desktop.label_online') + '</span>' +
'<span class="widget-map-legend-item offline"><span class="widget-map-legend-dot offline"></span>' + t('desktop.label_offline') + '</span></div></div></div>';
},
update: function (body) {
apiGet('/api/devices').then(function (data) {
var peers = (data && data.devices ? data.devices : (Array.isArray(data) ? data : []));
var pinsContainer = body.querySelector('.widget-map-pins');
if (!pinsContainer) return;
if (!peers.length) { pinsContainer.innerHTML = ''; return; }
// Group devices by IP and show approximate positions
var ipMap = {};
peers.forEach(function (p) {
var ip = p.ip || p.socket_addr || '';
if (ip.includes(':')) ip = ip.split(':')[0]; // Remove port
if (!ip || ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
ip = 'local';
}
if (!ipMap[ip]) ipMap[ip] = { ip: ip, devices: [], online: 0 };
ipMap[ip].devices.push(p);
if (p.live_online || p.online) ipMap[ip].online++;
});
// Simple hash-based positioning (deterministic for same IP)
var html = '';
var idx = 0;
Object.keys(ipMap).forEach(function (ip) {
var group = ipMap[ip];
var hash = 0;
for (var i = 0; i < ip.length; i++) hash = ((hash << 5) - hash) + ip.charCodeAt(i);
var x = 10 + Math.abs(hash % 80);
var y = 10 + Math.abs((hash * 31) % 70);
var online = group.online > 0;
var count = group.devices.length;
html += '<div class="widget-map-pin ' + (online ? 'online' : 'offline') + '" style="left:' + x + '%;top:' + y + '%" ' +
'title="' + esc(ip) + ' — ' + count + ' device(s)">' +
(count > 1 ? '<span class="widget-map-pin-count">' + count + '</span>' : '') + '</div>';
idx++;
});
pinsContainer.innerHTML = html;
}).catch(function () {});
}
});
// ============ 32. Remote Connect ============
register('remote-connect', {
name: t('desktop.widget_remote_connect') || 'Remote Connect',
icon: 'connected_tv',
color: '#58a6ff',
category: 'tools',
description: 'Quick-connect to online devices',
defaultSize: { w: 340, h: 300 },
minSize: { w: 260, h: 200 },
render: function (body) {
body.innerHTML =
'<div style="display:flex;flex-direction:column;height:100%;gap:6px;padding:2px 0">' +
'<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">' +
'<input class="rc-search" type="text" placeholder="' + esc(t('desktop.label_filter_devices') || 'Filter…') + '" ' +
'style="flex:1;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:5px 8px;color:inherit;font-size:12px;outline:none">' +
'<span class="rc-count" style="font-size:11px;opacity:0.5;white-space:nowrap"></span></div>' +
'<div class="rc-list" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:4px;scrollbar-width:thin"></div>' +
'<div class="rc-empty" style="display:none;text-align:center;padding:20px 8px;opacity:0.4;font-size:12px">' +
'<span class="material-icons" style="font-size:32px;display:block;margin-bottom:6px">desktop_access_disabled</span>' +
(t('desktop.label_no_online') || 'No online devices') + '</div></div>';
},
update: function (body) {
apiGet('/api/devices').then(function (data) {
var peers = data && data.devices ? data.devices : (Array.isArray(data) ? data : []);
var online = peers.filter(function (p) { return p.live_online || p.online; });
var list = body.querySelector('.rc-list');
var empty = body.querySelector('.rc-empty');
var countEl = body.querySelector('.rc-count');
var search = body.querySelector('.rc-search');
if (countEl) countEl.textContent = online.length + '/' + peers.length;
if (!online.length) {
if (list) list.innerHTML = '';
if (empty) empty.style.display = 'block';
return;
}
if (empty) empty.style.display = 'none';
var filter = (search && search.value || '').toLowerCase();
var filtered = filter ? online.filter(function (p) {
return (p.id || '').toLowerCase().includes(filter) ||
(p.hostname || '').toLowerCase().includes(filter) ||
(p.platform || '').toLowerCase().includes(filter);
}) : online;
if (!list) return;
list.innerHTML = filtered.map(function (p) {
var name = esc(p.hostname || p.id);
var platform = esc(p.platform || '');
var platformIcon = platform.toLowerCase().includes('windows') ? 'desktop_windows' :
platform.toLowerCase().includes('linux') ? 'terminal' :
platform.toLowerCase().includes('mac') ? 'laptop_mac' : 'devices';
return '<div class="rc-device" data-id="' + esc(p.id) + '" style="display:flex;align-items:center;gap:8px;padding:6px 8px;' +
'border-radius:6px;cursor:pointer;background:rgba(255,255,255,0.04);transition:background 0.15s" ' +
'onmouseover="this.style.background=\'rgba(88,166,255,0.12)\'" onmouseout="this.style.background=\'rgba(255,255,255,0.04)\'">' +
'<span class="material-icons" style="font-size:18px;opacity:0.6">' + platformIcon + '</span>' +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + name + '</div>' +
'<div style="font-size:10px;opacity:0.45;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(p.id) + (platform ? ' · ' + platform : '') + '</div></div>' +
'<span class="rc-dot" style="width:6px;height:6px;border-radius:50%;background:#3fb950;flex-shrink:0"></span>' +
'<button class="rc-connect-btn" data-id="' + esc(p.id) + '" style="background:rgba(88,166,255,0.15);border:none;' +
'border-radius:4px;color:#58a6ff;padding:3px 8px;font-size:11px;cursor:pointer;white-space:nowrap" ' +
'onmouseover="this.style.background=\'rgba(88,166,255,0.3)\'" onmouseout="this.style.background=\'rgba(88,166,255,0.15)\'">' +
(t('desktop.label_connect') || 'Connect') + '</button></div>';
}).join('');
// Attach search filter
if (search && !search._rcBound) {
search._rcBound = true;
search.addEventListener('input', function () {
if (body._rcTimer) clearTimeout(body._rcTimer);
body._rcTimer = setTimeout(function () {
var plugin = get('remote-connect');
if (plugin && plugin.update) plugin.update(body);
}, 200);
});
}
// Attach click handlers
list.querySelectorAll('.rc-device').forEach(function (el) {
el.addEventListener('click', function (e) {
var id = el.getAttribute('data-id');
if (!id) return;
e.stopPropagation();
// In desktop mode, open as a window; otherwise navigate
if (window.DesktopMode && window.DesktopMode.isActive && window.DesktopMode.isActive()) {
window.DesktopMode.openAppByRoute('/remote/' + id);
} else {
window.location.href = '/remote/' + id;
}
});
});
}).catch(function () {
var empty = body.querySelector('.rc-empty');
if (empty) empty.style.display = 'block';
});
}
});
// ============ Expose ============
window.WidgetPlugins = {
register: register,
get: get,
list: list
};
})();