965 lines
36 KiB
JavaScript
965 lines
36 KiB
JavaScript
/**
|
||
* BridgeSwarm Control Center — premium dashboard UI
|
||
*/
|
||
|
||
const SETTINGS_KEY = 'bridgeSwarmSettings';
|
||
const DASHBOARD_PORT = 'bridgeswarm-dashboard';
|
||
const DEFAULT_SETTINGS = {
|
||
defaultAppName: 'bridge-swarm',
|
||
defaultMaxPeers: 0,
|
||
defaultRequestTimeoutMs: 0,
|
||
readyTimeoutMs: 0,
|
||
defaultCapJobTimeoutMs: 120000,
|
||
disableOnFileUrls: false,
|
||
notifyOnDisconnect: false,
|
||
debug: false,
|
||
examplesServerEnabled: false,
|
||
examplesServerPort: 4173,
|
||
defaultFirewallMode: 'off',
|
||
defaultFirewallKeys: [],
|
||
dashboardRefreshMs: 2000,
|
||
};
|
||
|
||
let currentState = null;
|
||
let settings = { ...DEFAULT_SETTINGS };
|
||
let settingsLoading = false;
|
||
let settingsSaveTimer = null;
|
||
let refreshTimer = null;
|
||
let lastRefreshAt = 0;
|
||
let dashPort = null;
|
||
|
||
/** @type {any[]} */
|
||
let allLogs = [];
|
||
let paused = false;
|
||
let follow = true;
|
||
let unseenErrors = 0;
|
||
const levelFilter = new Set(['info', 'warn', 'error']);
|
||
const categoryFilter = new Set(['host', 'swarm', 'conn', 'cap', 'examples', 'settings']);
|
||
let searchQuery = '';
|
||
let metaFilter = null;
|
||
|
||
const COPY_SVG =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
|
||
|
||
function $(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function showToast(msg, kind) {
|
||
const el = $('toast');
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.className = 'toast show' + (kind === 'err' ? ' err' : kind === 'ok' ? ' ok' : '');
|
||
clearTimeout(showToast._t);
|
||
showToast._t = setTimeout(() => el.classList.remove('show'), 2200);
|
||
}
|
||
|
||
function timeAgo(ts) {
|
||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||
if (seconds < 60) return seconds + 's ago';
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return minutes + 'm ago';
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return hours + 'h ago';
|
||
return Math.floor(hours / 24) + 'd ago';
|
||
}
|
||
|
||
function formatUptime(ms) {
|
||
const seconds = Math.floor(ms / 1000);
|
||
if (seconds < 60) return seconds + 's';
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return minutes + 'm';
|
||
const hours = Math.floor(minutes / 60);
|
||
return hours + 'h ' + (minutes % 60) + 'm';
|
||
}
|
||
|
||
function truncate(str, len = 14) {
|
||
if (!str) return '';
|
||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const d = document.createElement('div');
|
||
d.textContent = str == null ? '' : String(str);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function idCell(full, len = 14) {
|
||
const safe = escapeHtml(full || '');
|
||
return `<span class="id-cell" title="${safe}"><code>${escapeHtml(truncate(full, len))}</code><button type="button" class="copy-btn" data-copy="${safe}" title="Copy">${COPY_SVG}</button></span>`;
|
||
}
|
||
|
||
function sendBg(action, payload) {
|
||
return new Promise((resolve) => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'bridge-swarm-native', action, payload },
|
||
(response) => {
|
||
if (chrome.runtime.lastError) {
|
||
resolve({ ok: false, error: chrome.runtime.lastError.message });
|
||
return;
|
||
}
|
||
resolve(response || { ok: false });
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
function connectDashboardPort() {
|
||
try {
|
||
dashPort = chrome.runtime.connect({ name: DASHBOARD_PORT });
|
||
} catch (e) {
|
||
console.warn('Dashboard port failed', e);
|
||
return;
|
||
}
|
||
dashPort.onMessage.addListener((msg) => {
|
||
if (!msg) return;
|
||
if (msg.type === 'bridge-swarm-logs') {
|
||
if (msg.logs) {
|
||
if (!paused) {
|
||
const prevLen = allLogs.length;
|
||
allLogs = msg.logs;
|
||
if (allLogs.length > prevLen) {
|
||
const added = allLogs.slice(prevLen);
|
||
for (const e of added) {
|
||
if (e.level === 'error') unseenErrors++;
|
||
}
|
||
}
|
||
updateErrorBadge();
|
||
renderLogs();
|
||
renderRecentActivity();
|
||
}
|
||
} else if (msg.entry && !paused) {
|
||
allLogs.push(msg.entry);
|
||
if (allLogs.length > 500) allLogs.shift();
|
||
if (msg.entry.level === 'error') {
|
||
unseenErrors++;
|
||
updateErrorBadge();
|
||
}
|
||
renderLogs();
|
||
renderRecentActivity();
|
||
}
|
||
}
|
||
if (msg.type === 'bridge-swarm-state' && msg.state) {
|
||
applyState(msg.state);
|
||
}
|
||
});
|
||
dashPort.onDisconnect.addListener(() => {
|
||
dashPort = null;
|
||
setTimeout(connectDashboardPort, 1000);
|
||
});
|
||
}
|
||
|
||
async function fetchState() {
|
||
await refreshHostSnapshotSoft();
|
||
return sendBg('getState');
|
||
}
|
||
|
||
async function refreshHostSnapshotSoft() {
|
||
/* getState already refreshes snapshot in BG */
|
||
}
|
||
|
||
function applyState(state) {
|
||
if (!state) return;
|
||
currentState = state;
|
||
lastRefreshAt = Date.now();
|
||
updateOverview(state);
|
||
updateSwarmsTable(state);
|
||
updateConnectionsTable(state);
|
||
updateTabsTable(state);
|
||
$('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||
$('sidebarHostHint').textContent = state.hostConnected
|
||
? 'Host: connected'
|
||
: state.health?.reconnectAttempt
|
||
? `Host: reconnecting (#${state.health.reconnectAttempt})`
|
||
: 'Host: disconnected';
|
||
}
|
||
|
||
function updateOverview(state) {
|
||
const connected = !!state.hostConnected;
|
||
const reconnecting = !connected && (state.stats?.reconnectAttempt > 0 || state.health?.reconnectAttempt > 0);
|
||
const pill = $('overviewPill');
|
||
const pillText = $('overviewPillText');
|
||
if (connected) {
|
||
pill.className = 'status-pill ok';
|
||
pillText.textContent = 'Host connected';
|
||
} else if (reconnecting) {
|
||
pill.className = 'status-pill warn';
|
||
pillText.textContent = 'Reconnecting…';
|
||
} else {
|
||
pill.className = 'status-pill err';
|
||
pillText.textContent = 'Host disconnected';
|
||
}
|
||
|
||
const h = state.health || {};
|
||
const health = $('healthCard');
|
||
if (!connected) {
|
||
health.innerHTML = `
|
||
<div class="empty-state">
|
||
<h4>Native host not connected</h4>
|
||
<p>${escapeHtml(h.lastDisconnectReason || 'Install or repair the BridgeSwarm native host, then reload this panel.')}</p>
|
||
<div class="btn-row" style="justify-content:center">
|
||
<button type="button" class="btn btn-primary" id="btnRetryHint">Retry connection</button>
|
||
</div>
|
||
${h.reconnectAttempt ? `<p class="muted" style="margin-top:0.75rem">Reconnect attempt #${h.reconnectAttempt} · next in ~${Math.round((state.stats?.reconnectDelayMs || 0) / 1000)}s</p>` : ''}
|
||
</div>
|
||
<div class="health" style="margin-top:1rem;border-top:1px solid var(--border);padding-top:0.85rem">
|
||
<div class="health-row"><span class="k">Extension</span><span class="v">v${escapeHtml(h.extensionVersion || '—')}</span></div>
|
||
<div class="health-row"><span class="k">Host</span><span class="v">${escapeHtml(h.hostVersion || '—')}</span></div>
|
||
</div>`;
|
||
$('btnRetryHint')?.addEventListener('click', () => refresh());
|
||
} else {
|
||
health.innerHTML = `
|
||
<div class="health">
|
||
<div class="health-row"><span class="k">Status</span><span class="v" style="color:var(--accent)">Connected</span></div>
|
||
<div class="health-row"><span class="k">Extension</span><span class="v">v${escapeHtml(h.extensionVersion || '—')}</span></div>
|
||
<div class="health-row"><span class="k">Host</span><span class="v">v${escapeHtml(h.hostVersion || '—')}</span></div>
|
||
<div class="health-row"><span class="k">Storage</span><span class="v">${escapeHtml(h.storagePath || '—')}</span></div>
|
||
<div class="health-row"><span class="k">Capability packs</span><span class="v">${escapeHtml((h.packs || []).join(', ') || 'none')}</span></div>
|
||
<div class="health-row"><span class="k">Pending RPCs</span><span class="v">${state.stats?.pendingRequests ?? 0}</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
$('dashSwarms').textContent = state.stats?.totalSwarms ?? 0;
|
||
$('dashConnections').textContent = state.stats?.totalConnections ?? 0;
|
||
$('dashTabs').textContent = state.tabs?.length ?? 0;
|
||
$('dashUptime').textContent = formatUptime(Date.now() - (state.stats?.uptime || Date.now()));
|
||
$('swarmCount').textContent = state.stats?.totalSwarms ?? 0;
|
||
$('connCount').textContent = state.stats?.totalConnections ?? 0;
|
||
|
||
updateOverviewExamples(state);
|
||
updateRecentConnections(state);
|
||
|
||
// Empty swarm guidance
|
||
if (connected && (state.stats?.totalSwarms || 0) === 0) {
|
||
/* shown via tables */
|
||
}
|
||
}
|
||
|
||
function updateOverviewExamples(state) {
|
||
const ex = state.examples || {};
|
||
const toggle = $('overviewExamplesToggle');
|
||
const body = $('overviewExamplesBody');
|
||
const enabled = settings.examplesServerEnabled === true;
|
||
toggle.classList.toggle('active', enabled);
|
||
toggle.setAttribute('aria-checked', enabled ? 'true' : 'false');
|
||
|
||
if (!enabled) {
|
||
body.innerHTML = `
|
||
<p class="muted" style="font-size:0.85rem;line-height:1.45;margin-bottom:0.85rem">
|
||
Serve bundled demos at <span class="mono">http://127.0.0.1:${settings.examplesServerPort || 4173}/</span> via the native host.
|
||
</p>
|
||
<button type="button" class="btn btn-primary btn-sm" id="btnEnableExamplesOverview">Enable examples server</button>`;
|
||
$('btnEnableExamplesOverview')?.addEventListener('click', () => {
|
||
const st = $('toggleExamplesServer');
|
||
if (st) st.classList.add('active');
|
||
toggle.classList.add('active');
|
||
settings.examplesServerEnabled = true;
|
||
chrome.storage.local.set({ [SETTINGS_KEY]: { ...settings, examplesServerEnabled: true } }, () => {
|
||
updateOverviewExamples(currentState || { examples: {}, hostConnected: !!currentState?.hostConnected });
|
||
updateExamplesSettingsPanel();
|
||
showToast('Examples server enabled', 'ok');
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
const url = ex.url || `http://127.0.0.1:${settings.examplesServerPort || 4173}/`;
|
||
let status = 'Starting…';
|
||
let statusClass = 'muted';
|
||
if (ex.error) {
|
||
status = ex.error;
|
||
statusClass = '';
|
||
status = `<span style="color:var(--danger)">${escapeHtml(ex.error)}</span>`;
|
||
} else if (ex.running) {
|
||
status = `<span style="color:var(--accent)">Running</span>`;
|
||
} else if (!state.hostConnected) {
|
||
status = 'Waiting for native host…';
|
||
}
|
||
|
||
body.innerHTML = `
|
||
<div class="health">
|
||
<div class="health-row"><span class="k">Status</span><span class="v">${status}</span></div>
|
||
<div class="health-row"><span class="k">URL</span><span class="v">${escapeHtml(url)}</span></div>
|
||
</div>
|
||
<div class="btn-row" style="margin-top:0.85rem">
|
||
<button type="button" class="btn btn-primary btn-sm" id="btnOpenExOverview">Open examples</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="btnCopyExOverview">Copy URL</button>
|
||
</div>`;
|
||
void statusClass;
|
||
$('btnOpenExOverview')?.addEventListener('click', () => chrome.tabs.create({ url }));
|
||
$('btnCopyExOverview')?.addEventListener('click', async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
showToast('URL copied', 'ok');
|
||
} catch (_) {
|
||
showToast('Copy failed', 'err');
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateRecentConnections(state) {
|
||
const connections = [];
|
||
for (const swarm of state.swarms || []) {
|
||
for (const conn of swarm.connections || []) connections.push(conn);
|
||
}
|
||
connections.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||
const tbody = $('recentConnections');
|
||
if (!connections.length) {
|
||
const connected = state.hostConnected;
|
||
tbody.innerHTML = connected
|
||
? `<tr><td colspan="4"><div class="empty-state"><h4>No peers yet</h4><p>Open the examples gallery and join a topic in two tabs.</p><button type="button" class="btn btn-primary btn-sm" data-goto="settings">Enable examples in Settings</button></div></td></tr>`
|
||
: `<tr><td colspan="4" class="muted" style="padding:1rem">Waiting for host…</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = connections
|
||
.slice(0, 8)
|
||
.map(
|
||
(c) => `<tr>
|
||
<td>${idCell(c.connId)}</td>
|
||
<td>${idCell(c.swarmId, 12)}</td>
|
||
<td>${idCell(c.peerKey, 12)}</td>
|
||
<td class="time-ago">${timeAgo(c.createdAt)}</td>
|
||
</tr>`
|
||
)
|
||
.join('');
|
||
}
|
||
|
||
function updateSwarmsTable(state) {
|
||
const tbody = $('swarmsTable');
|
||
const swarms = state.swarms || [];
|
||
if (!swarms.length) {
|
||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><h4>No active swarms</h4><p>Swarms appear when a page creates <code>new BridgeSwarm()</code> and joins a topic.</p></div></td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = swarms
|
||
.map((s) => {
|
||
const fw = s.firewall ? `${s.firewall.mode}${s.firewall.keyCount ? ` (${s.firewall.keyCount})` : ''}` : '—';
|
||
return `<tr>
|
||
<td>${idCell(s.swarmId, 18)}</td>
|
||
<td>${escapeHtml(s.appName || '—')}</td>
|
||
<td>${s.peerCount ?? s.connections?.length ?? 0}</td>
|
||
<td>${s.tabCount ?? 0}</td>
|
||
<td class="muted">${escapeHtml(fw)}</td>
|
||
<td class="time-ago">${s.createdAt ? timeAgo(s.createdAt) : '—'}</td>
|
||
<td><button type="button" class="btn btn-danger btn-sm" data-destroy="${escapeHtml(s.swarmId)}">Destroy</button></td>
|
||
</tr>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function updateConnectionsTable(state) {
|
||
const connections = [];
|
||
for (const swarm of state.swarms || []) {
|
||
for (const conn of swarm.connections || []) connections.push(conn);
|
||
}
|
||
connections.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||
const tbody = $('connectionsTable');
|
||
if (!connections.length) {
|
||
tbody.innerHTML = `<tr><td colspan="5"><div class="empty-state"><h4>No active connections</h4><p>Peer sockets show up after Hyperswarm discovers another peer on the same topic.</p></div></td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = connections
|
||
.map((c) => {
|
||
const topics = (c.topics || []).map((t) => truncate(t, 8)).join(', ') || '—';
|
||
return `<tr>
|
||
<td>${idCell(c.connId, 18)}</td>
|
||
<td>${idCell(c.swarmId, 12)}</td>
|
||
<td>${idCell(c.peerKey, 16)}</td>
|
||
<td class="mono muted">${escapeHtml(topics)}</td>
|
||
<td class="time-ago">${timeAgo(c.createdAt)}</td>
|
||
</tr>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function updateTabsTable(state) {
|
||
const tbody = $('tabsTable');
|
||
const tabs = state.tabs || [];
|
||
if (!tabs.length) {
|
||
tbody.innerHTML = `<tr><td colspan="4" class="muted" style="padding:1rem">No BridgeSwarm tabs</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = tabs
|
||
.map((t) => {
|
||
const title = t.title || `Tab ${t.tabId}`;
|
||
const url = t.url ? truncate(t.url, 40) : '';
|
||
return `<tr>
|
||
<td class="mono">${t.tabId}</td>
|
||
<td><div>${escapeHtml(title)}</div><div class="muted mono" style="font-size:0.7rem">${escapeHtml(url)}</div></td>
|
||
<td>${(t.swarmIds || []).map((id) => idCell(id, 10)).join(' ') || '—'}</td>
|
||
<td><button type="button" class="btn btn-ghost btn-sm" data-focus-tab="${t.tabId}">Focus</button></td>
|
||
</tr>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
/* ——— Logs ——— */
|
||
|
||
function updateErrorBadge() {
|
||
const badge = $('logErrorBadge');
|
||
if (!badge) return;
|
||
if (unseenErrors > 0) {
|
||
badge.hidden = false;
|
||
badge.textContent = String(unseenErrors);
|
||
badge.classList.add('warn');
|
||
} else {
|
||
badge.hidden = true;
|
||
}
|
||
}
|
||
|
||
function filteredLogs() {
|
||
return allLogs.filter((e) => {
|
||
const level = e.level || 'info';
|
||
const cat = e.category || 'host';
|
||
if (!levelFilter.has(level)) return false;
|
||
if (!categoryFilter.has(cat)) return false;
|
||
if (metaFilter) {
|
||
const m = e.meta || {};
|
||
const hay = [e.message, m.swarmId, m.connId, m.requestId, m.jobId].join(' ');
|
||
if (!hay.includes(metaFilter)) return false;
|
||
}
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
const blob = JSON.stringify(e).toLowerCase();
|
||
if (!blob.includes(q)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function linkifyMessage(msg) {
|
||
return escapeHtml(msg).replace(/(swarm_[a-zA-Z0-9]+|conn_[a-zA-Z0-9]+|req_[a-zA-Z0-9]+|(?:job|session)_[a-zA-Z0-9]+)/g, (id) => {
|
||
return `<span class="log-link" data-filter-id="${id}">${id}</span>`;
|
||
});
|
||
}
|
||
|
||
function renderLogs() {
|
||
const container = $('logsContainer');
|
||
if (!container) return;
|
||
const rows = filteredLogs();
|
||
$('logsCount').textContent = `${rows.length} / ${allLogs.length} events`;
|
||
$('logsFilterHint').textContent = metaFilter ? `Filter: ${metaFilter}` : searchQuery ? `Search: ${searchQuery}` : 'Showing filtered';
|
||
|
||
if (!rows.length) {
|
||
container.innerHTML = '<div class="empty-state muted">No log events match filters</div>';
|
||
return;
|
||
}
|
||
|
||
const slice = rows.slice(-400);
|
||
container.innerHTML = slice
|
||
.map((e) => {
|
||
const ts = e.ts || e.timestamp || Date.now();
|
||
const time = new Date(ts).toLocaleTimeString();
|
||
const level = e.level || 'info';
|
||
const cat = e.category || 'host';
|
||
const meta = e.meta && Object.keys(e.meta).length ? escapeHtml(JSON.stringify(e.meta, null, 2)) : '';
|
||
return `<div class="log-row level-${level}" data-id="${escapeHtml(e.id || '')}">
|
||
<span class="log-time" title="${new Date(ts).toISOString()}">${time}</span>
|
||
<span class="log-level">${level}</span>
|
||
<span class="log-cat">${cat}</span>
|
||
<span class="log-msg">${linkifyMessage(e.message || '')}${meta ? `<div class="log-meta">${meta}</div>` : ''}</span>
|
||
</div>`;
|
||
})
|
||
.join('');
|
||
|
||
if (follow) container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function renderRecentActivity() {
|
||
const el = $('recentActivity');
|
||
if (!el) return;
|
||
const recent = allLogs.slice(-12).reverse();
|
||
if (!recent.length) {
|
||
el.innerHTML = '<div class="empty-state muted">No events yet</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = recent
|
||
.map((e) => {
|
||
const ts = e.ts || e.timestamp;
|
||
return `<div class="log-row level-${e.level || 'info'}">
|
||
<span class="log-time">${new Date(ts).toLocaleTimeString()}</span>
|
||
<span class="log-msg">${escapeHtml(truncate(e.message, 80))}</span>
|
||
</div>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
/* ——— Settings ——— */
|
||
|
||
function renderSettingsForm() {
|
||
const root = $('settingsRoot');
|
||
if (!root) return;
|
||
const s = settings;
|
||
root.innerHTML = `
|
||
<div class="settings-group">
|
||
<h3>Notifications & debug</h3>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Host disconnect notification</div><div class="setting-desc">Browser notification when the native host drops</div></div>
|
||
<div class="toggle" id="toggleNotify" data-key="notifyOnDisconnect"></div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Debug mode</div><div class="setting-desc">Verbose bridge logging in the Activity stream and page console</div></div>
|
||
<div class="toggle" id="toggleDebug" data-key="debug"></div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Disable on file:// URLs</div><div class="setting-desc">Skip injecting BridgeSwarm on local files</div></div>
|
||
<div class="toggle" id="toggleDisableFileUrls" data-key="disableOnFileUrls"></div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-group">
|
||
<h3>Swarm defaults</h3>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Default app name</div><div class="setting-desc">Used when a page omits appName</div></div>
|
||
<input type="text" id="defaultAppName" value="${escapeHtml(s.defaultAppName || '')}" style="width:200px">
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Default max peers</div><div class="setting-desc">0 = unlimited</div></div>
|
||
<input type="number" id="defaultMaxPeers" min="0" value="${s.defaultMaxPeers ?? 0}" style="width:100px">
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Request timeout (ms)</div><div class="setting-desc">Default BridgeSwarm.request timeout; 0 = none</div></div>
|
||
<input type="number" id="defaultRequestTimeoutMs" min="0" max="300000" value="${s.defaultRequestTimeoutMs ?? 0}" style="width:120px">
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Ready timeout (ms)</div><div class="setting-desc">BridgeSwarm.ready() timeout; 0 = wait forever</div></div>
|
||
<input type="number" id="readyTimeoutMs" min="0" max="60000" value="${s.readyTimeoutMs ?? 0}" style="width:120px">
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Capability job timeout (ms)</div><div class="setting-desc">Default wait for media/transcode jobs</div></div>
|
||
<input type="number" id="defaultCapJobTimeoutMs" min="1000" max="600000" value="${s.defaultCapJobTimeoutMs ?? 120000}" style="width:120px">
|
||
</div>
|
||
</div>
|
||
<div class="settings-group">
|
||
<h3>Default peer firewall</h3>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Mode</div><div class="setting-desc">Applied automatically when a swarm initializes</div></div>
|
||
<select id="defaultFirewallMode">
|
||
<option value="off">Off</option>
|
||
<option value="allowlist">Allowlist</option>
|
||
<option value="denylist">Denylist</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Peer keys</div><div class="setting-desc">One public-key hex per line</div></div>
|
||
<textarea id="defaultFirewallKeys" placeholder="abcdef…">${escapeHtml((s.defaultFirewallKeys || []).join('\n'))}</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="settings-group">
|
||
<h3>Examples server</h3>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Enable examples server</div><div class="setting-desc">Native host serves demos on localhost only</div></div>
|
||
<div class="toggle" id="toggleExamplesServer" data-key="examplesServerEnabled"></div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Port</div><div class="setting-desc">Bound to 127.0.0.1 only</div></div>
|
||
<input type="number" id="examplesServerPort" min="1024" max="65535" value="${s.examplesServerPort ?? 4173}" style="width:100px">
|
||
</div>
|
||
<div id="examplesServerPanel" class="examples-panel" style="display:none"></div>
|
||
</div>
|
||
<div class="settings-group">
|
||
<h3>Panel</h3>
|
||
<div class="setting-row">
|
||
<div><div class="setting-label">Refresh interval (ms)</div><div class="setting-desc">How often Overview polls getState (1000–10000)</div></div>
|
||
<input type="number" id="dashboardRefreshMs" min="1000" max="10000" step="500" value="${s.dashboardRefreshMs ?? 2000}" style="width:100px">
|
||
</div>
|
||
</div>
|
||
<div class="settings-group">
|
||
<h3>About</h3>
|
||
<p class="muted" style="font-size:0.8rem;margin:0 0 0.5rem">
|
||
BridgeSwarm — Hyperswarm in the browser via a Bare native messaging host.
|
||
</p>
|
||
<p class="muted" style="font-size:0.8rem;margin:0 0 0.5rem">
|
||
Version: <span class="mono" id="aboutVersion">—</span> · License: <span class="mono">AGPL-3.0</span>
|
||
</p>
|
||
<p class="muted" style="font-size:0.8rem;margin:0 0 0.5rem">
|
||
Owned and engineered by <strong style="color:var(--text)">HoneyPeer, LLC</strong>
|
||
(DeKalb County, Georgia, USA).
|
||
</p>
|
||
<p class="muted" style="font-size:0.8rem;margin:0">
|
||
Legal: <a href="mailto:[email protected]">[email protected]</a>
|
||
</p>
|
||
</div>
|
||
<p class="muted" style="font-size:0.75rem">Settings autosave. Reset restores defaults including examples server off.</p>
|
||
`;
|
||
|
||
$('toggleNotify')?.classList.toggle('active', s.notifyOnDisconnect === true);
|
||
$('toggleDebug')?.classList.toggle('active', s.debug === true);
|
||
$('toggleDisableFileUrls')?.classList.toggle('active', s.disableOnFileUrls === true);
|
||
$('toggleExamplesServer')?.classList.toggle('active', s.examplesServerEnabled === true);
|
||
const fw = $('defaultFirewallMode');
|
||
if (fw) fw.value = s.defaultFirewallMode || 'off';
|
||
const ver = chrome.runtime.getManifest?.().version;
|
||
if (ver && $('aboutVersion')) $('aboutVersion').textContent = ver;
|
||
|
||
root.querySelectorAll('.toggle').forEach((toggle) => {
|
||
toggle.addEventListener('click', () => {
|
||
toggle.classList.toggle('active');
|
||
scheduleSaveSettings();
|
||
if (toggle.id === 'toggleExamplesServer' || toggle.id === 'overviewExamplesToggle') {
|
||
updateExamplesSettingsPanel();
|
||
}
|
||
});
|
||
});
|
||
|
||
[
|
||
'defaultAppName',
|
||
'defaultMaxPeers',
|
||
'defaultRequestTimeoutMs',
|
||
'readyTimeoutMs',
|
||
'defaultCapJobTimeoutMs',
|
||
'examplesServerPort',
|
||
'dashboardRefreshMs',
|
||
'defaultFirewallMode',
|
||
'defaultFirewallKeys',
|
||
].forEach((id) => {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.addEventListener('change', scheduleSaveSettings);
|
||
el.addEventListener('input', scheduleSaveSettings);
|
||
});
|
||
|
||
updateExamplesSettingsPanel();
|
||
}
|
||
|
||
function updateExamplesSettingsPanel() {
|
||
const panel = $('examplesServerPanel');
|
||
if (!panel) return;
|
||
const on = $('toggleExamplesServer')?.classList.contains('active');
|
||
panel.style.display = on ? 'block' : 'none';
|
||
if (!on) return;
|
||
const port = parseInt($('examplesServerPort')?.value, 10) || 4173;
|
||
const url = `http://127.0.0.1:${port}/`;
|
||
panel.innerHTML = `
|
||
<div class="mono" style="color:var(--accent);margin-bottom:0.5rem">${escapeHtml(url)}</div>
|
||
<div class="btn-row">
|
||
<button type="button" class="btn btn-primary btn-sm" id="btnOpenExamples">Open</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="btnCopyExamplesUrl">Copy URL</button>
|
||
</div>`;
|
||
$('btnOpenExamples')?.addEventListener('click', () => chrome.tabs.create({ url }));
|
||
$('btnCopyExamplesUrl')?.addEventListener('click', async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
showToast('URL copied', 'ok');
|
||
} catch (_) {
|
||
showToast('Copy failed', 'err');
|
||
}
|
||
});
|
||
}
|
||
|
||
function collectSettings() {
|
||
const keysRaw = ($('defaultFirewallKeys')?.value || '')
|
||
.split(/\r?\n/)
|
||
.map((l) => l.trim())
|
||
.filter(Boolean);
|
||
const port = parseInt($('examplesServerPort')?.value, 10);
|
||
const refresh = parseInt($('dashboardRefreshMs')?.value, 10);
|
||
const reqTimeout = parseInt($('defaultRequestTimeoutMs')?.value, 10);
|
||
const readyTimeout = parseInt($('readyTimeoutMs')?.value, 10);
|
||
const capTimeout = parseInt($('defaultCapJobTimeoutMs')?.value, 10);
|
||
const maxPeers = parseInt($('defaultMaxPeers')?.value, 10);
|
||
|
||
if (isNaN(reqTimeout) || reqTimeout < 0 || reqTimeout > 300000) {
|
||
showToast('Request timeout must be 0–300000', 'err');
|
||
return null;
|
||
}
|
||
if (isNaN(readyTimeout) || readyTimeout < 0 || readyTimeout > 60000) {
|
||
showToast('Ready timeout must be 0–60000', 'err');
|
||
return null;
|
||
}
|
||
if (isNaN(capTimeout) || capTimeout < 1000 || capTimeout > 600000) {
|
||
showToast('Cap job timeout must be 1000–600000', 'err');
|
||
return null;
|
||
}
|
||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||
showToast('Examples port must be 1024–65535', 'err');
|
||
return null;
|
||
}
|
||
if (isNaN(refresh) || refresh < 1000 || refresh > 10000) {
|
||
showToast('Refresh interval must be 1000–10000', 'err');
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
defaultAppName: ($('defaultAppName')?.value || '').trim() || DEFAULT_SETTINGS.defaultAppName,
|
||
defaultMaxPeers: isNaN(maxPeers) || maxPeers < 0 ? 0 : maxPeers,
|
||
defaultRequestTimeoutMs: reqTimeout,
|
||
readyTimeoutMs: readyTimeout,
|
||
defaultCapJobTimeoutMs: capTimeout,
|
||
disableOnFileUrls: $('toggleDisableFileUrls')?.classList.contains('active') || false,
|
||
notifyOnDisconnect: $('toggleNotify')?.classList.contains('active') || false,
|
||
debug: $('toggleDebug')?.classList.contains('active') || false,
|
||
examplesServerEnabled: $('toggleExamplesServer')?.classList.contains('active') || false,
|
||
examplesServerPort: port,
|
||
defaultFirewallMode: $('defaultFirewallMode')?.value || 'off',
|
||
defaultFirewallKeys: keysRaw,
|
||
dashboardRefreshMs: refresh,
|
||
};
|
||
}
|
||
|
||
function saveSettings(quiet) {
|
||
const next = collectSettings();
|
||
if (!next) return;
|
||
const refreshChanged = next.dashboardRefreshMs !== settings.dashboardRefreshMs;
|
||
settings = next;
|
||
chrome.storage.local.set({ [SETTINGS_KEY]: settings }, () => {
|
||
if (chrome.runtime.lastError) {
|
||
showToast(chrome.runtime.lastError.message, 'err');
|
||
return;
|
||
}
|
||
if (!quiet) showToast('Saved', 'ok');
|
||
updateExamplesSettingsPanel();
|
||
if (refreshChanged) scheduleRefreshLoop();
|
||
// sync overview toggle
|
||
$('overviewExamplesToggle')?.classList.toggle('active', settings.examplesServerEnabled);
|
||
});
|
||
}
|
||
|
||
function scheduleSaveSettings() {
|
||
if (settingsLoading) return;
|
||
clearTimeout(settingsSaveTimer);
|
||
settingsSaveTimer = setTimeout(() => saveSettings(true), 280);
|
||
}
|
||
|
||
function loadSettings() {
|
||
settingsLoading = true;
|
||
chrome.storage.local.get(SETTINGS_KEY, (result) => {
|
||
settings = { ...DEFAULT_SETTINGS, ...(result[SETTINGS_KEY] || {}) };
|
||
settingsLoading = false;
|
||
renderSettingsForm();
|
||
scheduleRefreshLoop();
|
||
const ver = chrome.runtime.getManifest?.().version;
|
||
if (ver) {
|
||
if ($('extVersion')) $('extVersion').textContent = 'v' + ver;
|
||
if ($('aboutVersion')) $('aboutVersion').textContent = ver;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function destroySwarm(swarmId) {
|
||
if (!confirm(`Destroy swarm "${swarmId}"? This disconnects its peers.`)) return;
|
||
const res = await sendBg('destroySwarm', { swarmId });
|
||
if (res && res.ok !== false && !res.error) {
|
||
showToast('Swarm destroyed', 'ok');
|
||
refresh();
|
||
} else {
|
||
showToast(res?.error || 'Destroy failed', 'err');
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
const res = await fetchState();
|
||
if (res && res.ok && res.state) applyState(res.state);
|
||
}
|
||
|
||
function scheduleRefreshLoop() {
|
||
clearInterval(refreshTimer);
|
||
const ms = Math.min(10000, Math.max(1000, settings.dashboardRefreshMs || 2000));
|
||
refreshTimer = setInterval(() => refresh(), ms);
|
||
}
|
||
|
||
function goToPage(page) {
|
||
document.querySelectorAll('.nav-item').forEach((i) => i.classList.toggle('active', i.dataset.page === page));
|
||
document.querySelectorAll('.page').forEach((p) => p.classList.toggle('active', p.id === `page-${page}`));
|
||
if (page === 'activity') {
|
||
unseenErrors = 0;
|
||
updateErrorBadge();
|
||
}
|
||
if (location.hash !== '#' + page) {
|
||
history.replaceState(null, '', '#' + page);
|
||
}
|
||
}
|
||
|
||
function setupNavigation() {
|
||
document.querySelectorAll('.nav-item').forEach((item) => {
|
||
item.addEventListener('click', () => goToPage(item.dataset.page));
|
||
});
|
||
document.querySelectorAll('[data-goto]').forEach((btn) => {
|
||
btn.addEventListener('click', () => goToPage(btn.getAttribute('data-goto')));
|
||
});
|
||
// delegated goto / destroy / focus / copy
|
||
document.body.addEventListener('click', async (e) => {
|
||
const t = e.target.closest('[data-goto], [data-destroy], [data-focus-tab], [data-copy], [data-filter-id], .log-row');
|
||
if (!t) return;
|
||
if (t.hasAttribute('data-goto')) {
|
||
goToPage(t.getAttribute('data-goto'));
|
||
return;
|
||
}
|
||
if (t.hasAttribute('data-destroy')) {
|
||
destroySwarm(t.getAttribute('data-destroy'));
|
||
return;
|
||
}
|
||
if (t.hasAttribute('data-focus-tab')) {
|
||
const tabId = parseInt(t.getAttribute('data-focus-tab'), 10);
|
||
await sendBg('focusTab', { tabId });
|
||
return;
|
||
}
|
||
if (t.hasAttribute('data-copy')) {
|
||
try {
|
||
await navigator.clipboard.writeText(t.getAttribute('data-copy'));
|
||
showToast('Copied', 'ok');
|
||
} catch (_) {
|
||
showToast('Copy failed', 'err');
|
||
}
|
||
return;
|
||
}
|
||
if (t.hasAttribute('data-filter-id')) {
|
||
metaFilter = t.getAttribute('data-filter-id');
|
||
$('logSearch').value = metaFilter;
|
||
searchQuery = metaFilter;
|
||
goToPage('activity');
|
||
renderLogs();
|
||
return;
|
||
}
|
||
if (t.classList.contains('log-row')) {
|
||
t.classList.toggle('expanded');
|
||
}
|
||
});
|
||
}
|
||
|
||
function setupLogsUi() {
|
||
$('levelChips')?.addEventListener('click', (e) => {
|
||
const chip = e.target.closest('[data-level]');
|
||
if (!chip) return;
|
||
const level = chip.dataset.level;
|
||
if (levelFilter.has(level)) levelFilter.delete(level);
|
||
else levelFilter.add(level);
|
||
chip.classList.toggle('active');
|
||
renderLogs();
|
||
});
|
||
$('categoryChips')?.addEventListener('click', (e) => {
|
||
const chip = e.target.closest('[data-cat]');
|
||
if (!chip) return;
|
||
const cat = chip.dataset.cat;
|
||
if (categoryFilter.has(cat)) categoryFilter.delete(cat);
|
||
else categoryFilter.add(cat);
|
||
chip.classList.toggle('active');
|
||
renderLogs();
|
||
});
|
||
$('logSearch')?.addEventListener('input', (e) => {
|
||
searchQuery = e.target.value.trim();
|
||
metaFilter = null;
|
||
renderLogs();
|
||
});
|
||
$('btnPauseLogs')?.addEventListener('click', () => {
|
||
paused = !paused;
|
||
$('btnPauseLogs').textContent = paused ? 'Resume' : 'Pause';
|
||
if (!paused) sendBg('getLogs').then((r) => {
|
||
if (r?.logs) {
|
||
allLogs = r.logs;
|
||
renderLogs();
|
||
}
|
||
});
|
||
});
|
||
$('btnFollowLogs')?.addEventListener('click', () => {
|
||
follow = !follow;
|
||
$('btnFollowLogs').textContent = 'Follow: ' + (follow ? 'ON' : 'OFF');
|
||
if (follow) {
|
||
const c = $('logsContainer');
|
||
if (c) c.scrollTop = c.scrollHeight;
|
||
}
|
||
});
|
||
$('logsContainer')?.addEventListener('scroll', () => {
|
||
const c = $('logsContainer');
|
||
if (!c) return;
|
||
const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight < 40;
|
||
if (!atBottom && follow) {
|
||
follow = false;
|
||
$('btnFollowLogs').textContent = 'Follow: OFF';
|
||
}
|
||
});
|
||
$('btnClearLogs')?.addEventListener('click', async () => {
|
||
await sendBg('clearLogs');
|
||
allLogs = [];
|
||
renderLogs();
|
||
renderRecentActivity();
|
||
showToast('Logs cleared', 'ok');
|
||
});
|
||
$('btnExportJson')?.addEventListener('click', () => exportLogs('json'));
|
||
$('btnExportTxt')?.addEventListener('click', () => exportLogs('txt'));
|
||
}
|
||
|
||
function exportLogs(kind) {
|
||
const rows = filteredLogs();
|
||
let blob;
|
||
let name;
|
||
if (kind === 'json') {
|
||
blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
|
||
name = 'bridgeswarm-logs.json';
|
||
} else {
|
||
const text = rows
|
||
.map((e) => {
|
||
const ts = new Date(e.ts || e.timestamp).toISOString();
|
||
return `${ts}\t${e.level}\t${e.category}\t${e.message}`;
|
||
})
|
||
.join('\n');
|
||
blob = new Blob([text], { type: 'text/plain' });
|
||
name = 'bridgeswarm-logs.txt';
|
||
}
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
showToast('Exported ' + rows.length + ' events', 'ok');
|
||
}
|
||
|
||
function setupEvents() {
|
||
$('btnResetSettings')?.addEventListener('click', () => {
|
||
settings = { ...DEFAULT_SETTINGS };
|
||
chrome.storage.local.set({ [SETTINGS_KEY]: settings }, () => {
|
||
renderSettingsForm();
|
||
showToast('Reset to defaults', 'ok');
|
||
scheduleRefreshLoop();
|
||
});
|
||
});
|
||
|
||
$('overviewExamplesToggle')?.addEventListener('click', () => {
|
||
const t = $('overviewExamplesToggle');
|
||
t.classList.toggle('active');
|
||
const enabled = t.classList.contains('active');
|
||
const st = $('toggleExamplesServer');
|
||
if (st) st.classList.toggle('active', enabled);
|
||
settings.examplesServerEnabled = enabled;
|
||
chrome.storage.local.set({ [SETTINGS_KEY]: { ...settings, examplesServerEnabled: enabled } }, () => {
|
||
updateOverviewExamples(currentState || { examples: {}, hostConnected: !!currentState?.hostConnected });
|
||
updateExamplesSettingsPanel();
|
||
showToast(enabled ? 'Examples server enabled' : 'Examples server disabled', 'ok');
|
||
});
|
||
});
|
||
$('overviewExamplesToggle')?.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
e.currentTarget.click();
|
||
}
|
||
});
|
||
|
||
chrome.storage.onChanged.addListener((changes, area) => {
|
||
if (area !== 'local' || !changes[SETTINGS_KEY]) return;
|
||
settingsLoading = true;
|
||
settings = { ...DEFAULT_SETTINGS, ...(changes[SETTINGS_KEY].newValue || {}) };
|
||
renderSettingsForm();
|
||
settingsLoading = false;
|
||
if (currentState) updateOverviewExamples(currentState);
|
||
});
|
||
|
||
setupLogsUi();
|
||
}
|
||
|
||
async function init() {
|
||
const hash = (location.hash || '').replace('#', '');
|
||
setupNavigation();
|
||
setupEvents();
|
||
loadSettings();
|
||
connectDashboardPort();
|
||
const logsRes = await sendBg('getLogs');
|
||
if (logsRes?.logs) {
|
||
allLogs = logsRes.logs;
|
||
renderLogs();
|
||
renderRecentActivity();
|
||
}
|
||
await refresh();
|
||
if (hash && $(`page-${hash}`)) goToPage(hash);
|
||
}
|
||
|
||
init();
|