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:
317
dashboard/DESIGN.md
Normal file
317
dashboard/DESIGN.md
Normal 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.
|
||||
163
dashboard/api.py
163
dashboard/api.py
@@ -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)
|
||||
|
||||
|
||||
54
dashboard/handlers/_validators.py
Normal file
54
dashboard/handlers/_validators.py
Normal 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
174
dashboard/handlers/auth.py
Normal 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
|
||||
1014
dashboard/handlers/projects.py
Normal file
1014
dashboard/handlers/projects.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
281
dashboard/login.html
Normal 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>
|
||||
@@ -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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderCard(p) {
|
||||
const total = p.storiesTotal || 0;
|
||||
const done = p.storiesComplete || 0;
|
||||
const failed = p.storiesFailed || 0;
|
||||
const blocked = p.storiesBlocked || 0;
|
||||
const pct = total > 0 ? Math.round(((done + failed + blocked) / total) * 100) : 0;
|
||||
|
||||
const current = p.currentStory
|
||||
? `<div class="ralph-current"><span class="ralph-current-id">${escapeHtml(p.currentStory.id)}</span> · ${escapeHtml(p.currentStory.title || '')} ` +
|
||||
(p.currentStory.retries ? `<span title="retries">(${p.currentStory.retries}/3)</span>` : '') + `</div>` +
|
||||
(p.currentStory.tags && p.currentStory.tags.length
|
||||
? `<div class="ralph-tags">${p.currentStory.tags.map(t => `<span class="ralph-tag">${escapeHtml(t)}</span>`).join('')}</div>`
|
||||
: '')
|
||||
: (p.status === 'complete'
|
||||
? `<div class="ralph-current">Toate stories complete (${done}/${total}).</div>`
|
||||
: `<div class="ralph-current" style="color:var(--text-muted)">Nu rulează acum.</div>`);
|
||||
|
||||
const eta = (p.etaMinutes != null && p.status === 'running')
|
||||
? `~${p.etaMinutes}min`
|
||||
: '';
|
||||
|
||||
const stopBtn = p.running
|
||||
? `<button type="button" class="ralph-icon-btn danger" data-action="stop" data-slug="${escapeHtml(p.slug)}" aria-label="Oprește Ralph">
|
||||
<i data-lucide="square" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
// Rollback: vizibil pe card-uri running (corectează ultima iteraţie
|
||||
// dacă Ralph a marcat passes prematur). Confirm dialog la click.
|
||||
const rollbackBtn = p.running
|
||||
? `<button type="button" class="ralph-icon-btn" data-action="rollback" data-slug="${escapeHtml(p.slug)}" aria-label="Rollback ultima iteraţie" title="Rollback ultima iteraţie (git revert HEAD)">
|
||||
<i data-lucide="undo-2" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="ralph-card" data-status="${escapeHtml(p.status)}">
|
||||
<header class="ralph-card-head">
|
||||
<div class="ralph-slug" title="${escapeHtml(p.slug)}">${escapeHtml(p.slug)}</div>
|
||||
<span class="ralph-status" data-status="${escapeHtml(p.status)}" aria-label="Status: ${escapeHtml(p.status)}">
|
||||
<span class="ralph-status-dot" aria-hidden="true"></span>${escapeHtml(p.status)}
|
||||
</span>
|
||||
</header>
|
||||
<div class="ralph-card-body">
|
||||
${current}
|
||||
<div class="ralph-progress">
|
||||
<div class="ralph-progress-meta">
|
||||
<span>${done}/${total} done${failed ? ` · ${failed} failed` : ''}${blocked ? ` · ${blocked} blocked` : ''}</span>
|
||||
<span>${eta}</span>
|
||||
</div>
|
||||
<div class="ralph-progress-bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="ralph-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ralph-card-foot">
|
||||
<span title="Ultima iterație">${fmtAgo(p.lastIterAt)}</span>
|
||||
<div class="ralph-actions">
|
||||
<button type="button" class="ralph-icon-btn" data-action="log" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi log">
|
||||
<i data-lucide="terminal" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="ralph-icon-btn" data-action="prd" data-slug="${escapeHtml(p.slug)}" aria-label="Vezi PRD">
|
||||
<i data-lucide="file-text" aria-hidden="true"></i>
|
||||
</button>
|
||||
${rollbackBtn}
|
||||
${stopBtn}
|
||||
</div>
|
||||
</footer>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return `
|
||||
<div class="ralph-empty">
|
||||
<i data-lucide="inbox" aria-hidden="true"></i>
|
||||
<div class="ralph-empty-title">Niciun proiect aprobat.</div>
|
||||
<div>Aprobă ceva pe Discord/Telegram cu <code>/a <slug></code>.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `
|
||||
<div class="ralph-error">
|
||||
<i data-lucide="alert-triangle" aria-hidden="true"></i>
|
||||
<div>Cannot reach Echo Core: ${escapeHtml(msg)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSnapshot(data) {
|
||||
const projects = data.projects || [];
|
||||
if (projects.length === 0) {
|
||||
contentEl.innerHTML = renderEmpty();
|
||||
} else {
|
||||
contentEl.innerHTML = `<div class="ralph-grid">${projects.map(renderCard).join('')}</div>`;
|
||||
}
|
||||
lastFetchEl.textContent = '· ' + fmtAgo(data.fetchedAt);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/echo/api/ralph/status', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
renderSnapshot(data);
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = renderError(err.message || String(err));
|
||||
setMode('offline');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function openLog(slug) {
|
||||
drawerTitle.textContent = `${slug} · progress.txt`;
|
||||
drawerBody.textContent = 'Se încarcă...';
|
||||
drawer.dataset.open = 'true';
|
||||
try {
|
||||
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/log?lines=200`);
|
||||
const data = await res.json();
|
||||
drawerBody.textContent = (data.lines || []).join('\n');
|
||||
} catch (err) {
|
||||
drawerBody.textContent = `Error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrd(slug) {
|
||||
drawerTitle.textContent = `${slug} · prd.json`;
|
||||
drawerBody.textContent = 'Se încarcă...';
|
||||
drawer.dataset.open = 'true';
|
||||
try {
|
||||
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/prd`);
|
||||
const data = await res.json();
|
||||
drawerBody.textContent = JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
drawerBody.textContent = `Error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRalph(slug) {
|
||||
if (!confirm(`Oprești Ralph pe ${slug}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/stop`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Eșec: ' + (data.error || 'unknown'));
|
||||
} else {
|
||||
fetchStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackRalph(slug) {
|
||||
if (!confirm(`Asta va da git revert HEAD pe ${slug} și va decrementa ultima story trecută. Continui?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/echo/api/ralph/${encodeURIComponent(slug)}/rollback`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
alert('Rollback eşuat: ' + (data.message || 'unknown'));
|
||||
} else {
|
||||
alert('✓ ' + (data.message || 'Rollback OK'));
|
||||
fetchStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Eroare rollback: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const slug = btn.dataset.slug;
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'log') openLog(slug);
|
||||
else if (action === 'prd') openPrd(slug);
|
||||
else if (action === 'stop') stopRalph(slug);
|
||||
else if (action === 'rollback') rollbackRalph(slug);
|
||||
});
|
||||
|
||||
drawerClose.addEventListener('click', () => {
|
||||
drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
drawer.addEventListener('click', (e) => {
|
||||
if (e.target === drawer) drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') drawer.dataset.open = 'false';
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// Connection: try SSE first; fallback to polling on error.
|
||||
// ────────────────────────────────────────────────────────
|
||||
let eventSource = null;
|
||||
let pollHandle = null;
|
||||
|
||||
function startPolling() {
|
||||
if (pollHandle) return;
|
||||
setMode('polling');
|
||||
fetchStatus();
|
||||
pollHandle = setInterval(fetchStatus, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startSSE() {
|
||||
if (typeof EventSource === 'undefined') {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
eventSource = new EventSource('/echo/api/ralph/stream');
|
||||
} catch (err) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Server-confirmed open — switch to live mode
|
||||
eventSource.addEventListener('open', () => {
|
||||
stopPolling();
|
||||
setMode('live');
|
||||
});
|
||||
|
||||
eventSource.addEventListener('status', (ev) => {
|
||||
stopPolling();
|
||||
setMode('live');
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
renderSnapshot(data);
|
||||
} catch (err) {
|
||||
// malformed payload — ignore, next event will reconcile
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Keep-alive; nothing to render but it confirms the link.
|
||||
if (liveIndicator.dataset.mode !== 'live') setMode('live');
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', () => {
|
||||
// EventSource auto-reconnect kicks in by default. If the
|
||||
// endpoint never responds (404/500/CORS), readyState=CLOSED
|
||||
// and we fall back permanently to polling.
|
||||
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
||||
eventSource = null;
|
||||
startPolling();
|
||||
} else {
|
||||
// Transient — show polling state until reconnect succeeds
|
||||
setMode('polling');
|
||||
if (!pollHandle) {
|
||||
// Don't double-fetch; SSE reconnect should resume soon
|
||||
fetchStatus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial paint via fetch (so first frame renders even if SSE handshake
|
||||
// takes a beat); SSE will then take over for live updates.
|
||||
fetchStatus();
|
||||
startSSE();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
dashboard/static/fonts/inter-400.woff2
Normal file
BIN
dashboard/static/fonts/inter-400.woff2
Normal file
Binary file not shown.
BIN
dashboard/static/fonts/inter-500.woff2
Normal file
BIN
dashboard/static/fonts/inter-500.woff2
Normal file
Binary file not shown.
BIN
dashboard/static/fonts/inter-600.woff2
Normal file
BIN
dashboard/static/fonts/inter-600.woff2
Normal file
Binary file not shown.
BIN
dashboard/static/fonts/inter-700.woff2
Normal file
BIN
dashboard/static/fonts/inter-700.woff2
Normal file
Binary file not shown.
159
dashboard/static/tokens.css
Normal file
159
dashboard/static/tokens.css
Normal 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
Reference in New Issue
Block a user