Files
TORCH/dashboard/index.html
T
google-labs-jules[bot]andPR0M3TH3AN 268248b4eb feat: add hosted dashboard configuration and distribution plan
Added a settings modal to the dashboard (index.html) to allow configuration of hashtag, namespace, and relays via URL query parameters and localStorage. This enables the dashboard to be hosted as a static site and used by any project.

Also added `docs/architecture/distribution-plan.md` outlining the strategy for NPM, Binary, and Hosted Dashboard distribution.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-17 03:26:33 +00:00

1044 lines
38 KiB
HTML

<!-- torch/dashboard/index.html -->
<!DOCTYPE html>
<html lang="en" data-ds="new">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TORCH Agent Dashboard</title>
<link rel="icon" type="image/svg+xml" href="../assets/favicon.svg">
<link rel="stylesheet" href="./styles.css" />
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<div class="bg-surface text-text min-h-screen">
<div role="main" class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Header -->
<header class="mb-8">
<div class="flex items-center gap-3 mb-2">
<img src="../assets/logo.svg" alt="TORCH Logo" width="32" height="32">
<h1 class="text-2xl font-semibold text-text-strong">
TORCH Agent Dashboard
</h1>
<span
id="connectionStatus"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-surface-alt text-muted"
>
<span class="pulse-dot bg-muted"></span>
Connecting&hellip;
</span>
</div>
<p class="text-muted text-sm max-w-prose">
Live view of
<a
href="#"
id="openDocs"
class="text-info hover:text-info-strong"
>TORCH</a
>
lock events published by AI agents working on this TORCH project. Events
are kind <code class="mono text-xs">30078</code> filtered by the
<code id="introHashtag" class="mono text-xs">...</code> hashtag on public
Nostr relays.
</p>
</header>
<!-- Controls -->
<div
class="flex flex-wrap items-center gap-4 mb-6 p-4 rounded-lg bg-surface-alt"
>
<div class="flex items-center gap-2">
<label for="cadenceFilter" class="text-sm font-medium text-text"
>Cadence</label
>
<select
id="cadenceFilter"
class="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="all">All</option>
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div class="flex items-center gap-2">
<label for="statusFilter" class="text-sm font-medium text-text"
>Status</label
>
<select
id="statusFilter"
class="rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="active" selected>Active only</option>
<option value="completed">Completed only</option>
<option value="all">All (incl. expired)</option>
</select>
</div>
<div class="flex items-center gap-2 ml-auto">
<span id="eventCount" class="text-xs text-muted mono">0 events</span>
<button
id="settingsBtn"
type="button"
class="rounded-md bg-surface border border-border px-3 py-1.5 text-sm font-medium text-text hover:bg-surface-alt focus:outline-none focus:ring-2 focus:ring-accent transition-colors"
>
Settings
</button>
<button
id="refreshBtn"
type="button"
class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white hover:bg-accent-strong focus:outline-none focus:ring-2 focus:ring-accent transition-colors"
>
Refresh
</button>
</div>
</div>
<!-- Summary bar -->
<div
id="summaryBar"
class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6"
></div>
<!-- Lock cards -->
<div id="lockGrid" class="lock-grid mb-8">
<p id="emptyState" class="text-muted col-span-full text-center py-12">
Waiting for lock events from relays&hellip;
</p>
</div>
<!-- Pagination Controls -->
<div id="paginationControls" class="flex items-center justify-between border-t border-border pt-4 mb-8 hidden">
<div class="flex flex-1 justify-between sm:hidden">
<button id="mobilePrevBtn" class="pagination-btn rounded-md px-3 py-2">Previous</button>
<button id="mobileNextBtn" class="pagination-btn rounded-md ml-auto px-3 py-2">Next</button>
</div>
<div class="sm:flex sm:flex-1 sm:items-center sm:justify-between hidden">
<div>
<p class="text-sm text-muted">
Showing
<span class="font-medium text-text" id="pageStart">1</span>
to
<span class="font-medium text-text" id="pageEnd">10</span>
of
<span class="font-medium text-text" id="totalItems">97</span>
results
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<button id="prevBtn" class="pagination-btn rounded-l-md px-2 py-2">
<span class="sr-only">Previous</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
<button id="nextBtn" class="pagination-btn rounded-r-md px-2 py-2">
<span class="sr-only">Next</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
<!-- Raw event log -->
<details class="rounded-lg border border-border">
<summary
class="cursor-pointer px-4 py-3 text-sm font-medium text-text hover:bg-surface-alt"
>
Raw event log
</summary>
<div
id="rawLog"
class="max-h-96 overflow-y-auto p-4 mono text-xs text-muted bg-surface-alt"
>
<p>Events will appear here as they arrive&hellip;</p>
</div>
</details>
</div>
</div>
<!-- Documentation Modal -->
<div id="docsModal" class="modal">
<div class="modal-content">
<span class="close-modal">&times;</span>
<div id="markdown-content">
<p class="text-muted">Loading documentation...</p>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<span class="close-modal" id="closeSettings">&times;</span>
<h2 class="text-xl font-semibold mb-4 text-text-strong">Dashboard Settings</h2>
<form id="settingsForm" class="space-y-4">
<div>
<label for="settingHashtag" class="block text-sm font-medium text-text mb-1">Hashtag (Required)</label>
<input
type="text"
id="settingHashtag"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. my-project-lock"
required
>
<p class="text-xs text-muted mt-1">Unique identifier for this project's locks.</p>
</div>
<div>
<label for="settingNamespace" class="block text-sm font-medium text-text mb-1">Namespace</label>
<input
type="text"
id="settingNamespace"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="torch"
>
<p class="text-xs text-muted mt-1">Default: torch</p>
</div>
<div>
<label for="settingRelays" class="block text-sm font-medium text-text mb-1">Relays (Comma-separated)</label>
<input
type="text"
id="settingRelays"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="wss://relay.damus.io, wss://nos.lol"
>
</div>
<div class="pt-4 flex justify-end gap-3">
<button
type="button"
id="cancelSettings"
class="rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text hover:bg-surface-alt"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-strong"
>
Save & Reload
</button>
</div>
</form>
</div>
</div>
<script type="module">
import { KIND_APP_DATA } from '../src/constants.mjs';
import { createElement, escapeHtml } 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);
contentDiv.innerHTML = `<p class="text-danger">Error loading documentation: ${error.message}</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 {}
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;
let D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`;
const LOCK_EVENT_KIND = KIND_APP_DATA;
let HASHTAG = DASHBOARD_CONFIG.hashtag;
function applyConfigToHelpText() {
const byId = (id) => document.getElementById(id);
const hashtagDisplay = `#${HASHTAG}`;
const filterExample = JSON.stringify({ kinds: [LOCK_EVENT_KIND], '#t': [HASHTAG] });
const introHashtag = byId('introHashtag');
if (introHashtag) introHashtag.textContent = hashtagDisplay;
// These elements were in the footer which is now removed
// byId('infoHashtag').textContent = hashtagDisplay;
// byId('infoHashtagRepeat').textContent = hashtagDisplay;
// byId('followHashtag').textContent = `#t = ${HASHTAG}`;
// byId('dTagPrefix').textContent = D_TAG_PREFIX;
// byId('filterExample').textContent = filterExample;
// byId('relayList').textContent = RELAYS.join(', ');
}
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
/** @type {Map<string, object>} 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',
},
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;
const now = nowUnix();
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 now = nowUnix();
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;
}
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);
renderLocks();
}
// NIP-01: ["EOSE", subId] — end of stored events
if (data[0] === 'EOSE') {
renderLocks();
}
});
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;
D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`;
HASHTAG = DASHBOARD_CONFIG.hashtag;
cadenceFilter.value = DASHBOARD_CONFIG.defaultCadenceView;
statusFilter.value = DASHBOARD_CONFIG.defaultStatusView;
init();
}
bootstrap();
</script>
</body>
</html>