feat(dashboard): unified workspace hub — cookie auth, 9-state projects, planning chat

Merges workspace.html + ralph.html into a single unified project hub with:
- Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict)
- 9-state project badge system (running-ralph/manual, planning, approved,
  pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix
- SSE realtime + polling fallback, version-based optimistic concurrency (If-Match)
- Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume)
- Propose modal (Variant B: inline Plan-with-Echo checkbox)
- 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar)
- Inter font self-hosted + shared tokens.css design system + DESIGN.md
- src/jsonlock.py (flock helper, sidecar .lock for stable inode)
- src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh)
- 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28
- No emoji anywhere (enforced by test_dashboard_no_emoji.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 07:26:19 +00:00
parent e771479d67
commit 5e930ade02
26 changed files with 5700 additions and 1569 deletions

317
dashboard/DESIGN.md Normal file
View File

@@ -0,0 +1,317 @@
# Echo Dashboard — Design System
This document is the source of truth for visual decisions across the Echo
Dashboard (port 8088, served at `/echo/`). Tokens live in
`dashboard/static/tokens.css`. Page-level CSS is in `common.css` and per-page
`<style>` blocks. **Pages must include `tokens.css` before `common.css`.**
---
## Theme
- **Default**: dark. Background `--bg-base: #13131a` (near-black neutral).
- **Light theme**: opt-in via `<html data-theme="light">`. Light tokens override
the dark palette in the same `:root`-equivalent block.
- **Toggle**: header `.theme-toggle` button — persisted in `localStorage`.
Surfaces are translucent overlays on `--bg-base`, never solid greys, so
elevation reads consistently against future backgrounds.
---
## Color tokens
### Surfaces (dark)
| Token | Value | Use |
|---|---|---|
| `--bg-base` | `#13131a` | App background |
| `--bg-surface` | `rgba(255,255,255,0.12)` | Cards, panels, inputs |
| `--bg-surface-hover` | `rgba(255,255,255,0.16)` | Hover state on surfaces |
| `--bg-surface-active` | `rgba(255,255,255,0.20)` | Pressed / active surfaces |
| `--bg-elevated` | `rgba(255,255,255,0.14)` | Selects, popovers |
| `--header-bg` | `rgba(19,19,26,0.95)` | Sticky header backdrop |
### Text
| Token | Value | Use |
|---|---|---|
| `--text-primary` | `#ffffff` | Headings, key labels |
| `--text-secondary` | `#f5f5f5` | Body copy |
| `--text-muted` | `#e5e5e5` | Meta, timestamps, captions |
### Accent + borders
| Token | Value | Use |
|---|---|---|
| `--accent` | `#3b82f6` | Primary buttons, focus, links |
| `--accent-hover` | `#2563eb` | Hover on `--accent` |
| `--accent-subtle` | `rgba(59,130,246,0.2)` | Active nav background |
| `--border` | `rgba(255,255,255,0.3)` | Card / input outline |
| `--border-focus` | `rgba(59,130,246,0.7)` | Card hover, input focus |
### Semantic state
| Token | Value | Meaning |
|---|---|---|
| `--success` | `#22c55e` | OK, saved, healthy |
| `--warning` | `#eab308` | Caution, soft fail |
| `--error` | `#ef4444` | Hard fail, destructive |
### Status palette (workflow states)
These drive the `.status-pill[data-status]` system on workspace cards.
| Token | Value | State name |
|---|---|---|
| `--status-running` | `rgb(34, 197, 94)` | `running-ralph`, `running-manual` |
| `--status-blocked` | `rgb(245, 158, 11)` | `blocked` |
| `--status-failed` | `rgb(239, 68, 68)` | `failed` |
| `--status-complete` | `rgb(156, 163, 175)` | `complete` |
| `--status-idle` | `var(--text-muted)` | `idle` |
| `--status-planning` | `rgb(167, 139, 250)` | `planning` *(new)* |
| `--status-pending` | `rgb(96, 165, 250)` | `pending` *(new)* |
| `--status-approved` | `rgb(234, 179, 8)` | `approved` *(new)* |
---
## Typography
- **Sans**: `'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`
— self-hosted woff2 at `/echo/static/fonts/inter-{400,500,600,700}.woff2`.
- **Mono**: `'JetBrains Mono', 'Fira Code', ui-monospace, monospace` — for
logs, code blocks, slugs, IDs. Loaded by browser if present (not bundled).
### Size scale
| Token | rem | px @ 16px |
|---|---|---|
| `--text-xs` | 0.75 | 12 |
| `--text-sm` | 0.875 | 14 |
| `--text-base` | 1 | 16 |
| `--text-lg` | 1.125 | 18 |
| `--text-xl` | 1.25 | 20 |
### Weights
400 (body), 500 (medium emphasis), 600 (headings, button labels),
700 (rare — page titles only). No 800/900.
---
## Spacing — 8px grid
All padding, margin, and gap values use these tokens. No hard-coded pixels.
| Token | px |
|---|---|
| `--space-1` | 4 |
| `--space-2` | 8 |
| `--space-3` | 12 |
| `--space-4` | 16 |
| `--space-5` | 20 |
| `--space-6` | 24 |
| `--space-8` | 32 |
| `--space-10` | 40 |
---
## Border radius
| Token | px | Use |
|---|---|---|
| `--radius-sm` | 4 | Tags, micro-pills |
| `--radius-md` | 8 | Buttons, inputs |
| `--radius-lg` | 12 | Cards, modals, panels |
| `--radius-full` | 9999 | Status pills, badges, avatars |
---
## Buttons
All buttons share `.btn` (8px radius, 14px font, 8/16 padding,
`--transition-fast`).
| Variant | Class | Surface | Text | Use |
|---|---|---|---|---|
| Primary | `.btn-primary` | `--accent` | white | The one CTA per row |
| Secondary | `.btn-secondary` | `--bg-surface` + `--border` | `--text-secondary` | Side actions |
| Ghost | `.btn-ghost` | transparent | `--text-secondary` | Tertiary, destructive-soft |
| Danger | `.btn-danger` | `--error` | white | Stop, delete, irreversible |
Disabled state: `opacity: 0.5; cursor: not-allowed;`. Never grey out by
swapping colors — keep variant identity.
---
## Card component (`.project-card`)
- `border-radius: var(--radius-lg)` (12px)
- `background: var(--bg-surface)`
- `border: 1px solid var(--border)`
- `padding: var(--space-5)`
- `transition: border-color var(--transition-base)`
- **Hover**: `border-color: var(--border-focus)` (blue glow). No surface
brightening — border-only hover keeps the grid calm.
---
## Status pill system
A `.status-pill` is a `--radius-full` chip placed on every project card. It
encodes the current workflow state via `data-status="<state>"`.
### Visual recipe
- **Background**: state color at **18% alpha** (`color-mix(in srgb, var(--status-X) 18%, transparent)` or precomputed `rgba(...)`).
- **Text**: solid state color (full alpha).
- **Border**: 1px state color at 30% alpha.
- **Padding**: `var(--space-1) var(--space-3)` — slim.
- **Font**: `var(--text-xs)`, weight 500.
### Pulse-dot
Active states render a 6px CSS-shape circle that pulses (no SVG, no emoji).
```css
.status-pill::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: currentColor; margin-right: var(--space-2);
}
.status-pill[data-status="running-ralph"]::before,
.status-pill[data-status="running-manual"]::before,
.status-pill[data-status="planning"]::before {
animation: pulse-dot 1.6s ease-in-out infinite;
}
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
```
### State matrix
| `data-status` | Color token | Pulse | Label |
|---|---|---|---|
| `running-ralph` | `--status-running` | yes | Ralph running |
| `running-manual` | `--status-running` | yes | Manual run |
| `planning` | `--status-planning` | yes | Planning |
| `approved` | `--status-approved` | no | Approved |
| `pending` | `--status-pending` | no | Pending |
| `blocked` | `--status-blocked` | no | Blocked |
| `failed` | `--status-failed` | no | Failed |
| `complete` | `--status-complete` | no | Complete |
| `idle` | `--status-idle` | no | Idle |
---
## BUTTONS_FOR_STATE matrix
Each project card surfaces ≤3 actions, ordered Primary / Secondary / Ghost.
The renderer picks the row matching `data-status`.
| State | Primary | Secondary | Ghost |
|---|---|---|---|
| `running-ralph` | Stop Ralph (danger) | Logs | PRD |
| `running-manual` | Stop (danger) | Open server | Logs |
| `planning` | Continue chat | — | Cancel |
| `approved` | — | Unapprove | Plan |
| `pending` | Approve | Plan with Echo | Cancel |
| `blocked` | View logs | Resume | — |
| `failed` | View logs | Retry | Rollback |
| `complete` | View plan | — | Run again |
| `idle` | Run Ralph | — | Delete |
Rules:
- **Stop / Delete** are always `.btn-danger`, never primary blue.
- A dash (`—`) means render nothing (no placeholder, no greyed-out slot).
- The Primary slot is the default action when the card is keyboard-focused
and Enter is pressed.
---
## Toast taxonomy
Toasts appear top-right, stack vertically, dismiss after 4s (errors: 8s).
**Five types**, distinguished by a 3px colored left bar — no emoji, no icon
fill. Body uses `--text-primary` on `--bg-surface`.
| Type | Bar color | Use |
|---|---|---|
| `success` | `--success` | Saved, approved, deployed |
| `info` | `--accent` | Neutral confirmation |
| `warning` | `--warning` | Soft fail, retried |
| `busy` | `--status-planning` | Long-running op started |
| `error` | `--error` | Hard fail, action required |
Toast renderer is shared across pages and reads from a single global
`window.showToast(type, msg)` helper.
---
## SSE indicator
Top-right of pages with a live stream (workspace, ralph). Three states
indicated via a CSS-shape pulse-dot — never an emoji.
| State | Dot color | Label | Pulse |
|---|---|---|---|
| Live | `--success` | "Live" | yes |
| Polling | `--warning` | "Polling" | no |
| Offline | `--error` | "Offline" | no |
Uses the same `.pulse-dot` 6px CSS shape as `.status-pill::before`. The dot
sits before the label, both inside a tiny `.sse-indicator` chip on
`--bg-surface`.
---
## Modal pattern
Used for the planning chat, PRD viewer, log tail, propose-feature form.
- **Overlay**: full viewport, `background: rgba(0,0,0,0.6)`,
`backdrop-filter: blur(4px)`, `display: flex` centered.
- **Container** (`.modal`): `--radius-lg`, `--bg-base`, `--border`,
max-width 720px, max-height 80vh, scroll on overflow.
- **Header / Footer**: 1px border separators using `--border`.
- **Focus trap**: first focusable element gets focus on open; Tab cycles
inside the modal.
- **ESC**: closes — but if the modal has unsaved input, prompt
"Discard changes?" before closing. Click on overlay = same behavior.
- **Mobile (≤640px)**: full-screen takeover. Header / footer stick; body
scrolls. Implemented in `tokens.css` via the shared `@media (max-width:640px)`
block.
---
## No-emoji rule
**No emoji anywhere in the dashboard.** This is a hard rule, not a
preference.
- Buttons are **text-only**. No leading/trailing emoji decoration.
- Status indicators use **CSS-shape colored dots** (`.pulse-dot`,
`.status-pill::before`) — never `🟢 ⏱ 🛑 ✅` etc.
- The login monogram is the **letter `E`** rendered in Inter 700 inside a
square with `--accent` background. Not an emoji, not an SVG logo.
- Where icons are needed (nav, action buttons), use **Lucide-style stroke
SVGs inlined** — `stroke: currentColor`, `fill: none`, `stroke-width: 2`,
`stroke-linecap: round`, `stroke-linejoin: round`. Never use emoji as a
substitute for an icon.
This rule keeps the UI legible across themes, scales correctly at all sizes,
and avoids OS-dependent rendering (Apple, Twemoji, Noto all draw the same
emoji differently).
---
## Pages that include this system
Every dashboard page (`index.html`, `workspace.html`, `ralph.html`, `notes.html`,
`habits.html`, `files.html`, `login.html`) **must** include in `<head>`:
```html
<link rel="stylesheet" href="/echo/static/tokens.css">
<link rel="stylesheet" href="/echo/common.css">
```
In that order — tokens first so `common.css` and per-page styles can resolve
the variables.

View File

@@ -6,6 +6,7 @@ responsible only for URL dispatch, CORS, JSON response helpers, and
server bootstrap.
"""
import json
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -34,12 +35,14 @@ from constants import ( # noqa: E402 re-exported for tests
VENV_PYTHON,
WORKSPACE_DIR,
)
from handlers.auth import AuthHandlers # noqa: E402
from handlers.cron import CronHandlers # noqa: E402
from handlers.eco import EcoHandlers # noqa: E402
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.projects import ProjectsHandlers # noqa: E402
from handlers.ralph import RalphHandlers # noqa: E402
from handlers.workspace import WorkspaceHandlers # noqa: E402
from handlers.youtube import YoutubeHandlers # noqa: E402
@@ -59,10 +62,6 @@ NAV_HTML = '''<header class="header">
<i data-lucide="code"></i>
<span>Workspace</span>
</a>
<a href="/echo/ralph.html" class="nav-item" data-page="ralph">
<i data-lucide="bot"></i>
<span>Ralph</span>
</a>
<a href="/echo/notes.html" class="nav-item" data-page="notes">
<i data-lucide="file-text"></i>
<span>KB</span>
@@ -93,6 +92,8 @@ NAV_HTML = '''<header class="header">
class TaskBoardHandler(
AuthHandlers,
ProjectsHandlers,
GitHandlers,
HabitsHandlers,
EcoHandlers,
@@ -133,6 +134,36 @@ class TaskBoardHandler(
# ── dispatch ────────────────────────────────────────────────
def do_GET(self):
from datetime import datetime as _dt
import os
# Static assets — served directly from dashboard/static/. Handles the
# case where the URL is hit with the /echo/ prefix intact (e.g. direct
# localhost curl); when behind the reverse proxy that strips /echo/,
# the request falls through to SimpleHTTPRequestHandler which serves
# cwd/static/ naturally (cwd is set to KANBAN_DIR/dashboard).
if self.path.startswith('/echo/static/'):
rel = self.path[len('/echo/static/'):].split('?', 1)[0]
file_path = os.path.join(os.path.dirname(__file__), 'static', rel)
if os.path.isfile(file_path):
ext = os.path.splitext(rel)[1].lstrip('.').lower()
ctype = {
'css': 'text/css',
'woff2': 'font/woff2',
'woff': 'font/woff',
'js': 'application/javascript',
'svg': 'image/svg+xml',
'png': 'image/png',
}.get(ext, 'application/octet-stream')
with open(file_path, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'public, max-age=86400')
self.end_headers()
self.wfile.write(data)
else:
self.send_error(404)
return
if self.path == '/api/status':
self.send_json({'status': 'ok', 'time': _dt.now().isoformat()})
elif self.path == '/api/git' or self.path.startswith('/api/git?'):
@@ -182,6 +213,55 @@ class TaskBoardHandler(
self.send_error(404)
else:
self.send_error(404)
elif self.path == '/api/projects' or self.path.startswith('/api/projects?'):
self.handle_unified_status()
elif self.path == '/api/projects/signature' or self.path.startswith('/api/projects/signature?'):
self.handle_unified_signature()
elif self.path == '/api/projects/stream' or self.path.startswith('/api/projects/stream?'):
self.handle_projects_stream()
elif self.path.startswith('/api/projects/'):
# /api/projects/<slug>/plan/(state|transcript)
parts = self.path.split('?', 1)[0].split('/')
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
if len(parts) >= 6 and parts[4] == 'plan':
slug = parts[3]
action = parts[5]
if action == 'state':
self.handle_plan_state(slug)
elif action == 'transcript':
self.handle_plan_transcript(slug)
else:
self.send_error(404)
else:
self.send_error(404)
elif self.path == '/echo/login' or self.path.startswith('/echo/login?'):
# If already logged in, redirect to workspace; otherwise serve
# login.html (created in Lane B2).
if self._check_dashboard_cookie():
self.send_response(302)
self.send_header('Location', '/echo/workspace.html')
self.send_header('Content-Length', '0')
self.end_headers()
return
login_html = KANBAN_DIR / 'login.html'
if login_html.is_file():
body = login_html.read_text('utf-8').replace('<!--NAV-->', NAV_HTML).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
self.wfile.write(body)
else:
# Lane B2 hasn't shipped yet — return 503 with a hint.
self.send_error(503, 'login.html not yet available')
elif self.path == '/ralph.html' or self.path.startswith('/ralph.html?'):
# Legacy redirect — Ralph dashboard merged into workspace.html (Lane D1).
self.send_response(302)
self.send_header('Location', '/echo/workspace.html')
self.send_header('Content-Length', '0')
self.end_headers()
return
elif self.path.startswith('/api/'):
self.send_error(404)
else:
@@ -206,7 +286,49 @@ class TaskBoardHandler(
return
super().do_GET()
# POSTs that bypass the auth middleware (login itself can't require a cookie).
UNPROTECTED_POSTS = frozenset({'/api/auth/login'})
def do_POST(self):
# ── Auth middleware ────────────────────────────────────────
# Only protect /api/* POSTs for now — older endpoints predate auth and
# we want a single, well-defined gate. Static asset POSTs (none today)
# would also fall through.
path_only = self.path.split('?', 1)[0]
if path_only.startswith('/api/') and path_only not in self.UNPROTECTED_POSTS:
if not self._check_dashboard_cookie():
body = b'{"error":"Unauthorized"}'
self.send_response(401)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
# CSRF: require Origin (or Referer) to be on the allowlist.
origin = self.headers.get('Origin', '') or ''
referer = self.headers.get('Referer', '') or ''
allowed = ['http://127.0.0.1:8088', 'http://localhost:8088']
dh = os.environ.get('DASHBOARD_HOST', '').strip()
if dh:
allowed.append(dh)
check = origin or referer
if check and not any(check.startswith(a) for a in allowed):
body = b'{"error":"CSRF"}'
self.send_response(403)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
if self.path == '/api/youtube':
self.handle_youtube()
elif self.path == '/api/files':
@@ -255,6 +377,39 @@ class TaskBoardHandler(
self.send_error(404)
else:
self.send_error(404)
elif self.path == '/api/auth/login':
self.handle_login()
elif self.path == '/api/auth/logout':
self.handle_logout()
elif self.path == '/api/projects/propose':
self.handle_propose()
elif self.path == '/api/projects/approve':
self.handle_approve()
elif self.path == '/api/projects/unapprove':
self.handle_unapprove()
elif self.path == '/api/projects/cancel':
self.handle_cancel()
elif self.path.startswith('/api/projects/'):
# /api/projects/<slug>/plan/(start|respond|finalize|cancel|advance)
parts = self.path.split('?', 1)[0].split('/')
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
if len(parts) >= 6 and parts[4] == 'plan':
slug = parts[3]
action = parts[5]
if action == 'start':
self.handle_plan_start(slug)
elif action == 'respond':
self.handle_plan_respond(slug)
elif action == 'finalize':
self.handle_plan_finalize(slug)
elif action == 'cancel':
self.handle_plan_cancel_planning(slug)
elif action == 'advance':
self.handle_plan_advance(slug)
else:
self.send_error(404)
else:
self.send_error(404)
else:
self.send_error(404)

View File

@@ -0,0 +1,54 @@
"""Shared validation helpers for dashboard handlers."""
import json
import re
from http.server import BaseHTTPRequestHandler
_SLUG_RE = re.compile(r'^[a-z0-9][a-z0-9\-_]{1,38}[a-z0-9]$')
def validate_slug(slug: str) -> str | None:
"""Returns error message or None if valid."""
if not slug:
return "slug required"
if not _SLUG_RE.match(slug):
return "slug must be 3-40 chars, lowercase alphanumeric + hyphens/underscores"
return None
def validate_description(desc: str) -> str | None:
"""Returns error message or None if valid. Min 10 chars, max 500."""
if not desc or len(desc.strip()) < 10:
return "description must be at least 10 characters"
if len(desc) > 500:
return "description must be at most 500 characters"
return None
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict | None:
"""Parse JSON body from request. Returns None on failure (sends 400)."""
try:
length = int(handler.headers.get('Content-Length', '0') or '0')
except (TypeError, ValueError):
length = 0
def _send_error(msg: str) -> None:
sender = getattr(handler, 'send_json', None)
if callable(sender):
sender({'error': msg}, 400)
return
body = json.dumps({'error': msg}).encode()
handler.send_response(400)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)
if length <= 0:
_send_error('empty body')
return None
try:
raw = handler.rfile.read(length)
return json.loads(raw.decode('utf-8'))
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
_send_error('invalid JSON body')
return None

174
dashboard/handlers/auth.py Normal file
View File

@@ -0,0 +1,174 @@
"""Cookie-based authentication for the unified dashboard.
This mixin provides:
- POST /api/auth/login — exchanges a token (form body) for a cookie.
- POST /api/auth/logout — clears the cookie.
- _check_dashboard_cookie — used by the global POST middleware (and the
SSE GET endpoint) to gate access.
`DASHBOARD_TOKEN` is read once from `dashboard/.env` (loaded into
`os.environ` by `dashboard/constants.py` at import time). When the token is
not configured we generate a random one at startup, stash it in-process,
and warn loudly to stderr — this means the dashboard is reachable from
localhost only with a freshly-printed token (printed once at boot).
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import sys
from urllib.parse import parse_qs
log = logging.getLogger(__name__)
# 30 days
_COOKIE_MAX_AGE = 60 * 60 * 24 * 30
_COOKIE_NAME = "dashboard"
_COOKIE_PATH = "/echo/"
# Module-level cache for the resolved token. Set lazily on first call so
# importing this module doesn't have a side effect at process boot.
_DASHBOARD_TOKEN: str | None = None
def _get_dashboard_token() -> str:
"""Return the dashboard token (cached). Generates a random one if absent.
`dashboard/constants.py` already loads `dashboard/.env` into os.environ at
import time, so by the time this is called the value (if present) is in
`os.environ['DASHBOARD_TOKEN']`. If missing, we mint a 32-byte URL-safe
token and warn — operators must read it from the log to log in.
"""
global _DASHBOARD_TOKEN
if _DASHBOARD_TOKEN is not None:
return _DASHBOARD_TOKEN
token = os.environ.get("DASHBOARD_TOKEN", "").strip()
if not token:
token = secrets.token_urlsafe(32)
msg = (
"[auth] DASHBOARD_TOKEN not set in dashboard/.env — generated a "
f"random token for this process: {token}\n"
" Add `DASHBOARD_TOKEN=<value>` to dashboard/.env to make it "
"stable across restarts.\n"
)
print(msg, file=sys.stderr, flush=True)
log.warning("DASHBOARD_TOKEN not configured — using ephemeral token")
_DASHBOARD_TOKEN = token
return token
def _parse_cookie_header(raw: str) -> dict[str, str]:
"""Tiny RFC 6265 cookie-pair parser. Last-write-wins on duplicates."""
out: dict[str, str] = {}
if not raw:
return out
for chunk in raw.split(";"):
chunk = chunk.strip()
if not chunk or "=" not in chunk:
continue
k, v = chunk.split("=", 1)
out[k.strip()] = v.strip()
return out
class AuthHandlers:
"""Mixin: /api/auth/login, /api/auth/logout, plus _check_dashboard_cookie."""
# ── helpers ────────────────────────────────────────────────────────
def _check_dashboard_cookie(self) -> bool:
"""Return True if the request carries a valid `dashboard` cookie."""
raw = self.headers.get("Cookie", "") or ""
cookies = _parse_cookie_header(raw)
provided = cookies.get(_COOKIE_NAME, "")
if not provided:
return False
expected = _get_dashboard_token()
# Constant-time compare — token guess attacks aren't realistic here
# (cookie path is /echo/, HttpOnly), but cheap defense in depth.
return secrets.compare_digest(provided, expected)
def _read_form_body(self) -> dict[str, str]:
"""Parse `application/x-www-form-urlencoded` POST body."""
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except (TypeError, ValueError):
length = 0
if length <= 0:
return {}
try:
raw = self.rfile.read(length).decode("utf-8")
except (UnicodeDecodeError, OSError):
return {}
parsed = parse_qs(raw, keep_blank_values=True)
# Flatten — single-value form fields only
return {k: v[0] for k, v in parsed.items() if v}
# ── POST /api/auth/login ───────────────────────────────────────────
def handle_login(self):
"""Validate token from form body; on success, set cookie + 302 to workspace.
On failure, return 401 JSON. The cookie is set with HttpOnly +
SameSite=Strict; Path=/echo/ so it scopes to the dashboard reverse
proxy mount.
"""
# Accept JSON body too (login.html might POST JSON in Lane B2)
ctype = (self.headers.get("Content-Type", "") or "").lower()
if "application/json" in ctype:
try:
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length).decode("utf-8") if length > 0 else ""
form = json.loads(raw) if raw else {}
if not isinstance(form, dict):
form = {}
except (ValueError, json.JSONDecodeError, UnicodeDecodeError, OSError):
form = {}
else:
form = self._read_form_body()
provided = (form.get("token") or "").strip()
expected = _get_dashboard_token()
if not provided or not secrets.compare_digest(provided, expected):
body = json.dumps({"error": "Invalid token"}).encode("utf-8")
self.send_response(401)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
cookie = (
f"{_COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; "
f"Path={_COOKIE_PATH}; Max-Age={_COOKIE_MAX_AGE}"
)
self.send_response(302)
self.send_header("Set-Cookie", cookie)
self.send_header("Location", "/echo/workspace.html")
self.send_header("Content-Length", "0")
self.send_header("Cache-Control", "no-store")
self.end_headers()
# ── POST /api/auth/logout ──────────────────────────────────────────
def handle_logout(self):
"""Clear the dashboard cookie. Returns 200 JSON `{"ok": true}`."""
cookie = (
f"{_COOKIE_NAME}=; HttpOnly; SameSite=Strict; "
f"Path={_COOKIE_PATH}; Max-Age=0"
)
body = json.dumps({"ok": True}).encode("utf-8")
self.send_response(200)
self.send_header("Set-Cookie", cookie)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,6 @@ Reuse path constants din `dashboard/constants.py` (WORKSPACE_DIR).
"""
import json
import os
import re
import signal
import subprocess
import sys
@@ -34,6 +33,8 @@ from pathlib import Path
import constants
from handlers._validators import _SLUG_RE, validate_slug
# Best-effort import of pure functions for /api/ralph/usage (instrumentation MVP).
# Helper lives at <repo>/tools/ralph_usage.py — sibling of `dashboard/`.
_TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
@@ -45,10 +46,6 @@ except ImportError: # pragma: no cover — diagnostic only
ralph_usage = None # type: ignore
# Slug strict: alphanum + dash + underscore, max 64 chars. Reject path traversal explicit.
_SLUG_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
# Path Ralph per proiect (mereu în scripts/ralph/)
def _ralph_dir(project_dir: Path) -> Path:
return project_dir / "scripts" / "ralph"
@@ -65,17 +62,11 @@ class RalphHandlers:
def _ralph_validate_slug(self, slug: str):
"""Validează slug-ul + returnează project_dir sau None.
Strict: alphanum + dash + underscore, ≤64 chars. Path traversal sequences
(`..`, `/`, `\\`) sau caractere ne-alfanumerice sunt respinse înainte de
orice atingere a filesystem-ului.
Delegates the slug-shape check to the shared `validate_slug` helper
in `dashboard/handlers/_validators.py`; only filesystem checks remain
here (existence + path-confinement under WORKSPACE_DIR).
"""
if not slug:
return None
# Defense-in-depth: explicit path-traversal/separator reject (regex îl
# acoperă, dar îl ţinem explicit ca safety net dacă regex-ul se relaxează).
if ".." in slug or "/" in slug or "\\" in slug:
return None
if not _SLUG_RE.match(slug):
if validate_slug(slug) is not None:
return None
project_dir = constants.WORKSPACE_DIR / slug
try:

View File

@@ -11,13 +11,15 @@ from urllib.parse import parse_qs, urlparse
import constants
from handlers._validators import validate_slug
class WorkspaceHandlers:
"""Mixin for /api/workspace and /api/workspace/*."""
def _validate_project(self, name):
"""Validate project name and return its path, or None."""
if not name or '/' in name or '..' in name:
if validate_slug(name) is not None:
return None
project_dir = constants.WORKSPACE_DIR / name
if not project_dir.exists() or not project_dir.is_dir():

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
<title>Echo · Dashboard</title>
<link rel="stylesheet" href="/echo/static/tokens.css">
<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>

281
dashboard/login.html Normal file
View File

@@ -0,0 +1,281 @@
<!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 — Autentificare</title>
<link rel="stylesheet" href="/echo/static/tokens.css">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
background: var(--bg-base, #13131a);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: 1.5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--space-6) var(--space-4);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login-shell {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
width: min(380px, 100% - 48px);
}
.monogram {
font-family: var(--font-sans);
font-weight: 700;
font-size: 56px;
line-height: 1;
letter-spacing: -0.02em;
color: var(--accent);
user-select: none;
}
.login-card {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-6);
box-shadow: var(--shadow-md);
}
.login-title {
margin: 0 0 var(--space-1) 0;
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.login-subtitle {
margin: 0 0 var(--space-5) 0;
font-size: var(--text-sm);
color: var(--text-muted);
text-align: center;
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.form-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
}
.form-input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: var(--text-base);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
-webkit-appearance: none;
appearance: none;
}
.form-input::placeholder {
color: var(--text-muted);
opacity: 0.6;
}
.form-input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.form-input.is-invalid {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
}
.form-error {
min-height: 1.25em;
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--error);
visibility: hidden;
}
.form-error.is-visible {
visibility: visible;
}
.submit-btn {
width: 100%;
margin-top: var(--space-5);
padding: var(--space-3) var(--space-4);
background: var(--accent);
color: #ffffff;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-base);
font-weight: 600;
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast);
}
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.submit-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.submit-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
@media (max-width: 480px) {
.login-card { padding: var(--space-5); }
.monogram { font-size: 48px; }
}
</style>
</head>
<body>
<main class="login-shell">
<div class="monogram" aria-hidden="true">E</div>
<section class="login-card">
<h1 class="login-title">Echo Dashboard</h1>
<p class="login-subtitle">Autentificare</p>
<form id="login-form" method="post" action="/api/auth/login" novalidate>
<div class="form-field">
<label class="form-label" for="token-input">Token de acces</label>
<input
id="token-input"
name="token"
type="password"
autocomplete="current-password"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
aria-label="Token de acces"
aria-describedby="form-error"
required>
<div id="form-error" class="form-error" role="alert" aria-live="polite"></div>
</div>
<button id="submit-btn" type="submit" class="submit-btn">Intră</button>
</form>
</section>
</main>
<script>
(function () {
'use strict';
var form = document.getElementById('login-form');
var input = document.getElementById('token-input');
var btn = document.getElementById('submit-btn');
var errorEl = document.getElementById('form-error');
var DEFAULT_LABEL = 'Intră';
var SUBMITTING_LABEL = 'Se autentifică...';
var RETRY_LABEL = 'Reîncearcă';
// Auto-focus input on load (skip on touch devices to avoid keyboard pop)
window.addEventListener('DOMContentLoaded', function () {
if (!('ontouchstart' in window)) {
try { input.focus(); } catch (e) { /* ignore */ }
}
});
// Clear error styling as soon as the user edits the field
input.addEventListener('input', function () {
if (input.classList.contains('is-invalid')) {
input.classList.remove('is-invalid');
errorEl.textContent = '';
errorEl.classList.remove('is-visible');
}
});
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var token = input.value.trim();
if (!token) {
input.classList.add('is-invalid');
errorEl.textContent = 'Token invalid';
errorEl.classList.add('is-visible');
input.focus();
return;
}
// Submitting state
btn.disabled = true;
btn.textContent = SUBMITTING_LABEL;
input.classList.remove('is-invalid');
errorEl.textContent = '';
errorEl.classList.remove('is-visible');
var body = 'token=' + encodeURIComponent(token);
fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json, text/html'
},
body: body,
credentials: 'same-origin',
redirect: 'follow'
}).then(function (res) {
// Browsers auto-follow 302, so a successful login surfaces
// here as a 2xx (workspace.html) or an opaqueredirect.
if (res.ok || res.type === 'opaqueredirect' || res.redirected) {
var dest = res.url && res.redirected ? res.url : '/echo/workspace.html';
window.location.assign(dest);
return;
}
if (res.status === 401) {
showInvalid();
return;
}
// Any other status — treat as a generic failure
showInvalid();
}).catch(function () {
showInvalid();
});
});
function showInvalid() {
input.classList.add('is-invalid');
errorEl.textContent = 'Token invalid';
errorEl.classList.add('is-visible');
btn.disabled = false;
btn.textContent = RETRY_LABEL;
try { input.focus(); input.select(); } catch (e) { /* ignore */ }
}
})();
</script>
</body>
</html>

