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:
2026-04-26 19:07:13 +00:00
parent dedeedf024
commit ff9b9a0d1d
4 changed files with 647 additions and 39 deletions

View File

@@ -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>