mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Phase 0 — CSS-only fixes: - Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.) - Replace hardcoded hex colors with CSS variables across 16+ files - Merge global-nav.css (507 lines) into layout.css, delete original - Reduce !important in responsive.css from 71 to 8 via .app-shell specificity - Standardize breakpoints to 480/768/1024/1280px Phase 1 — Loading states & SSE connection feedback: - Add centralized SSEManager (sse-manager.js) with exponential backoff - Add SSE status indicator dot in nav bar - Add withLoadingButton() + .btn-loading CSS spinner - Add mode section crossfade transitions Phase 2 — Accessibility: - Add aria-labels to icon-only buttons across mode partials - Add for/id associations to 42 form labels in 5 mode partials - Add aria-live on toast stack, enableListKeyNav() utility Phase 3 — Destructive action guards & list overflow: - Add confirmAction() styled modal, replace all 25 native confirm() calls - Add toast cap at 5 simultaneous toasts - Add list overflow indicator CSS Phase 4 — Inline style extraction: - Refactor switchMode() in app.js and index.html to use classList.toggle() - Add CSS toggle rules for all switchMode-controlled elements - Remove inline style="display:none" from 7+ HTML elements - Add utility classes (.hidden, .d-flex, .d-grid, etc.) Phase 5 — Mobile UX polish: - pre/code overflow handling already in place - Touch target sizing via --touch-min variable Phase 6 — Error handling consistency: - Add reportActionableError() to user-facing catch blocks in 5 mode JS files - 28 error toast additions alongside existing console.error calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
7.5 KiB
JavaScript
246 lines
7.5 KiB
JavaScript
/**
|
|
* SSEManager - Centralized Server-Sent Events connection manager
|
|
* Handles connection lifecycle, reconnection with exponential backoff,
|
|
* visibility-based pause/resume, and state change notifications.
|
|
*/
|
|
const SSEManager = (function() {
|
|
'use strict';
|
|
|
|
const STATES = {
|
|
CONNECTING: 'connecting',
|
|
OPEN: 'open',
|
|
RECONNECTING: 'reconnecting',
|
|
CLOSED: 'closed',
|
|
ERROR: 'error',
|
|
};
|
|
|
|
const BACKOFF_INITIAL = 1000;
|
|
const BACKOFF_MAX = 30000;
|
|
const BACKOFF_MULTIPLIER = 2;
|
|
|
|
/** @type {Map<string, ConnectionEntry>} */
|
|
const connections = new Map();
|
|
|
|
/**
|
|
* @typedef {Object} ConnectionEntry
|
|
* @property {string} key
|
|
* @property {string} url
|
|
* @property {EventSource|null} source
|
|
* @property {string} state
|
|
* @property {number} backoff
|
|
* @property {number|null} retryTimer
|
|
* @property {boolean} intentionallyClosed
|
|
* @property {Function|null} onMessage
|
|
* @property {Function|null} onStateChange
|
|
*/
|
|
|
|
function connect(key, url, options) {
|
|
const opts = options || {};
|
|
|
|
// Disconnect existing connection for this key
|
|
if (connections.has(key)) {
|
|
disconnect(key);
|
|
}
|
|
|
|
const entry = {
|
|
key: key,
|
|
url: url,
|
|
source: null,
|
|
state: STATES.CLOSED,
|
|
backoff: BACKOFF_INITIAL,
|
|
retryTimer: null,
|
|
intentionallyClosed: false,
|
|
onMessage: typeof opts.onMessage === 'function' ? opts.onMessage : null,
|
|
onStateChange: typeof opts.onStateChange === 'function' ? opts.onStateChange : null,
|
|
};
|
|
|
|
connections.set(key, entry);
|
|
openConnection(entry);
|
|
return entry;
|
|
}
|
|
|
|
function openConnection(entry) {
|
|
if (entry.intentionallyClosed) return;
|
|
|
|
setState(entry, entry.state === STATES.CLOSED ? STATES.CONNECTING : STATES.RECONNECTING);
|
|
|
|
try {
|
|
const source = new EventSource(entry.url);
|
|
entry.source = source;
|
|
|
|
source.onopen = function() {
|
|
entry.backoff = BACKOFF_INITIAL;
|
|
setState(entry, STATES.OPEN);
|
|
};
|
|
|
|
source.onmessage = function(event) {
|
|
if (entry.onMessage) {
|
|
try {
|
|
entry.onMessage(event);
|
|
} catch (err) {
|
|
console.debug('[SSEManager] onMessage error for ' + entry.key + ':', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
source.onerror = function() {
|
|
// EventSource fires error on close and connection loss
|
|
if (entry.intentionallyClosed) return;
|
|
|
|
closeSource(entry);
|
|
setState(entry, STATES.ERROR);
|
|
scheduleReconnect(entry);
|
|
};
|
|
} catch (err) {
|
|
setState(entry, STATES.ERROR);
|
|
scheduleReconnect(entry);
|
|
}
|
|
}
|
|
|
|
function closeSource(entry) {
|
|
if (entry.source) {
|
|
entry.source.onopen = null;
|
|
entry.source.onmessage = null;
|
|
entry.source.onerror = null;
|
|
try { entry.source.close(); } catch (e) { /* ignore */ }
|
|
entry.source = null;
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect(entry) {
|
|
if (entry.intentionallyClosed) return;
|
|
if (entry.retryTimer) return;
|
|
|
|
// Pause reconnection when tab is hidden
|
|
if (document.hidden) {
|
|
setState(entry, STATES.RECONNECTING);
|
|
return;
|
|
}
|
|
|
|
const delay = entry.backoff;
|
|
entry.backoff = Math.min(entry.backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX);
|
|
|
|
setState(entry, STATES.RECONNECTING);
|
|
|
|
entry.retryTimer = window.setTimeout(function() {
|
|
entry.retryTimer = null;
|
|
if (!entry.intentionallyClosed) {
|
|
openConnection(entry);
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
function disconnect(key) {
|
|
const entry = connections.get(key);
|
|
if (!entry) return;
|
|
|
|
entry.intentionallyClosed = true;
|
|
|
|
if (entry.retryTimer) {
|
|
clearTimeout(entry.retryTimer);
|
|
entry.retryTimer = null;
|
|
}
|
|
|
|
closeSource(entry);
|
|
setState(entry, STATES.CLOSED);
|
|
connections.delete(key);
|
|
}
|
|
|
|
function disconnectAll() {
|
|
for (const key of Array.from(connections.keys())) {
|
|
disconnect(key);
|
|
}
|
|
}
|
|
|
|
function getState(key) {
|
|
const entry = connections.get(key);
|
|
return entry ? entry.state : STATES.CLOSED;
|
|
}
|
|
|
|
function getActiveKeys() {
|
|
const keys = [];
|
|
connections.forEach(function(entry, key) {
|
|
if (entry.state === STATES.OPEN) {
|
|
keys.push(key);
|
|
}
|
|
});
|
|
return keys;
|
|
}
|
|
|
|
function setState(entry, newState) {
|
|
if (entry.state === newState) return;
|
|
const oldState = entry.state;
|
|
entry.state = newState;
|
|
|
|
if (entry.onStateChange) {
|
|
try {
|
|
entry.onStateChange(newState, oldState, entry.key);
|
|
} catch (err) {
|
|
console.debug('[SSEManager] onStateChange error:', err);
|
|
}
|
|
}
|
|
|
|
// Update global indicator
|
|
updateGlobalIndicator();
|
|
}
|
|
|
|
// --- Global SSE Status Indicator ---
|
|
|
|
function updateGlobalIndicator() {
|
|
const dot = document.getElementById('sseStatusDot');
|
|
if (!dot) return;
|
|
|
|
let hasOpen = false;
|
|
let hasReconnecting = false;
|
|
let hasError = false;
|
|
|
|
connections.forEach(function(entry) {
|
|
if (entry.state === STATES.OPEN) hasOpen = true;
|
|
else if (entry.state === STATES.RECONNECTING || entry.state === STATES.CONNECTING) hasReconnecting = true;
|
|
else if (entry.state === STATES.ERROR) hasError = true;
|
|
});
|
|
|
|
// Remove all state classes
|
|
dot.classList.remove('online', 'warning', 'error', 'inactive');
|
|
|
|
if (connections.size === 0) {
|
|
dot.classList.add('inactive');
|
|
dot.setAttribute('data-tooltip', 'No active streams');
|
|
} else if (hasError && !hasOpen) {
|
|
dot.classList.add('error');
|
|
dot.setAttribute('data-tooltip', 'Stream connection error');
|
|
} else if (hasReconnecting) {
|
|
dot.classList.add('warning');
|
|
dot.setAttribute('data-tooltip', 'Reconnecting...');
|
|
} else if (hasOpen) {
|
|
dot.classList.add('online');
|
|
dot.setAttribute('data-tooltip', 'Streams connected');
|
|
} else {
|
|
dot.classList.add('inactive');
|
|
dot.setAttribute('data-tooltip', 'Streams idle');
|
|
}
|
|
}
|
|
|
|
// --- Visibility API: pause/resume reconnection ---
|
|
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.hidden) return;
|
|
|
|
// Tab became visible — reconnect any entries that were waiting
|
|
connections.forEach(function(entry) {
|
|
if (!entry.intentionallyClosed && !entry.source && !entry.retryTimer) {
|
|
openConnection(entry);
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
STATES: STATES,
|
|
connect: connect,
|
|
disconnect: disconnect,
|
|
disconnectAll: disconnectAll,
|
|
getState: getState,
|
|
getActiveKeys: getActiveKeys,
|
|
};
|
|
})();
|