feat(dashboard): SSE realtime + story rollback button
Replaces 5s polling on /echo/ralph.html with EventSource streaming and adds
a rollback control for the running Ralph cards.
Server (dashboard/handlers/ralph.py):
- /api/ralph/stream — Server-Sent Events. Emits `event: status` whenever a
signature over the projects' state changes (poll filesystem at 2s); emits
`event: heartbeat` every 30s to keep proxies happy. Disables proxy
buffering via X-Accel-Buffering:no.
- /api/ralph/<slug>/rollback (POST) — runs `git revert --no-edit HEAD` in
the project; falls back to `git reset --hard HEAD~1` only if revert
reports conflict. After rolling back the commit, decrements `passes` on
the last user story marked complete in prd.json (atomic temp+rename
write, same pattern as ralph_dag.py). Returns
`{success, message, reverted_commit, story_reverted, method}`.
- _ralph_validate_slug tightened to a strict regex (alphanum + dash +
underscore, ≤64 chars) plus explicit ../, /, \ rejection. All previously
accepted slugs still pass; URL-encoded traversal and shell metachars
now blocked before the filesystem is touched.
- _ralph_collect_status / _ralph_signature factored out of
handle_ralph_status so the SSE loop can reuse them and detect changes
cheaply.
Server (dashboard/api.py):
- HTTPServer → ThreadingHTTPServer with daemon_threads=True. SSE is a
long-lived response; without threading a single client would block all
other dashboard endpoints.
- /api/ralph/stream (GET) and /api/ralph/<slug>/rollback (POST) wired
into the dispatch.
Client (dashboard/ralph.html):
- EventSource('/api/ralph/stream') with permanent fallback to 5s polling
when readyState=CLOSED (no server, CORS blocked, browser without SSE).
- Indicator badge: 🟢 Live (SSE), ⏱ Polling (fallback), Offline.
- Rollback button (undo-2 icon) on running cards; native confirm() with
message: "Asta va da git revert HEAD pe <slug> și va decrementa ultima
story trecută. Continui?"
Tests (tests/test_dashboard_ralph_endpoint.py, +20 cases):
- Strict slug validator: underscore allowed, >64 rejected, special chars
/ backslash / URL-encoded traversal rejected.
- _ralph_collect_status + _ralph_signature: stable when nothing changes,
flips when project added or `passes` toggles.
- Rollback: invalid slug → 400, non-git project → 400, real two-commit
repo revert succeeds and decrements last passing story (US-002 goes
passes:false while US-001 stays passes:true), no-passing-stories case
succeeds with story_reverted=None, response shape contract, atomic
helper leaves no .tmp file behind.
- API routing smoke: confirms ThreadingHTTPServer + stream + rollback
references present in dashboard/api.py.
39/39 tests pass on tests/test_dashboard_ralph_endpoint.py. Pre-existing
failures in test_dashboard_constants.py::test_base_dir_is_echo_core (the
worktree dir is `echo-core-realtime`, not `echo-core`) and
test_dashboard_unified_index.py::test_index_has_all_panels are unrelated
to this change and reproduced on master.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,19 @@
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Indicator state: live (SSE) vs polling (fallback) vs offline */
|
||||
.live-indicator[data-mode="polling"] .live-dot {
|
||||
background: var(--status-blocked);
|
||||
animation: none;
|
||||
}
|
||||
.live-indicator[data-mode="offline"] .live-dot {
|
||||
background: var(--status-failed);
|
||||
animation: none;
|
||||
}
|
||||
.live-indicator[data-mode="connecting"] .live-dot {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
@@ -378,11 +391,11 @@
|
||||
<i data-lucide="bot" aria-hidden="true"></i>
|
||||
Echo · Ralph
|
||||
</div>
|
||||
<div class="page-subtitle">Live status pe proiectele autonome (polling 5s)</div>
|
||||
<div class="page-subtitle">Live status pe proiectele autonome</div>
|
||||
</div>
|
||||
<div class="live-indicator" aria-live="polite">
|
||||
<div class="live-indicator" aria-live="polite" id="liveIndicator" data-mode="connecting">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span id="liveLabel">Live</span>
|
||||
<span id="liveLabel">Conectare…</span>
|
||||
<span class="last-fetch" id="lastFetch"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -416,11 +429,24 @@
|
||||
const contentEl = document.getElementById('ralphContent');
|
||||
const lastFetchEl = document.getElementById('lastFetch');
|
||||
const liveLabel = document.getElementById('liveLabel');
|
||||
const liveIndicator = document.getElementById('liveIndicator');
|
||||
const drawer = document.getElementById('ralphDrawer');
|
||||
const drawerTitle = document.getElementById('drawerTitle');
|
||||
const drawerBody = document.getElementById('drawerBody');
|
||||
const drawerClose = document.getElementById('drawerClose');
|
||||
|
||||
// Connection mode: 'connecting' → 'live' (SSE) | 'polling' (fallback) | 'offline'
|
||||
function setMode(mode) {
|
||||
liveIndicator.dataset.mode = mode;
|
||||
const labels = {
|
||||
connecting: 'Conectare…',
|
||||
live: '🟢 Live',
|
||||
polling: '⏱ Polling',
|
||||
offline: 'Offline',
|
||||
};
|
||||
liveLabel.textContent = labels[mode] || mode;
|
||||
}
|
||||
|
||||
function fmtAgo(iso) {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
@@ -469,6 +495,14 @@
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
// Rollback: vizibil pe card-uri running (corectează ultima iteraţie
|
||||
// dacă Ralph a marcat passes prematur). Confirm dialog la click.
|
||||
const rollbackBtn = p.running
|
||||
? `<button type="button" class="ralph-icon-btn" data-action="rollback" data-slug="${escapeHtml(p.slug)}" aria-label="Rollback ultima iteraţie" title="Rollback ultima iteraţie (git revert HEAD)">
|
||||
<i data-lucide="undo-2" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="ralph-card" data-status="${escapeHtml(p.status)}">
|
||||
<header class="ralph-card-head">
|
||||
@@ -498,6 +532,7 @@
|
||||
<button type="button" class="ralph-icon-btn" data-action="prd" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi PRD">
|
||||
<i data-lucide="file-text" aria-hidden="true"></i>
|
||||
</button>
|
||||
${rollbackBtn}
|
||||
${stopBtn}
|
||||
</div>
|
||||
</footer>
|
||||
@@ -521,23 +556,26 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSnapshot(data) {
|
||||
const projects = data.projects || [];
|
||||
if (projects.length === 0) {
|
||||
contentEl.innerHTML = renderEmpty();
|
||||
} else {
|
||||
contentEl.innerHTML = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
|
||||
}
|
||||
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/ralph/status', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
const projects = data.projects || [];
|
||||
if (projects.length === 0) {
|
||||
contentEl.innerHTML = renderEmpty();
|
||||
} else {
|
||||
contentEl.innerHTML = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
|
||||
}
|
||||
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
|
||||
liveLabel.textContent = 'Live';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
renderSnapshot(data);
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = renderError(err.message || String(err));
|
||||
liveLabel.textContent = 'Offline';
|
||||
setMode('offline');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
@@ -583,6 +621,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackRalph(slug) {
|
||||
if (!confirm(`Asta va da git revert HEAD pe ${slug} și va decrementa ultima story trecută. Continui?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/ralph/${encodeURIComponent(slug)}/rollback`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Rollback eşuat: ' + (data.message || 'unknown'));
|
||||
} else {
|
||||
alert('✓ ' + (data.message || 'Rollback OK'));
|
||||
fetchStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare rollback: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
@@ -591,6 +645,7 @@
|
||||
if (action === 'log') openLog(slug);
|
||||
else if (action === 'prd') openPrd(slug);
|
||||
else if (action === 'stop') stopRalph(slug);
|
||||
else if (action === 'rollback') rollbackRalph(slug);
|
||||
});
|
||||
|
||||
drawerClose.addEventListener('click', () => {
|
||||
@@ -605,9 +660,82 @@
|
||||
if (e.key === 'Escape') drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
// Boot + poll
|
||||
// ────────────────────────────────────────────────────────
|
||||
// Connection: try SSE first; fallback to polling on error.
|
||||
// ────────────────────────────────────────────────────────
|
||||
let eventSource = null;
|
||||
let pollHandle = null;
|
||||
|
||||
function startPolling() {
|
||||
if (pollHandle) return;
|
||||
setMode('polling');
|
||||
fetchStatus();
|
||||
pollHandle = setInterval(fetchStatus, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startSSE() {
|
||||
if (typeof EventSource === 'undefined') {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
eventSource = new EventSource('/api/ralph/stream');
|
||||
} catch (err) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Server-confirmed open — switch to live mode
|
||||
eventSource.addEventListener('open', () => {
|
||||
stopPolling();
|
||||
setMode('live');
|
||||
});
|
||||
|
||||
eventSource.addEventListener('status', (ev) => {
|
||||
stopPolling();
|
||||
setMode('live');
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
renderSnapshot(data);
|
||||
} catch (err) {
|
||||
// malformed payload — ignore, next event will reconcile
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Keep-alive; nothing to render but it confirms the link.
|
||||
if (liveIndicator.dataset.mode !== 'live') setMode('live');
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', () => {
|
||||
// EventSource auto-reconnect kicks in by default. If the
|
||||
// endpoint never responds (404/500/CORS), readyState=CLOSED
|
||||
// and we fall back permanently to polling.
|
||||
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
||||
eventSource = null;
|
||||
startPolling();
|
||||
} else {
|
||||
// Transient — show polling state until reconnect succeeds
|
||||
setMode('polling');
|
||||
if (!pollHandle) {
|
||||
// Don't double-fetch; SSE reconnect should resume soon
|
||||
fetchStatus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial paint via fetch (so first frame renders even if SSE handshake
|
||||
// takes a beat); SSE will then take over for live updates.
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, POLL_MS);
|
||||
startSSE();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user