View File

@@ -1,743 +0,0 @@
<!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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 &lt;slug&gt;</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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

159
dashboard/static/tokens.css Normal file
View File

@@ -0,0 +1,159 @@
/*
* Echo Dashboard — Design Tokens
* Single source of truth for all CSS variables, fonts, and shared
* mobile-modal behavior. Loaded via /echo/static/tokens.css on every
* dashboard page (in addition to common.css for now).
*
* Token coverage:
* - Colors (dark default + light theme override)
* - Status palette (running, blocked, failed, complete, idle,
* planning, pending, approved)
* - Typography (Inter sans + JetBrains Mono mono, size scale)
* - Spacing (8px grid)
* - Radius scale
* - Shadows / transitions
*/
/* ==========================================================
@font-face — Inter (self-hosted, woff2 only)
========================================================== */
@font-face {
font-family: 'Inter';
src: url('/echo/static/fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/echo/static/fonts/inter-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/echo/static/fonts/inter-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/echo/static/fonts/inter-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ==========================================================
Tokens — dark theme (default)
========================================================== */
:root {
/* Colors — dark surface */
--bg-base: #13131a;
--bg-surface: rgba(255, 255, 255, 0.12);
--bg-surface-hover: rgba(255, 255, 255, 0.16);
--bg-surface-active: rgba(255, 255, 255, 0.20);
--bg-elevated: rgba(255, 255, 255, 0.14);
--text-primary: #ffffff;
--text-secondary: #f5f5f5;
--text-muted: #e5e5e5;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-subtle: rgba(59, 130, 246, 0.2);
--border: rgba(255, 255, 255, 0.3);
--border-focus: rgba(59, 130, 246, 0.7);
--header-bg: rgba(19, 19, 26, 0.95);
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
/* Status palette — used by .status-pill[data-status] */
--status-running: rgb(34, 197, 94); /* green — running-ralph / running-manual */
--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);
/* Status palette — extended (workflow states) */
--status-planning: rgb(167, 139, 250); /* violet — Echo is planning */
--status-pending: rgb(96, 165, 250); /* sky — awaiting approval */
--status-approved: rgb(234, 179, 8); /* gold — approved tonight */
/* Spacing — 8px grid */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
/* Motion */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
}
/* ==========================================================
Light theme override
========================================================== */
[data-theme="light"] {
--bg-base: #f8f9fa;
--bg-surface: rgba(0, 0, 0, 0.04);
--bg-surface-hover: rgba(0, 0, 0, 0.08);
--bg-surface-active: rgba(0, 0, 0, 0.12);
--bg-elevated: rgba(0, 0, 0, 0.06);
--text-primary: #1a1a1a;
--text-secondary: #444444;
--text-muted: #666666;
--border: rgba(0, 0, 0, 0.12);
--border-focus: rgba(59, 130, 246, 0.5);
--accent-subtle: rgba(59, 130, 246, 0.12);
--header-bg: rgba(255, 255, 255, 0.95);
}
/* ==========================================================
Mobile modal — shared across all pages with .modal-overlay
========================================================== */
@media (max-width: 640px) {
.modal-overlay { padding: 0; align-items: stretch; }
.modal { max-width: 100vw !important; max-height: 100vh !important; border-radius: 0; height: 100vh; }
.modal-header { position: sticky; top: 0; background: var(--bg-base); }
.modal-footer { position: sticky; bottom: 0; padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
.phase-stepper .phase-step:not(.active) span:not(.step-num) { display: none; }
}

File diff suppressed because it is too large Load Diff