/* global marked */ import { KIND_APP_DATA } from '../src/constants.mjs'; import { createElement } from './domUtils.js'; // ----------------------------------------------------------------------- // Modal Logic // ----------------------------------------------------------------------- const docsModal = document.getElementById("docsModal"); const settingsModal = document.getElementById("settingsModal"); const openDocsBtn = document.getElementById("openDocs"); const openSettingsBtn = document.getElementById("settingsBtn"); const closeDocsBtn = document.getElementsByClassName("close-modal")[0]; const closeSettingsBtn = document.getElementById("closeSettings"); const contentDiv = document.getElementById("markdown-content"); let docsLoaded = false; // Docs Modal if (openDocsBtn) { openDocsBtn.onclick = function(e) { e.preventDefault(); docsModal.classList.add("show"); if (!docsLoaded) { fetchDocs(); } } } if (closeDocsBtn) { closeDocsBtn.onclick = function() { docsModal.classList.remove("show"); } } // Settings Modal if (openSettingsBtn) { openSettingsBtn.onclick = function(e) { e.preventDefault(); // Pre-fill values document.getElementById('settingHashtag').value = HASHTAG; document.getElementById('settingNamespace').value = DASHBOARD_CONFIG.namespace; document.getElementById('settingRelays').value = RELAYS.join(', '); settingsModal.classList.add("show"); } } if (closeSettingsBtn) { closeSettingsBtn.onclick = function() { settingsModal.classList.remove("show"); } } document.getElementById('cancelSettings').onclick = function() { settingsModal.classList.remove("show"); } // Handle Settings Save document.getElementById('settingsForm').onsubmit = function(e) { e.preventDefault(); const newHashtag = document.getElementById('settingHashtag').value.trim(); const newNamespace = document.getElementById('settingNamespace').value.trim(); const newRelays = document.getElementById('settingRelays').value.split(',').map(r => r.trim()).filter(Boolean); if (!newHashtag) { alert('Hashtag is required.'); return; } const prefs = { hashtag: newHashtag, namespace: newNamespace, relays: newRelays }; // Save to LocalStorage localStorage.setItem('torch_dashboard_prefs', JSON.stringify(prefs)); // Update URL to reflect new settings (so link is shareable) const url = new URL(window.location); url.searchParams.set('hashtag', newHashtag); url.searchParams.set('namespace', newNamespace); url.searchParams.set('relays', newRelays.join(',')); window.history.pushState({}, '', url); // Reload to apply window.location.reload(); } window.onclick = function(event) { if (event.target == docsModal) { docsModal.classList.remove("show"); } if (event.target == settingsModal) { settingsModal.classList.remove("show"); } } async function fetchDocs() { try { const response = await fetch('../src/docs/TORCH.md'); if (!response.ok) { throw new Error('Failed to load documentation'); } const text = await response.text(); contentDiv.innerHTML = marked.parse(text); docsLoaded = true; } catch (error) { console.error(error); const p = document.createElement('p'); p.className = 'text-danger'; p.textContent = `Error loading documentation: ${error.message}`; contentDiv.replaceChildren(p); } } // ----------------------------------------------------------------------- // Configuration — matches bin/torch-lock.mjs defaults // Optional URL params: // ?namespace=torch&hashtag=torch-agent-lock&relays=wss://relay.damus.io,wss://nos.lol // ----------------------------------------------------------------------- const DASHBOARD_DEFAULTS = { namespace: 'torch', hashtag: '', defaultCadenceView: 'daily', defaultStatusView: 'active', relays: [ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', ], }; function normalizeCadence(value) { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'daily' || normalized === 'weekly' || normalized === 'all' ? normalized : DASHBOARD_DEFAULTS.defaultCadenceView; } function normalizeStatus(value) { const normalized = String(value || '').trim().toLowerCase(); return ['active', 'completed', 'all'].includes(normalized) ? normalized : DASHBOARD_DEFAULTS.defaultStatusView; } async function loadTorchConfigFile() { const configCandidates = ['../torch-config.json', './torch-config.json']; for (const path of configCandidates) { try { const response = await fetch(path, { cache: 'no-store' }); if (!response.ok) continue; return await response.json(); } catch { // optional config, ignore } } return {}; } function parseDashboardConfig(torchConfig = {}) { const params = new URLSearchParams(window.location.search); // 1. LocalStorage Preferences let localPrefs = {}; try { localPrefs = JSON.parse(localStorage.getItem('torch_dashboard_prefs')) || {}; } catch { /* ignore malformed localStorage */ } const dashboardConfig = torchConfig.dashboard || {}; const lockConfig = torchConfig.nostrLock || {}; // Hierarchy: URL Param > LocalStorage > Config File > Default // Namespace const configNamespace = dashboardConfig.namespace || lockConfig.namespace || DASHBOARD_DEFAULTS.namespace; const namespace = ( params.get('namespace') || localPrefs.namespace || configNamespace ).trim() || DASHBOARD_DEFAULTS.namespace; // Hashtag const configHashtag = dashboardConfig.hashtag || DASHBOARD_DEFAULTS.hashtag; const hashtag = ( params.get('hashtag') || localPrefs.hashtag || configHashtag || `${namespace}-agent-lock` ).trim(); // Relays const paramsRelays = params.get('relays') ? params.get('relays').split(',') : null; const localRelays = localPrefs.relays; const configRelays = dashboardConfig.relays || lockConfig.relays || DASHBOARD_DEFAULTS.relays; let relays = (paramsRelays || localRelays || configRelays) .map(r => r.trim()) .filter(Boolean); if (relays.length === 0) relays = DASHBOARD_DEFAULTS.relays; const defaultCadenceView = normalizeCadence(dashboardConfig.defaultCadenceView); const defaultStatusView = normalizeStatus(dashboardConfig.defaultStatusView); return { namespace, hashtag, relays, defaultCadenceView, defaultStatusView, }; } let DASHBOARD_CONFIG = parseDashboardConfig({}); let RELAYS = DASHBOARD_CONFIG.relays; const LOCK_EVENT_KIND = KIND_APP_DATA; let HASHTAG = DASHBOARD_CONFIG.hashtag; function applyConfigToHelpText() { const byId = (id) => document.getElementById(id); const hashtagDisplay = `#${HASHTAG}`; const introHashtag = byId('introHashtag'); if (introHashtag) introHashtag.textContent = hashtagDisplay; } // ----------------------------------------------------------------------- // State // ----------------------------------------------------------------------- /** @type {Map} dTag -> latest parsed lock */ const lockStore = new Map(); /** @type {WebSocket[]} */ const sockets = []; let connectedCount = 0; let refreshIntervalId = null; let isTeardown = false; // DOM refs const connectionStatus = document.getElementById('connectionStatus'); const lockGrid = document.getElementById('lockGrid'); const emptyState = document.getElementById('emptyState'); const rawLog = document.getElementById('rawLog'); const eventCount = document.getElementById('eventCount'); const summaryBar = document.getElementById('summaryBar'); const cadenceFilter = document.getElementById('cadenceFilter'); const statusFilter = document.getElementById('statusFilter'); const refreshBtn = document.getElementById('refreshBtn'); // Pagination DOM refs const paginationControls = document.getElementById('paginationControls'); const mobilePrevBtn = document.getElementById('mobilePrevBtn'); const mobileNextBtn = document.getElementById('mobileNextBtn'); const prevBtn = document.getElementById('prevBtn'); const nextBtn = document.getElementById('nextBtn'); const pageStartEl = document.getElementById('pageStart'); const pageEndEl = document.getElementById('pageEnd'); const totalItemsEl = document.getElementById('totalItems'); // Pagination State let currentPage = 1; const itemsPerPage = 20; // ----------------------------------------------------------------------- // Cleanup check // ----------------------------------------------------------------------- function isAlive() { if (isTeardown) return false; // Check if our root element is still in the DOM const stillInDom = document.body.contains(lockGrid); if (!stillInDom) { teardown(); return false; } return true; } function teardown() { if (isTeardown) return; isTeardown = true; if (refreshIntervalId) { clearInterval(refreshIntervalId); refreshIntervalId = null; } for (const ws of sockets) { try { ws.close(); } catch { // ignore } } sockets.length = 0; } // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- function nowUnix() { return Math.floor(Date.now() / 1000); } function todayStr() { return new Date().toISOString().slice(0, 10); } function fmtTime(unixSec) { return new Date(unixSec * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); } function fmtDuration(seconds) { if (seconds < 0) return 'expired'; const m = Math.floor(seconds / 60); if (m < 60) return `${m}m`; const h = Math.floor(m / 60); return `${h}h ${m % 60}m`; } function parseLockEvent(event) { const dTag = (event.tags || []).find((t) => t[0] === 'd')?.[1] ?? ''; const expTag = (event.tags || []).find( (t) => t[0] === 'expiration', )?.[1]; const expiresAt = expTag ? parseInt(expTag, 10) : null; let content = {}; try { content = JSON.parse(event.content); } catch { // non-JSON content } return { eventId: event.id, pubkey: event.pubkey, createdAt: event.created_at, expiresAt, dTag, agent: content.agent ?? dTag.split('/')[2] ?? 'unknown', cadence: content.cadence ?? dTag.split('/')[1] ?? 'unknown', status: content.status ?? 'unknown', date: content.date ?? dTag.split('/')[3] ?? todayStr(), platform: content.platform ?? 'unknown', model: content.model ?? null, }; } function isExpired(lock) { return lock.expiresAt != null && lock.expiresAt <= nowUnix(); } function shouldReplaceLock(existingLock, incomingLock) { if (!existingLock) return true; if (incomingLock.createdAt !== existingLock.createdAt) { return incomingLock.createdAt > existingLock.createdAt; } // Deterministic tie-break: prefer lexicographically higher event id. return (incomingLock.eventId || '') > (existingLock.eventId || ''); } // ----------------------------------------------------------------------- // Platform styling // ----------------------------------------------------------------------- const PLATFORM_COLORS = { jules: { bg: 'bg-info/10', text: 'text-info', label: 'Jules' }, 'claude-code': { bg: 'bg-warning/10', text: 'text-warning', label: 'Claude Code', }, codex: { bg: 'bg-success/10', text: 'text-success', label: 'Codex', }, goose: { bg: 'bg-purple/10', text: 'text-purple', label: 'Goose', }, gemini: { bg: 'bg-accent/10', text: 'text-accent', label: 'Gemini CLI', }, antigravity: { bg: 'bg-pink/10', text: 'text-pink', label: 'Antigravity IDE', }, qwen: { bg: 'bg-purple/10', text: 'text-purple', label: 'Qwen Coder', }, opencode: { bg: 'bg-teal/10', text: 'text-teal', label: 'OpenCode', }, unknown: { bg: 'bg-muted/10', text: 'text-muted', label: 'Unknown' }, }; function platformStyle(platform) { const raw = String(platform || '').toLowerCase().trim(); if (raw === 'claude' || raw.includes('claude')) return PLATFORM_COLORS['claude-code']; return PLATFORM_COLORS[raw] || PLATFORM_COLORS.unknown; } // ----------------------------------------------------------------------- // Rendering // ----------------------------------------------------------------------- function getFilteredLocks() { const cadence = cadenceFilter.value; const statusMode = statusFilter.value; return [...lockStore.values()] .filter((l) => { if (cadence !== 'all' && l.cadence !== cadence) return false; if (statusMode === 'completed') { return l.status === 'completed'; } if (statusMode === 'active') { // Active means not expired AND not completed if (isExpired(l)) return false; if (l.status === 'completed') return false; return true; } // If 'all', show everything (expired, completed, started) return true; }) .sort((a, b) => b.createdAt - a.createdAt); } function renderSummary(locks) { const active = locks.filter((l) => !isExpired(l)); const platforms = {}; for (const l of active) { const label = platformStyle(l.platform).label; platforms[label] = (platforms[label] || 0) + 1; } const cards = [ { label: 'Active locks', value: active.length, color: 'text-accent-strong', }, { label: 'Total events', value: locks.length, color: 'text-text', }, { label: 'Platforms', value: Object.keys(platforms).join(', ') || 'none', color: 'text-info', }, { label: 'Date', value: todayStr(), color: 'text-muted', }, ]; summaryBar.replaceChildren(); for (const c of cards) { const card = createElement('div', 'rounded-lg bg-surface-alt p-4'); const label = createElement('div', 'text-xs text-muted mb-1', c.label); const value = createElement('div', `text-lg font-semibold ${c.color} mono`, String(c.value)); card.appendChild(label); card.appendChild(value); summaryBar.appendChild(card); } } function createLockCard(lock, now) { const expired = isExpired(lock); const isCompleted = lock.status === 'completed'; const ps = platformStyle(lock.platform); const age = now - lock.createdAt; const remaining = lock.expiresAt ? lock.expiresAt - now : 0; const ttlTotal = lock.expiresAt ? lock.expiresAt - lock.createdAt : 7200; const ttlPct = expired ? 0 : Math.max(0, Math.min(100, (remaining / ttlTotal) * 100)); let cardBg = 'bg-surface-alt'; let cardBorder = expired ? 'border-border/50 opacity-60' : 'border-border'; if (isCompleted) { cardBg = 'bg-success/5'; cardBorder = 'border-success/30'; } const card = createElement('div', `rounded-lg border ${cardBorder} ${cardBg} p-4 flex flex-col gap-3`); // Header const header = createElement('div', 'flex items-start justify-between'); const left = createElement('div', 'flex items-center gap-2'); if (!expired && !isCompleted) { left.appendChild(createElement('span', 'pulse-dot bg-success mt-1 shrink-0')); } const meta = createElement('div'); meta.appendChild(createElement('div', 'font-medium text-text-strong text-sm', lock.agent)); meta.appendChild(createElement('div', 'text-xs text-muted', `${lock.cadence} \u00B7 ${lock.date}`)); left.appendChild(meta); header.appendChild(left); const right = createElement('div', 'flex flex-col items-end gap-1'); const badge = createElement('span', `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ps.bg} ${ps.text}`, ps.label); right.appendChild(badge); if (lock.model && lock.model !== 'unknown') { right.appendChild(createElement('span', 'text-xs text-muted mono opacity-75', lock.model)); } header.appendChild(right); card.appendChild(header); // Grid const grid = createElement('div', 'grid grid-cols-2 gap-x-4 gap-y-1 text-xs'); grid.appendChild(createElement('div', 'text-muted', 'Started')); grid.appendChild(createElement('div', 'mono text-text', fmtTime(lock.createdAt))); grid.appendChild(createElement('div', 'text-muted', 'Age')); grid.appendChild(createElement('div', 'mono text-text', fmtDuration(age))); grid.appendChild(createElement('div', 'text-muted', 'TTL remaining')); grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-text'}`, fmtDuration(remaining))); grid.appendChild(createElement('div', 'text-muted', 'Status')); grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-success'}`, expired ? 'expired' : lock.status)); card.appendChild(grid); // Progress Bar const barContainer = createElement('div', 'w-full bg-border/30 rounded-full overflow-hidden'); const bar = createElement('div', `ttl-bar ${expired ? 'bg-danger' : 'bg-success'}`); bar.style.width = `${ttlPct}%`; barContainer.appendChild(bar); card.appendChild(barContainer); // Footer (Event ID) const footer = createElement('div', 'text-xs text-muted mono truncate', lock.eventId ? lock.eventId.slice(0, 16) + '\u2026' : 'no id'); footer.title = lock.eventId || ''; card.appendChild(footer); return card; } let renderPending = false; function scheduleRender() { if (renderPending) return; renderPending = true; requestAnimationFrame(() => { renderPending = false; if (isAlive() && !document.hidden) { renderLocks(); } }); } function renderLocks() { if (!isAlive()) return; const allLocks = getFilteredLocks(); const now = nowUnix(); eventCount.textContent = `${lockStore.size} events`; renderSummary(allLocks); const totalItems = allLocks.length; const totalPages = Math.ceil(totalItems / itemsPerPage); // Adjust currentPage if out of bounds if (currentPage > totalPages && totalPages > 0) { currentPage = totalPages; } if (currentPage < 1) currentPage = 1; if (totalItems === 0) { lockGrid.replaceChildren(emptyState); emptyState.textContent = lockStore.size === 0 ? 'Waiting for lock events from relays\u2026' : 'No locks match the current filters.'; paginationControls.classList.add('hidden'); return; } // Slice for pagination const startIdx = (currentPage - 1) * itemsPerPage; const endIdx = Math.min(startIdx + itemsPerPage, totalItems); const pageLocks = allLocks.slice(startIdx, endIdx); const cards = pageLocks.map((lock) => createLockCard(lock, now)); lockGrid.replaceChildren(...cards); // Update Pagination Controls if (totalItems > itemsPerPage) { paginationControls.classList.remove('hidden'); // Update text pageStartEl.textContent = startIdx + 1; pageEndEl.textContent = endIdx; totalItemsEl.textContent = totalItems; // Update buttons state const isFirst = currentPage === 1; const isLast = currentPage === totalPages; prevBtn.disabled = isFirst; nextBtn.disabled = isLast; mobilePrevBtn.disabled = isFirst; mobileNextBtn.disabled = isLast; } else { paginationControls.classList.add('hidden'); } } // ----------------------------------------------------------------------- // Raw log // ----------------------------------------------------------------------- let rawLogInitialized = false; function appendRawLog(event) { if (!isAlive()) return; if (!rawLogInitialized) { rawLog.replaceChildren(); rawLogInitialized = true; } const ts = new Date(event.created_at * 1000).toISOString(); const text = `[${ts}] ${JSON.stringify(event).slice(0, 300)}`; const line = createElement('div', 'mb-2 pb-2 border-b border-border/30', text); rawLog.prepend(line); // Keep log bounded while (rawLog.children.length > 200) { rawLog.removeChild(rawLog.lastChild); } } // ----------------------------------------------------------------------- // WebSocket relay connections // ----------------------------------------------------------------------- function updateConnectionBadge() { if (!connectionStatus) return; const dot = connectionStatus.querySelector('.pulse-dot'); if (connectedCount === RELAYS.length) { connectionStatus.className = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-success/10 text-success'; dot.className = 'pulse-dot bg-success'; connectionStatus.lastChild.textContent = ` ${connectedCount}/${RELAYS.length} relays`; } else if (connectedCount > 0) { connectionStatus.className = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-warning/10 text-warning'; dot.className = 'pulse-dot bg-warning'; connectionStatus.lastChild.textContent = ` ${connectedCount}/${RELAYS.length} relays`; } else { connectionStatus.className = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-danger/10 text-danger'; dot.className = 'pulse-dot bg-danger'; connectionStatus.lastChild.textContent = ' Disconnected'; } } function connectToRelay(url) { if (!isAlive()) return; const ws = new WebSocket(url); const subId = 'torch-dash-' + Math.random().toString(36).slice(2, 8); ws.addEventListener('open', () => { if (!isAlive()) return; connectedCount++; updateConnectionBadge(); // NIP-01 REQ: subscribe to TORCH lock events // Request from 7 days ago to cover active locks created before midnight // and recent history for weekly cadence const since = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60; const filter = { kinds: [LOCK_EVENT_KIND], '#t': [HASHTAG], since: since, }; ws.send(JSON.stringify(['REQ', subId, filter])); }); ws.addEventListener('message', (msg) => { if (!isAlive()) return; let data; try { data = JSON.parse(msg.data); } catch { return; } // NIP-01: ["EVENT", subId, event] if (data[0] === 'EVENT' && data[2]) { const event = data[2]; const lock = parseLockEvent(event); // De-duplicate by dTag: keep one lock per d-tag, preferring the // most recent created_at; if equal, replace deterministically by event id. const existing = lockStore.get(lock.dTag); if (shouldReplaceLock(existing, lock)) { lockStore.set(lock.dTag, lock); } appendRawLog(event); scheduleRender(); } // NIP-01: ["EOSE", subId] — end of stored events if (data[0] === 'EOSE') { scheduleRender(); } }); ws.addEventListener('close', () => { // Clean up socket from tracking array const idx = sockets.indexOf(ws); if (idx !== -1) { sockets.splice(idx, 1); } if (isTeardown) return; if (ws.isManualClose) return; connectedCount = Math.max(0, connectedCount - 1); updateConnectionBadge(); // Auto-reconnect after 5 seconds if still alive setTimeout(() => { if (isAlive()) { connectToRelay(url); } }, 5000); }); ws.addEventListener('error', () => { // Error fires before close, so close handler will decrement }); sockets.push(ws); } // ----------------------------------------------------------------------- // Periodic TTL refresh // ----------------------------------------------------------------------- refreshIntervalId = setInterval(() => { if (isAlive() && !document.hidden) { renderLocks(); } }, 30_000); document.addEventListener('visibilitychange', () => { if (!document.hidden && isAlive()) { renderLocks(); } }); // ----------------------------------------------------------------------- // Event handlers // ----------------------------------------------------------------------- function setPage(p) { currentPage = p; renderLocks(); } if (cadenceFilter) cadenceFilter.addEventListener('change', () => { currentPage = 1; renderLocks(); }); if (statusFilter) statusFilter.addEventListener('change', () => { currentPage = 1; renderLocks(); }); if (prevBtn) prevBtn.addEventListener('click', () => setPage(currentPage - 1)); if (nextBtn) nextBtn.addEventListener('click', () => setPage(currentPage + 1)); if (mobilePrevBtn) mobilePrevBtn.addEventListener('click', () => setPage(currentPage - 1)); if (mobileNextBtn) mobileNextBtn.addEventListener('click', () => setPage(currentPage + 1)); if (refreshBtn) refreshBtn.addEventListener('click', () => { // Close existing connections and reconnect for (const ws of sockets) { try { ws.isManualClose = true; ws.close(); } catch { // ignore } } sockets.length = 0; connectedCount = 0; lockStore.clear(); updateConnectionBadge(); init(); }); // ----------------------------------------------------------------------- // Init // ----------------------------------------------------------------------- function init() { if (!isAlive()) return; applyConfigToHelpText(); for (const url of RELAYS) { connectToRelay(url); } } async function bootstrap() { const torchConfig = await loadTorchConfigFile(); DASHBOARD_CONFIG = parseDashboardConfig(torchConfig); RELAYS = DASHBOARD_CONFIG.relays; HASHTAG = DASHBOARD_CONFIG.hashtag; cadenceFilter.value = DASHBOARD_CONFIG.defaultCadenceView; statusFilter.value = DASHBOARD_CONFIG.defaultStatusView; init(); } bootstrap();