// Tailscale device list panel for status.sami
//
// Polls /api/v1/tailscale/status (richer payload: online + offline peers with
// lastSeen, OS, user) every 30s and renders a device card. Clicking the card
// opens a panel that lists every device known to the tailnet โ online ones
// first, with "last seen Xm ago" for offline ones.
//
// Self-contained so it doesn't need to be in dist/. Just needs secureFetch,
// escapeHtml, and timeAgo (all from dist/core.js, which loads first).
(function () {
'use strict';
const REFRESH_MS = 30 * 1000; // status doesn't change fast โ 30s is plenty
const STATUS_URL = '/api/v1/tailscale/status';
const DEVICES_URL = '/api/v1/tailscale/devices';
const OS_ICON = {
windows: '๐ช',
linux: '๐ง',
darwin: '๐',
android: '๐ค',
ios: '๐ฑ',
macos: '๐',
synology:'๐พ',
other: '๐ฅ๏ธ',
};
function osKey(raw) {
if (!raw) return 'other';
const s = String(raw).toLowerCase();
if (s.includes('windows')) return 'windows';
if (s.includes('darwin') || s.includes('macos') || s.includes('mac')) return 'darwin';
if (s.includes('android')) return 'android';
if (s.includes('ios')) return 'ios';
if (s.includes('linux')) return 'linux';
if (s.includes('synology') || s.includes('dsm')) return 'synology';
return 'other';
}
// Module-local cache so the panel render doesn't refetch when the user toggles
// between card and panel view.
let cache = null; // last successful /status payload
let lastFetch = 0; // ms timestamp of last successful fetch
let pollTimer = null;
let inFlight = null; // promise of in-flight fetch (deduplicate overlapping calls)
function $ (id) { return document.getElementById(id); }
function setCard (onlineCount, totalCount, connected, installed) {
const card = $('tailscale-card');
const dot = $('tailscale-dot');
const pill = $('tailscale-pill');
const summary = $('tailscale-summary');
if (!card || !dot || !pill || !summary) return;
if (!installed) {
card.setAttribute('data-status', 'off');
dot.className = 'dot bad at-bl';
pill.className = 'badge off';
pill.textContent = 'N/A';
summary.textContent = 'Tailscale not installed';
return;
}
if (!connected) {
card.setAttribute('data-status', 'off');
dot.className = 'dot bad at-bl';
pill.className = 'badge off';
pill.textContent = 'OFF';
summary.textContent = 'Daemon offline';
return;
}
card.setAttribute('data-status', 'on');
dot.className = 'dot ok at-bl';
pill.className = 'badge on';
pill.textContent = onlineCount + '/' + totalCount;
summary.textContent = onlineCount === 1
? '1 device online'
: onlineCount + ' devices online';
}
function deviceRow (dev) {
const online = !!dev.online;
const isSelf = !!dev.isSelf;
const osIcon = OS_ICON[osKey(dev.os)] || OS_ICON.other;
const host = escapeHtml(dev.hostname || dev.id || '(unknown)');
const ip = dev.ip ? escapeHtml(dev.ip) : '';
const user = dev.user ? escapeHtml(String(dev.user).split('@')[0]) : '';
const lastSeen = (!online && dev.lastSeen)
? 'last seen ' + escapeHtml(timeAgo(dev.lastSeen)) + ''
: '';
const selfBadge = isSelf
? 'THIS DEVICE'
: '';
return `
${osIcon}
${host}${selfBadge}
${online ? 'ONLINE' : 'OFFLINE'}
${user ? 'ยท' + user + '' : ''}
${ip ? 'ยท' + ip + '' : ''}
${lastSeen}
`;
}
function renderPanel (status) {
const list = $('tailscale-device-list');
const count = $('tailscale-panel-count');
const tailnet = $('tailscale-tailnet');
if (!list) return;
if (!status || !status.installed) {
list.innerHTML = 'Tailscale is not installed on this host.
';
if (count) count.textContent = '';
if (tailnet) tailnet.textContent = '';
return;
}
if (!status.connected) {
list.innerHTML = 'Tailscale daemon is not running. Start it with systemctl start tailscaled.
';
if (count) count.textContent = '';
if (tailnet) tailnet.textContent = '';
return;
}
// Build merged device list: self first, then online peers, then offline peers
const devices = [];
if (status.self && status.self.ip) {
devices.push({
id: 'self', hostname: status.self.hostname, ip: status.self.ip,
os: status.self.os, online: !!status.self.online, isSelf: true,
lastSeen: status.self.online ? null : null,
});
}
const onlinePeers = [];
const offlinePeers = [];
for (const dev of (status.devices || [])) {
if (dev.isSelf) continue; // already added above
const entry = {
id: dev.id, hostname: dev.hostname, ip: dev.ip, os: dev.os,
online: !!dev.online, user: dev.user,
lastSeen: dev.online ? null : dev.lastSeen,
};
if (entry.online) onlinePeers.push(entry); else offlinePeers.push(entry);
}
// Sort: hostname alpha within each bucket
onlinePeers.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
offlinePeers.sort((a, b) => {
// Most-recently-seen first for offline
const ta = a.lastSeen ? new Date(a.lastSeen).getTime() : 0;
const tb = b.lastSeen ? new Date(b.lastSeen).getTime() : 0;
return tb - ta;
});
const sorted = devices.concat(onlinePeers, offlinePeers);
if (sorted.length === 0) {
list.innerHTML = 'No devices found in this tailnet.
';
} else {
list.innerHTML = sorted.map(deviceRow).join('');
}
const onlineCount = sorted.filter(d => d.online).length;
if (count) {
count.textContent = sorted.length === 1
? '1 device total'
: sorted.length + ' devices total ยท ' + onlineCount + ' online';
}
if (tailnet) {
const tn = status.self && status.self.tailnetName;
tailnet.textContent = tn ? '(' + tn + ')' : '';
}
}
async function fetchStatus (force) {
// Dedupe overlapping calls + skip if we have a fresh cache
const now = Date.now();
if (!force && cache && (now - lastFetch) < REFRESH_MS) return cache;
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const res = await secureFetch(STATUS_URL, { cache: 'no-store' });
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json();
cache = data;
lastFetch = Date.now();
return data;
} catch (e) {
// If /status fails, the container might be on an older build without it.
// Fall back to /devices (smaller payload, online only).
try {
const res2 = await secureFetch(DEVICES_URL, { cache: 'no-store' });
if (!res2.ok) throw new Error('devices ' + res2.status);
const d = await res2.json();
cache = {
installed: true,
connected: true,
self: d.devices && d.devices.find(x => x.isSelf),
devices: (d.devices || []).filter(x => !x.isSelf),
};
lastFetch = Date.now();
return cache;
} catch (e2) {
// Surface a graceful "unknown" state rather than throwing
if (typeof errorHandler !== 'undefined') {
errorHandler.logError('[Tailscale] Fetch', e2, { function: 'fetchStatus' });
}
return null;
}
} finally {
inFlight = null;
}
})();
return inFlight;
}
async function refresh () {
const status = await fetchStatus(false);
if (!status) {
setCard(0, 0, false, false);
return;
}
const installed = !!status.installed;
const connected = !!status.connected;
const total = (status.devices ? status.devices.length : 0) + (status.self ? 1 : 0);
const online = (status.devices ? status.devices.filter(d => d.online).length : 0)
+ (status.self && status.self.online ? 1 : 0);
setCard(online, total, connected, installed);
// Only re-render the panel if it's currently open (saves DOM thrash)
const panel = $('tailscale-panel');
if (panel && panel.style.display !== 'none') {
renderPanel(status);
}
}
function togglePanel () {
const panel = $('tailscale-panel');
if (!panel) return;
const isOpen = panel.style.display !== 'none';
if (isOpen) {
panel.style.display = 'none';
} else {
panel.style.display = '';
// Force a refresh when opening so the user always sees fresh data
fetchStatus(true).then(renderPanel);
}
}
function init () {
// Card click โ toggle panel (skip if click was on the refresh button)
const card = $('tailscale-card');
if (card) {
card.addEventListener('click', (e) => {
if (e.target && e.target.id === 'tailscale-refresh-btn') return;
togglePanel();
});
}
// Cursor hint
if (card) card.style.cursor = 'pointer';
// Refresh button โ force fetch
const btn = $('tailscale-refresh-btn');
if (btn) {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = 'โฆ';
try {
const status = await fetchStatus(true);
if (status) {
renderPanel(status);
// Update card too
const total = (status.devices ? status.devices.length : 0) + (status.self ? 1 : 0);
const online = (status.devices ? status.devices.filter(d => d.online).length : 0)
+ (status.self && status.self.online ? 1 : 0);
setCard(online, total, status.connected, status.installed);
}
} finally {
btn.innerHTML = orig;
btn.disabled = false;
}
});
}
// Close button on panel
const closeBtn = $('tailscale-panel-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
const panel = $('tailscale-panel');
if (panel) panel.style.display = 'none';
});
}
// Initial fetch + periodic poll
refresh();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(refresh, REFRESH_MS);
}
// Re-init if the card is later rendered (e.g. after TOTP auth completes)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// Defer slightly to let dist/core.js's secureFetch be defined
setTimeout(init, 0);
}
})();