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:
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user