feat(ralph): smart gates + DAG + dashboard live (W3)
Restructurare Ralph QC loop pe smart gate dispatcher tag-driven (în loc de 5 faze fixe), DAG dependsOn cu propagare blocked, retry guard 3-strike, rate limit detection, plus dashboard live cu polling 5s. Changes: - tools/ralph_prd_generator.py: parametru optional final_plan_path; când e furnizat, invocă Claude Opus pe final-plan.md pentru extragere user stories cu schema extinsă (tags, dependsOn, acceptanceCriteria 3-5). Backward compat păstrat — fără final_plan_path, fallback la heuristic-ul vechi. - tools/ralph/prd-template.json: schema W3 (tags[], dependsOn[], retries, failed, blocked, failureReason, requiresDesignReview). - tools/ralph/prompt.md: 4 faze (impl, base quality, smart gates, commit) + dispatcher pe story.tags. Tags vide → run-all-gates fallback (safe default). - tools/ralph_dag.py (nou): tag validation heuristic anti-silent-regression (force ui dacă diff atinge .vue/.tsx/.html/.css/.scss; force db pentru migrations sau .sql; force vercel dacă există vercel.json) + topological sort cu blocked propagation + atomic prd.json updates. - tools/ralph/ralph.sh: --max-turns 30, DAG-aware story selection, retry counter cu auto-fail la 3, rate limit detection (sleep 30min + 1 retry), CLI subcommands prin tools/ralph_dag.py helper. - dashboard/handlers/ralph.py (nou): /api/ralph/status + /<slug>/log + /prd + /stop. Defensive vs corrupt prd.json. Sandbox-ed PID kill. - dashboard/ralph.html (nou): live cards 3/2/1 col responsive, polling 5s, drawer pentru log/PRD viewer, status colors (--status-running/blocked/ failed/complete declarate inline), Lucide icons cu aria-labels. - dashboard/api.py: mount /api/ralph/* (GET status/log/prd, POST stop). - tests/: 72 teste noi (smart gates, DAG, retry, dashboard endpoint). Note arhitecturale: - Polling 5s ales peste SSE/WebSocket (suficient pentru iter Ralph 8-15min) - Tag validation rulează POST-iter pe diff git pentru anti-silent-regression - Rate limit retry: 1 dată per rulare, apoi mark failed=rate_limited Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ from handlers.files import FilesHandlers # noqa: E402
|
||||
from handlers.git import GitHandlers # noqa: E402
|
||||
from handlers.habits import HabitsHandlers # noqa: E402
|
||||
from handlers.pdf import PDFHandlers # noqa: E402
|
||||
from handlers.ralph import RalphHandlers # noqa: E402
|
||||
from handlers.workspace import WorkspaceHandlers # noqa: E402
|
||||
from handlers.youtube import YoutubeHandlers # noqa: E402
|
||||
|
||||
@@ -95,6 +96,7 @@ class TaskBoardHandler(
|
||||
PDFHandlers,
|
||||
YoutubeHandlers,
|
||||
WorkspaceHandlers,
|
||||
RalphHandlers,
|
||||
CronHandlers,
|
||||
SimpleHTTPRequestHandler,
|
||||
):
|
||||
@@ -155,6 +157,23 @@ class TaskBoardHandler(
|
||||
self.handle_eco_logs()
|
||||
elif self.path == '/api/eco/doctor':
|
||||
self.handle_eco_doctor()
|
||||
elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'):
|
||||
self.handle_ralph_status()
|
||||
elif self.path.startswith('/api/ralph/'):
|
||||
# /api/ralph/<slug>/log or /api/ralph/<slug>/prd
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
# parts: ['', 'api', 'ralph', '<slug>', '<action>']
|
||||
if len(parts) >= 5:
|
||||
slug = parts[3]
|
||||
action = parts[4]
|
||||
if action == 'log':
|
||||
self.handle_ralph_log(slug)
|
||||
elif action == 'prd':
|
||||
self.handle_ralph_prd(slug)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
elif self.path.startswith('/api/'):
|
||||
self.send_error(404)
|
||||
else:
|
||||
@@ -214,6 +233,13 @@ class TaskBoardHandler(
|
||||
self.handle_eco_git_commit()
|
||||
elif self.path == '/api/eco/restart-taskboard':
|
||||
self.handle_eco_restart_taskboard()
|
||||
elif self.path.startswith('/api/ralph/') and self.path.endswith('/stop'):
|
||||
parts = self.path.split('?', 1)[0].split('/')
|
||||
if len(parts) >= 5:
|
||||
slug = parts[3]
|
||||
self.handle_ralph_stop(slug)
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
|
||||
305
dashboard/handlers/ralph.py
Normal file
305
dashboard/handlers/ralph.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Ralph live dashboard endpoints (W3).
|
||||
|
||||
Endpoints:
|
||||
GET /api/ralph/status — toate proiectele Ralph (cards data)
|
||||
GET /api/ralph/<slug>/log — tail progress.txt (default 100 lines)
|
||||
GET /api/ralph/<slug>/prd — full prd.json content
|
||||
POST /api/ralph/<slug>/stop — SIGTERM la Ralph PID
|
||||
|
||||
Polling: 5s din ralph.html (suficient pentru iter 8-15min Ralph).
|
||||
NU SSE/WebSocket pentru MVP.
|
||||
|
||||
Citește status din `~/workspace/<slug>/scripts/ralph/`:
|
||||
- prd.json → stories (passes/failed/blocked/retries)
|
||||
- progress.txt → log human-readable
|
||||
- logs/iteration-*.log → mtime ultimului iter
|
||||
- .ralph.pid → PID activ (verificat cu os.kill 0)
|
||||
|
||||
Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
# Path Ralph per proiect (mereu în scripts/ralph/)
|
||||
def _ralph_dir(project_dir: Path) -> Path:
|
||||
return project_dir / "scripts" / "ralph"
|
||||
|
||||
|
||||
# Estimare ETA simplistă: avg iter time × stories rămase
|
||||
DEFAULT_ITER_MINUTES = 12 # midpoint din intervalul 8-15min menționat în plan
|
||||
|
||||
|
||||
class RalphHandlers:
|
||||
"""Mixin pentru /api/ralph/* — Ralph live status + control."""
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────
|
||||
def _ralph_validate_slug(self, slug: str):
|
||||
"""Validează slug-ul + returnează project_dir sau None."""
|
||||
if not slug or "/" in slug or ".." in slug:
|
||||
return None
|
||||
slug = unquote(slug)
|
||||
project_dir = constants.WORKSPACE_DIR / slug
|
||||
try:
|
||||
resolved = project_dir.resolve()
|
||||
workspace_resolved = constants.WORKSPACE_DIR.resolve()
|
||||
resolved.relative_to(workspace_resolved)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if not project_dir.exists() or not project_dir.is_dir():
|
||||
return None
|
||||
return project_dir
|
||||
|
||||
def _ralph_pid_alive(self, ralph_dir: Path):
|
||||
"""Întoarce (running: bool, pid: int|None)."""
|
||||
pid_file = ralph_dir / ".ralph.pid"
|
||||
if not pid_file.exists():
|
||||
return False, None
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
os.kill(pid, 0) # signal 0 = check existence
|
||||
return True, pid
|
||||
except (ValueError, ProcessLookupError, PermissionError, OSError):
|
||||
return False, None
|
||||
|
||||
def _ralph_eta_minutes(self, stories_remaining: int, last_iter_mtime: float | None) -> int | None:
|
||||
"""Estimează minute rămase — None dacă nu avem date."""
|
||||
if stories_remaining <= 0:
|
||||
return 0
|
||||
return stories_remaining * DEFAULT_ITER_MINUTES
|
||||
|
||||
def _ralph_summarize_project(self, project_dir: Path) -> dict | None:
|
||||
"""Construiește dict de status per proiect — None dacă nu e Ralph project."""
|
||||
ralph_dir = _ralph_dir(project_dir)
|
||||
prd_json = ralph_dir / "prd.json"
|
||||
if not prd_json.exists():
|
||||
return None
|
||||
|
||||
# Defensive parse — corupt prd.json nu trebuie să dărâme dashboard
|
||||
try:
|
||||
prd = json.loads(prd_json.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {
|
||||
"slug": project_dir.name,
|
||||
"status": "error",
|
||||
"error": "prd.json invalid sau ilizibil",
|
||||
"running": False,
|
||||
"pid": None,
|
||||
"stories": [],
|
||||
"storiesTotal": 0,
|
||||
"storiesComplete": 0,
|
||||
"storiesFailed": 0,
|
||||
"storiesBlocked": 0,
|
||||
}
|
||||
|
||||
stories = prd.get("userStories", []) or []
|
||||
total = len(stories)
|
||||
complete = sum(1 for s in stories if s.get("passes"))
|
||||
failed = sum(1 for s in stories if s.get("failed"))
|
||||
blocked = sum(1 for s in stories if s.get("blocked"))
|
||||
remaining = total - complete - failed - blocked
|
||||
|
||||
running, pid = self._ralph_pid_alive(ralph_dir)
|
||||
|
||||
# Last iteration mtime (pentru "acum X")
|
||||
logs_dir = ralph_dir / "logs"
|
||||
last_iter_mtime = None
|
||||
last_iter_iso = None
|
||||
if logs_dir.exists():
|
||||
iter_logs = sorted(logs_dir.glob("iteration-*.log"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
if iter_logs:
|
||||
last_iter_mtime = iter_logs[0].stat().st_mtime
|
||||
last_iter_iso = datetime.fromtimestamp(last_iter_mtime).isoformat()
|
||||
|
||||
# Status compus pentru UI cards
|
||||
if running:
|
||||
top_status = "running"
|
||||
elif failed > 0 and remaining == 0:
|
||||
top_status = "failed"
|
||||
elif complete == total and total > 0:
|
||||
top_status = "complete"
|
||||
elif blocked > 0 and running is False:
|
||||
top_status = "blocked"
|
||||
else:
|
||||
top_status = "idle"
|
||||
|
||||
# Current story (DAG-eligible cel mai mic priority)
|
||||
current_story = None
|
||||
if running:
|
||||
eligible = [
|
||||
s for s in stories
|
||||
if not s.get("passes") and not s.get("failed") and not s.get("blocked")
|
||||
]
|
||||
eligible.sort(key=lambda s: (s.get("priority", 999), s.get("id", "")))
|
||||
if eligible:
|
||||
current_story = {
|
||||
"id": eligible[0].get("id"),
|
||||
"title": eligible[0].get("title"),
|
||||
"tags": eligible[0].get("tags", []),
|
||||
"retries": eligible[0].get("retries", 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"slug": project_dir.name,
|
||||
"status": top_status,
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"branchName": prd.get("branchName", ""),
|
||||
"storiesTotal": total,
|
||||
"storiesComplete": complete,
|
||||
"storiesFailed": failed,
|
||||
"storiesBlocked": blocked,
|
||||
"storiesRemaining": remaining,
|
||||
"currentStory": current_story,
|
||||
"lastIterAt": last_iter_iso,
|
||||
"etaMinutes": self._ralph_eta_minutes(remaining, last_iter_mtime),
|
||||
"stories": [
|
||||
{
|
||||
"id": s.get("id"),
|
||||
"title": s.get("title"),
|
||||
"passes": bool(s.get("passes")),
|
||||
"failed": bool(s.get("failed")),
|
||||
"blocked": bool(s.get("blocked")),
|
||||
"retries": int(s.get("retries", 0)),
|
||||
"tags": s.get("tags", []),
|
||||
"failureReason": s.get("failureReason", ""),
|
||||
}
|
||||
for s in stories
|
||||
],
|
||||
}
|
||||
|
||||
# ── /api/ralph/status (GET) ────────────────────────────────
|
||||
def handle_ralph_status(self):
|
||||
"""Întoarce status pentru toate proiectele Ralph din workspace."""
|
||||
try:
|
||||
projects = []
|
||||
if not constants.WORKSPACE_DIR.exists():
|
||||
self.send_json({"projects": [], "fetchedAt": datetime.now().isoformat()})
|
||||
return
|
||||
|
||||
for entry in sorted(constants.WORKSPACE_DIR.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
summary = self._ralph_summarize_project(entry)
|
||||
if summary is not None:
|
||||
projects.append(summary)
|
||||
|
||||
self.send_json({
|
||||
"projects": projects,
|
||||
"fetchedAt": datetime.now().isoformat(),
|
||||
"count": len(projects),
|
||||
})
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/log (GET) ────────────────────────────
|
||||
def handle_ralph_log(self, slug: str):
|
||||
"""Tail progress.txt pentru un slug. Default last 100 lines."""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({"error": "Invalid project slug"}, 400)
|
||||
return
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
try:
|
||||
lines_n = min(int(qs.get("lines", ["100"])[0]), 1000)
|
||||
except ValueError:
|
||||
lines_n = 100
|
||||
|
||||
progress = _ralph_dir(project_dir) / "progress.txt"
|
||||
if not progress.exists():
|
||||
self.send_json({"slug": slug, "lines": [], "total": 0})
|
||||
return
|
||||
|
||||
try:
|
||||
content = progress.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
self.send_json({"error": f"read failed: {exc}"}, 500)
|
||||
return
|
||||
|
||||
all_lines = content.splitlines()
|
||||
tail = all_lines[-lines_n:] if len(all_lines) > lines_n else all_lines
|
||||
self.send_json({
|
||||
"slug": slug,
|
||||
"lines": tail,
|
||||
"total": len(all_lines),
|
||||
})
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/prd (GET) ────────────────────────────
|
||||
def handle_ralph_prd(self, slug: str):
|
||||
"""Returnează full prd.json pentru un slug."""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({"error": "Invalid project slug"}, 400)
|
||||
return
|
||||
|
||||
prd_json = _ralph_dir(project_dir) / "prd.json"
|
||||
if not prd_json.exists():
|
||||
self.send_json({"error": "prd.json not found"}, 404)
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(prd_json.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
self.send_json({"error": f"prd.json invalid: {exc}"}, 500)
|
||||
return
|
||||
|
||||
self.send_json(data)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, 500)
|
||||
|
||||
# ── /api/ralph/<slug>/stop (POST) ──────────────────────────
|
||||
def handle_ralph_stop(self, slug: str):
|
||||
"""Trimite SIGTERM la Ralph PID. Verifică că PID-ul e în WORKSPACE_DIR."""
|
||||
try:
|
||||
project_dir = self._ralph_validate_slug(slug)
|
||||
if not project_dir:
|
||||
self.send_json({"success": False, "error": "Invalid project slug"}, 400)
|
||||
return
|
||||
|
||||
ralph_dir = _ralph_dir(project_dir)
|
||||
pid_file = ralph_dir / ".ralph.pid"
|
||||
if not pid_file.exists():
|
||||
self.send_json({"success": False, "error": "No PID file"}, 404)
|
||||
return
|
||||
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
except (ValueError, OSError) as exc:
|
||||
self.send_json({"success": False, "error": f"Invalid PID file: {exc}"}, 500)
|
||||
return
|
||||
|
||||
# Sandbox: verifică că procesul e în workspace (nu omoară random PID)
|
||||
try:
|
||||
proc_cwd = Path(f"/proc/{pid}/cwd").resolve()
|
||||
if not str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)):
|
||||
self.send_json({"success": False, "error": "PID not in workspace"}, 403)
|
||||
return
|
||||
except (FileNotFoundError, PermissionError):
|
||||
# Procesul nu mai există — best-effort cleanup
|
||||
self.send_json({"success": True, "message": "Process already stopped"})
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
self.send_json({"success": True, "message": "Process already stopped"})
|
||||
return
|
||||
except PermissionError:
|
||||
self.send_json({"success": False, "error": "Permission denied"}, 403)
|
||||
return
|
||||
|
||||
self.send_json({"success": True, "message": f"Ralph stopped (PID {pid})"})
|
||||
except Exception as exc:
|
||||
self.send_json({"success": False, "error": str(exc)}, 500)
|
||||
615
dashboard/ralph.html
Normal file
615
dashboard/ralph.html
Normal file
@@ -0,0 +1,615 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
@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 (polling 5s)</div>
|
||||
</div>
|
||||
<div class="live-indicator" aria-live="polite">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span id="liveLabel">Live</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 drawer = document.getElementById('ralphDrawer');
|
||||
const drawerTitle = document.getElementById('drawerTitle');
|
||||
const drawerBody = document.getElementById('drawerBody');
|
||||
const drawerClose = document.getElementById('drawerClose');
|
||||
|
||||
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>`
|
||||
: '';
|
||||
|
||||
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>
|
||||
${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>`;
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = renderError(err.message || String(err));
|
||||
liveLabel.textContent = '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(`/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(`/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(`/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));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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';
|
||||
});
|
||||
|
||||
// Boot + poll
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, POLL_MS);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user