// 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); } })();