Tailscale Serve mapează /echo/* → 127.0.0.1:8088 (dashboard) și / → :18789 (alt backend). Browser-ul calling /api/ralph/status (relative cu absolute path la root domain) ajungea la 18789 care nu are endpoint Ralph → 502 Bad Gateway. Fix: toate cele 6 URL-uri (5x fetch + 1x EventSource) folosesc acum prefix /echo/api/ralph/* pentru a respecta routing-ul tailscale. Pattern consistent cu workspace.html și index.html (verificat manual). Endpoints atinse: /status, /<slug>/log, /<slug>/prd, /<slug>/stop, /<slug>/rollback, /stream (SSE). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
744 lines
27 KiB
HTML
744 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ro">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
|
|
<title>Echo · Ralph</title>
|
|
<link rel="stylesheet" href="/echo/common.css">
|
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
<script src="/echo/swipe-nav.js"></script>
|
|
<style>
|
|
/* ==========================================
|
|
Ralph status extension tokens
|
|
(existing common.css NU declară --status-*)
|
|
========================================== */
|
|
:root {
|
|
--status-running: rgb(34, 197, 94); /* green */
|
|
--status-blocked: rgb(245, 158, 11); /* amber */
|
|
--status-failed: rgb(239, 68, 68); /* red */
|
|
--status-complete: rgb(156, 163, 175); /* slate (done = neutral) */
|
|
--status-idle: var(--text-muted);
|
|
}
|
|
|
|
/* ==========================================
|
|
Layout
|
|
========================================== */
|
|
.main {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: var(--space-5);
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-3);
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
|
|
.page-title {
|
|
font-size: var(--text-xl);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.page-subtitle {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Live indicator pulse */
|
|
.live-indicator {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-muted);
|
|
padding: var(--space-1) var(--space-3);
|
|
background: var(--bg-surface);
|
|
border-radius: var(--radius-full);
|
|
}
|
|
|
|
.live-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--status-running);
|
|
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); }
|
|
}
|
|
|
|
.last-fetch {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* ==========================================
|
|
Cards grid
|
|
========================================== */
|
|
.ralph-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: var(--space-4);
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.ralph-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.ralph-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
.ralph-card {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--space-4);
|
|
min-height: 180px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
transition: border-color var(--transition-fast);
|
|
}
|
|
|
|
.ralph-card:hover {
|
|
border-color: var(--border-focus);
|
|
}
|
|
|
|
.ralph-card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.ralph-slug {
|
|
font-size: var(--text-base);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.ralph-status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
padding: 2px 10px;
|
|
font-size: var(--text-xs);
|
|
font-weight: 600;
|
|
border-radius: var(--radius-full);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.ralph-status[data-status="running"] { background: rgba(34, 197, 94, 0.18); color: var(--status-running); }
|
|
.ralph-status[data-status="blocked"] { background: rgba(245, 158, 11, 0.18); color: var(--status-blocked); }
|
|
.ralph-status[data-status="failed"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
|
|
.ralph-status[data-status="complete"] { background: rgba(156, 163, 175, 0.18); color: var(--status-complete); }
|
|
.ralph-status[data-status="idle"] { background: var(--bg-surface-active); color: var(--status-idle); }
|
|
.ralph-status[data-status="error"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
|
|
|
|
.ralph-status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
|
|
.ralph-card-body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.ralph-current {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.ralph-current-id {
|
|
font-family: var(--font-mono);
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.ralph-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
.ralph-tag {
|
|
font-size: var(--text-xs);
|
|
padding: 1px 8px;
|
|
background: var(--accent-subtle);
|
|
color: var(--accent);
|
|
border-radius: var(--radius-sm);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
/* Progress bar */
|
|
.ralph-progress {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
.ralph-progress-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: var(--text-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.ralph-progress-bar {
|
|
height: 6px;
|
|
background: var(--bg-surface-active);
|
|
border-radius: var(--radius-full);
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.ralph-progress-fill {
|
|
height: 100%;
|
|
background: var(--status-complete);
|
|
transition: width var(--transition-base);
|
|
}
|
|
|
|
.ralph-card[data-status="running"] .ralph-progress-fill { background: var(--status-running); }
|
|
.ralph-card[data-status="failed"] .ralph-progress-fill { background: var(--status-failed); }
|
|
.ralph-card[data-status="blocked"] .ralph-progress-fill { background: var(--status-blocked); }
|
|
|
|
.ralph-card-foot {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.ralph-actions {
|
|
display: flex;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
.ralph-icon-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
padding: 6px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 32px;
|
|
min-height: 32px;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
|
|
.ralph-icon-btn:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-surface-hover);
|
|
}
|
|
|
|
.ralph-icon-btn.danger {
|
|
color: var(--status-failed);
|
|
border-color: rgba(239, 68, 68, 0.4);
|
|
}
|
|
|
|
.ralph-icon-btn.danger:hover {
|
|
background: rgba(239, 68, 68, 0.12);
|
|
}
|
|
|
|
.ralph-icon-btn svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.ralph-icon-btn {
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
}
|
|
.ralph-icon-btn svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
}
|
|
|
|
/* Empty / loading / error states */
|
|
.ralph-empty,
|
|
.ralph-loading,
|
|
.ralph-error {
|
|
text-align: center;
|
|
padding: var(--space-10) var(--space-5);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.ralph-empty svg,
|
|
.ralph-loading svg,
|
|
.ralph-error svg {
|
|
width: 32px;
|
|
height: 32px;
|
|
margin-bottom: var(--space-3);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.ralph-empty-title {
|
|
font-size: var(--text-base);
|
|
color: var(--text-secondary);
|
|
margin-bottom: var(--space-1);
|
|
}
|
|
|
|
/* ==========================================
|
|
Drawer (log + PRD viewer)
|
|
========================================== */
|
|
.ralph-drawer {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
.ralph-drawer[data-open="true"] {
|
|
display: flex;
|
|
}
|
|
|
|
.ralph-drawer-content {
|
|
background: var(--bg-base);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
max-width: 900px;
|
|
width: 100%;
|
|
max-height: 85vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.ralph-drawer-head {
|
|
padding: var(--space-3) var(--space-4);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.ralph-drawer-title {
|
|
font-size: var(--text-base);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.ralph-drawer-body {
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
.ralph-drawer-pre {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!--NAV-->
|
|
|
|
<main class="main">
|
|
<header class="page-header">
|
|
<div>
|
|
<div class="page-title">
|
|
<i data-lucide="bot" aria-hidden="true"></i>
|
|
Echo · Ralph
|
|
</div>
|
|
<div class="page-subtitle">Live status pe proiectele autonome</div>
|
|
</div>
|
|
<div class="live-indicator" aria-live="polite" id="liveIndicator" data-mode="connecting">
|
|
<span class="live-dot" aria-hidden="true"></span>
|
|
<span id="liveLabel">Conectare…</span>
|
|
<span class="last-fetch" id="lastFetch"></span>
|
|
</div>
|
|
</header>
|
|
|
|
<section id="ralphContent" aria-live="polite">
|
|
<div class="ralph-loading">
|
|
<i data-lucide="loader" aria-hidden="true"></i>
|
|
<div>Se încarcă proiectele Ralph...</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- Drawer pentru log / PRD viewer -->
|
|
<div class="ralph-drawer" id="ralphDrawer" data-open="false" role="dialog" aria-modal="true" aria-labelledby="drawerTitle">
|
|
<div class="ralph-drawer-content">
|
|
<div class="ralph-drawer-head">
|
|
<div class="ralph-drawer-title" id="drawerTitle">—</div>
|
|
<button type="button" class="ralph-icon-btn" id="drawerClose" aria-label="Închide drawer">
|
|
<i data-lucide="x" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
<div class="ralph-drawer-body">
|
|
<pre class="ralph-drawer-pre" id="drawerBody"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const POLL_MS = 5000;
|
|
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();
|
|
if (isNaN(t)) return '—';
|
|
const diff = Math.max(0, Date.now() - t);
|
|
const sec = Math.floor(diff / 1000);
|
|
if (sec < 60) return `acum ${sec}s`;
|
|
const min = Math.floor(sec / 60);
|
|
if (min < 60) return `acum ${min}m`;
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 24) return `acum ${hr}h`;
|
|
const day = Math.floor(hr / 24);
|
|
return `acum ${day}z`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function renderCard(p) {
|
|
const total = p.storiesTotal || 0;
|
|
const done = p.storiesComplete || 0;
|
|
const failed = p.storiesFailed || 0;
|
|
const blocked = p.storiesBlocked || 0;
|
|
const pct = total > 0 ? Math.round(((done + failed + blocked) / total) * 100) : 0;
|
|
|
|
const current = p.currentStory
|
|
? `<div class="ralph-current"><span class="ralph-current-id">${escapeHtml(p.currentStory.id)}</span> · ${escapeHtml(p.currentStory.title || '')} ` +
|
|
(p.currentStory.retries ? `<span title="retries">(${p.currentStory.retries}/3)</span>` : '') + `</div>` +
|
|
(p.currentStory.tags && p.currentStory.tags.length
|
|
? `<div class="ralph-tags">${p.currentStory.tags.map(t => `<span class="ralph-tag">${escapeHtml(t)}</span>`).join('')}</div>`
|
|
: '')
|
|
: (p.status === 'complete'
|
|
? `<div class="ralph-current">Toate stories complete (${done}/${total}).</div>`
|
|
: `<div class="ralph-current" style="color:var(--text-muted)">Nu rulează acum.</div>`);
|
|
|
|
const eta = (p.etaMinutes != null && p.status === 'running')
|
|
? `~${p.etaMinutes}min`
|
|
: '';
|
|
|
|
const stopBtn = p.running
|
|
? `<button type="button" class="ralph-icon-btn danger" data-action="stop" data-slug="${escapeHtml(p.slug)}" aria-label="Oprește Ralph">
|
|
<i data-lucide="square" aria-hidden="true"></i>
|
|
</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">
|
|
<div class="ralph-slug" title="${escapeHtml(p.slug)}">${escapeHtml(p.slug)}</div>
|
|
<span class="ralph-status" data-status="${escapeHtml(p.status)}" aria-label="Status: ${escapeHtml(p.status)}">
|
|
<span class="ralph-status-dot" aria-hidden="true"></span>${escapeHtml(p.status)}
|
|
</span>
|
|
</header>
|
|
<div class="ralph-card-body">
|
|
${current}
|
|
<div class="ralph-progress">
|
|
<div class="ralph-progress-meta">
|
|
<span>${done}/${total} done${failed ? ` · ${failed} failed` : ''}${blocked ? ` · ${blocked} blocked` : ''}</span>
|
|
<span>${eta}</span>
|
|
</div>
|
|
<div class="ralph-progress-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
|
|
<div class="ralph-progress-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<footer class="ralph-card-foot">
|
|
<span title="Ultima iterație">${fmtAgo(p.lastIterAt)}</span>
|
|
<div class="ralph-actions">
|
|
<button type="button" class="ralph-icon-btn" data-action="log" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi log">
|
|
<i data-lucide="terminal" aria-hidden="true"></i>
|
|
</button>
|
|
<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>
|
|
</article>`;
|
|
}
|
|
|
|
function renderEmpty() {
|
|
return `
|
|
<div class="ralph-empty">
|
|
<i data-lucide="inbox" aria-hidden="true"></i>
|
|
<div class="ralph-empty-title">Niciun proiect aprobat.</div>
|
|
<div>Aprobă ceva pe Discord/Telegram cu <code>/a <slug></code>.</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderError(msg) {
|
|
return `
|
|
<div class="ralph-error">
|
|
<i data-lucide="alert-triangle" aria-hidden="true"></i>
|
|
<div>Cannot reach Echo Core: ${escapeHtml(msg)}</div>
|
|
</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('/echo/api/ralph/status', { cache: 'no-store' });
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
const data = await res.json();
|
|
renderSnapshot(data);
|
|
} catch (err) {
|
|
contentEl.innerHTML = renderError(err.message || String(err));
|
|
setMode('offline');
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
async function openLog(slug) {
|
|
drawerTitle.textContent = `${slug} · progress.txt`;
|
|
drawerBody.textContent = 'Se încarcă...';
|
|
drawer.dataset.open = 'true';
|
|
try {
|
|
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/log?lines=200`);
|
|
const data = await res.json();
|
|
drawerBody.textContent = (data.lines || []).join('\n');
|
|
} catch (err) {
|
|
drawerBody.textContent = `Error: ${err.message || err}`;
|
|
}
|
|
}
|
|
|
|
async function openPrd(slug) {
|
|
drawerTitle.textContent = `${slug} · prd.json`;
|
|
drawerBody.textContent = 'Se încarcă...';
|
|
drawer.dataset.open = 'true';
|
|
try {
|
|
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/prd`);
|
|
const data = await res.json();
|
|
drawerBody.textContent = JSON.stringify(data, null, 2);
|
|
} catch (err) {
|
|
drawerBody.textContent = `Error: ${err.message || err}`;
|
|
}
|
|
}
|
|
|
|
async function stopRalph(slug) {
|
|
if (!confirm(`Oprești Ralph pe ${slug}?`)) return;
|
|
try {
|
|
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/stop`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!data.success) {
|
|
alert('Eșec: ' + (data.error || 'unknown'));
|
|
} else {
|
|
fetchStatus();
|
|
}
|
|
} catch (err) {
|
|
alert('Eroare: ' + (err.message || err));
|
|
}
|
|
}
|
|
|
|
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(`/echo/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;
|
|
const slug = btn.dataset.slug;
|
|
const action = btn.dataset.action;
|
|
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', () => {
|
|
drawer.dataset.open = 'false';
|
|
});
|
|
|
|
drawer.addEventListener('click', (e) => {
|
|
if (e.target === drawer) drawer.dataset.open = 'false';
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') drawer.dataset.open = 'false';
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────
|
|
// 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('/echo/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();
|
|
startSSE();
|
|
if (window.lucide) lucide.createIcons();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|