936 lines
28 KiB
Python
936 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
VaultMesh Console Receipts HTTP Bridge
|
|
|
|
A minimal FastAPI server that exposes the Console receipt emitter
|
|
for the OpenCode plugin to call via HTTP.
|
|
|
|
Usage:
|
|
python scripts/console_receipts_server.py
|
|
|
|
# Or with uvicorn directly:
|
|
uvicorn scripts.console_receipts_server:app --host 127.0.0.1 --port 9110
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Literal, Optional
|
|
|
|
# Add parent directory to path for imports
|
|
SCRIPT_DIR = Path(__file__).parent.absolute()
|
|
VAULTMESH_ROOT = SCRIPT_DIR.parent
|
|
sys.path.insert(0, str(VAULTMESH_ROOT))
|
|
|
|
# Set environment variable for the emitter
|
|
os.environ.setdefault("VAULTMESH_ROOT", str(VAULTMESH_ROOT))
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel
|
|
|
|
from dataclasses import asdict
|
|
|
|
from engines.console.receipts import (
|
|
ConsoleReceiptEmitter,
|
|
ReceiptType,
|
|
emit_console_receipt,
|
|
get_emitter,
|
|
)
|
|
from engines.console.approvals import get_approval_manager, ApprovalRequest
|
|
|
|
# ============================================================================
|
|
# FastAPI App
|
|
# ============================================================================
|
|
|
|
app = FastAPI(
|
|
title="VaultMesh Console Receipts API",
|
|
description="HTTP bridge for emitting Console receipts to the Civilization Ledger",
|
|
version="0.1.0",
|
|
)
|
|
|
|
# Allow CORS for local development
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Request/Response Models
|
|
# ============================================================================
|
|
|
|
class ReceiptIn(BaseModel):
|
|
"""Request body for emitting a receipt."""
|
|
type: ReceiptType
|
|
session_id: Optional[str] = None
|
|
payload: Dict[str, Any]
|
|
|
|
|
|
class ReceiptOut(BaseModel):
|
|
"""Response after emitting a receipt."""
|
|
ok: bool
|
|
record: Dict[str, Any]
|
|
|
|
|
|
class RootInfo(BaseModel):
|
|
"""Console scroll root information."""
|
|
engine_id: str
|
|
merkle_root: str
|
|
events: int
|
|
updated_at: Optional[str] = None
|
|
|
|
|
|
class AnchorRequest(BaseModel):
|
|
"""Request to trigger Guardian anchor."""
|
|
scrolls: Optional[List[str]] = None
|
|
|
|
|
|
class SearchRequest(BaseModel):
|
|
"""Request to search receipts."""
|
|
scroll: Optional[str] = "Console"
|
|
receipt_type: Optional[str] = None
|
|
limit: int = 50
|
|
|
|
|
|
class ApprovalRequestIn(BaseModel):
|
|
"""Request to create an approval."""
|
|
session_id: str
|
|
action_type: str
|
|
action_details: Dict[str, Any]
|
|
requested_by: str
|
|
approvers: List[str]
|
|
timeout_minutes: int = 60
|
|
|
|
|
|
class ApprovalDecisionIn(BaseModel):
|
|
"""Request to decide on an approval."""
|
|
approved: bool
|
|
approver: str
|
|
reason: str = ""
|
|
|
|
|
|
# ============================================================================
|
|
# Endpoints
|
|
# ============================================================================
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint."""
|
|
return {"status": "ok", "engine": "console"}
|
|
|
|
|
|
@app.post("/v1/console/receipt", response_model=ReceiptOut)
|
|
async def post_receipt(req: ReceiptIn):
|
|
"""
|
|
Emit a Console receipt.
|
|
|
|
This is the main endpoint called by the OpenCode plugin.
|
|
"""
|
|
try:
|
|
record = emit_console_receipt(
|
|
receipt_type=req.type,
|
|
payload=req.payload,
|
|
session_id=req.session_id,
|
|
)
|
|
return ReceiptOut(ok=True, record=record)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/v1/console/root", response_model=RootInfo)
|
|
async def get_root():
|
|
"""
|
|
Get Console scroll Merkle root info.
|
|
"""
|
|
try:
|
|
emitter = get_emitter()
|
|
info = emitter.get_root_info()
|
|
return RootInfo(**info)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/v1/guardian/anchor")
|
|
async def trigger_anchor(req: AnchorRequest):
|
|
"""
|
|
Trigger a Guardian anchor cycle.
|
|
|
|
NOTE: This is a stub - in production, this would call the
|
|
actual Guardian engine to anchor the specified scrolls.
|
|
"""
|
|
scrolls = req.scrolls or ["Console"]
|
|
# TODO: Integrate with actual Guardian engine
|
|
return {
|
|
"ok": True,
|
|
"message": f"Anchor requested for scrolls: {scrolls}",
|
|
"note": "Stub implementation - Guardian integration pending",
|
|
}
|
|
|
|
|
|
@app.post("/v1/receipts/search")
|
|
async def search_receipts(req: SearchRequest):
|
|
"""
|
|
Search Console receipts.
|
|
|
|
NOTE: This is a simple implementation that reads from JSONL.
|
|
In production, use a proper index or database.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
emitter = get_emitter()
|
|
events_path = Path(emitter.events_path)
|
|
|
|
if not events_path.exists():
|
|
return {"results": [], "total": 0}
|
|
|
|
results = []
|
|
with open(events_path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
record = json.loads(line)
|
|
# Filter by receipt type if specified
|
|
if req.receipt_type and record.get("type") != req.receipt_type:
|
|
continue
|
|
results.append(record)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
# Return most recent first, with limit
|
|
results = list(reversed(results))[:req.limit]
|
|
|
|
return {
|
|
"results": results,
|
|
"total": len(results),
|
|
"scroll": req.scroll,
|
|
}
|
|
|
|
|
|
@app.get("/v1/console/receipts")
|
|
async def list_receipts(limit: int = 20, offset: int = 0):
|
|
"""
|
|
List Console receipts with pagination.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
emitter = get_emitter()
|
|
events_path = Path(emitter.events_path)
|
|
|
|
if not events_path.exists():
|
|
return {"receipts": [], "total": 0, "limit": limit, "offset": offset}
|
|
|
|
all_receipts = []
|
|
with open(events_path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
all_receipts.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
# Most recent first
|
|
all_receipts = list(reversed(all_receipts))
|
|
total = len(all_receipts)
|
|
page = all_receipts[offset : offset + limit]
|
|
|
|
return {
|
|
"receipts": page,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Session Query Endpoints
|
|
# ============================================================================
|
|
|
|
@app.get("/v1/console/sessions")
|
|
async def list_sessions(status: str = "all", limit: int = 20):
|
|
"""List Console sessions."""
|
|
import json
|
|
|
|
emitter = get_emitter()
|
|
events_path = Path(emitter.events_path)
|
|
|
|
if not events_path.exists():
|
|
return {"sessions": [], "total": 0}
|
|
|
|
sessions: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
r = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
|
|
sid = r.get("session_id")
|
|
if not sid:
|
|
continue
|
|
|
|
if sid not in sessions:
|
|
sessions[sid] = {
|
|
"session_id": sid,
|
|
"status": "active",
|
|
"started_at": None,
|
|
"ended_at": None,
|
|
"events": 0,
|
|
}
|
|
|
|
sessions[sid]["events"] += 1
|
|
r_type = r.get("type")
|
|
|
|
if r_type == "console_session_start":
|
|
sessions[sid]["started_at"] = r["ts"]
|
|
elif r_type == "console_session_end":
|
|
sessions[sid]["ended_at"] = r["ts"]
|
|
sessions[sid]["status"] = "ended"
|
|
|
|
results = list(sessions.values())
|
|
if status == "active":
|
|
results = [s for s in results if s["status"] == "active"]
|
|
elif status == "ended":
|
|
results = [s for s in results if s["status"] == "ended"]
|
|
|
|
return {"sessions": results[-limit:], "total": len(results)}
|
|
|
|
|
|
@app.get("/v1/console/sessions/{session_id}")
|
|
async def get_session(session_id: str):
|
|
"""Get detailed session status."""
|
|
import json
|
|
|
|
emitter = get_emitter()
|
|
events_path = Path(emitter.events_path)
|
|
|
|
if not events_path.exists():
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
session: Optional[Dict[str, Any]] = None
|
|
|
|
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
r = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
|
|
if r.get("session_id") != session_id:
|
|
continue
|
|
|
|
r_type = r.get("type")
|
|
payload = r.get("payload") or {}
|
|
|
|
if r_type == "console_session_start":
|
|
session = {
|
|
"session_id": session_id,
|
|
"status": "active",
|
|
"started_at": r["ts"],
|
|
"agent_type": payload.get("agent_type"),
|
|
"model_id": payload.get("model_id"),
|
|
"caller": payload.get("caller"),
|
|
}
|
|
elif r_type == "console_session_end" and session is not None:
|
|
session["status"] = "ended"
|
|
session["ended_at"] = r["ts"]
|
|
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
return session
|
|
|
|
|
|
# ============================================================================
|
|
# Approval Endpoints
|
|
# ============================================================================
|
|
|
|
@app.post("/v1/console/approvals/request")
|
|
async def request_approval(req: ApprovalRequestIn):
|
|
"""Request approval for an action."""
|
|
manager = get_approval_manager()
|
|
request = manager.request_approval(
|
|
session_id=req.session_id,
|
|
action_type=req.action_type,
|
|
action_details=req.action_details,
|
|
requested_by=req.requested_by,
|
|
approvers=req.approvers,
|
|
timeout_minutes=req.timeout_minutes,
|
|
)
|
|
return {"ok": True, "approval_id": request.approval_id}
|
|
|
|
|
|
@app.get("/v1/console/approvals/pending")
|
|
async def list_pending_approvals(session_id: Optional[str] = None):
|
|
"""List pending approval requests."""
|
|
manager = get_approval_manager()
|
|
pending = manager.list_pending(session_id)
|
|
return {"pending": [asdict(r) for r in pending]}
|
|
|
|
|
|
@app.post("/v1/console/approvals/{approval_id}/decide")
|
|
async def decide_approval(approval_id: str, req: ApprovalDecisionIn):
|
|
"""Approve or reject a pending action."""
|
|
manager = get_approval_manager()
|
|
try:
|
|
success = manager.decide(
|
|
approval_id=approval_id,
|
|
approved=req.approved,
|
|
approver=req.approver,
|
|
reason=req.reason,
|
|
)
|
|
return {
|
|
"ok": success,
|
|
"decision": "approved" if req.approved else "rejected",
|
|
}
|
|
except PermissionError as e:
|
|
raise HTTPException(status_code=403, detail=str(e))
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="Approval not found")
|
|
|
|
|
|
# ============================================================================
|
|
# GitLab Webhook Handler
|
|
# ============================================================================
|
|
|
|
@app.post("/gitlab/webhook")
|
|
async def gitlab_webhook(request: Request):
|
|
"""
|
|
Handle GitLab webhook events and emit Console receipts.
|
|
|
|
Supports:
|
|
- Push events → console_gitlab_push
|
|
- Merge request events → console_gitlab_mr
|
|
- Pipeline events → console_gitlab_pipeline
|
|
|
|
Configure in GitLab: Settings → Webhooks
|
|
"""
|
|
event_type = request.headers.get("X-Gitlab-Event", "unknown")
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
|
|
|
emitter = get_emitter()
|
|
|
|
if event_type == "Push Hook":
|
|
# Push event
|
|
project = body.get("project", {})
|
|
commits = body.get("commits", [])
|
|
session_id = f"gitlab-push-{body.get('checkout_sha', 'unknown')[:12]}"
|
|
|
|
emit_console_receipt(
|
|
"console_command", # Reuse console_command for push events
|
|
{
|
|
"command": "git_push",
|
|
"args_hash": body.get("checkout_sha", ""),
|
|
"exit_code": 0,
|
|
"duration_ms": 0,
|
|
"gitlab_event": "push",
|
|
"project_path": project.get("path_with_namespace", ""),
|
|
"ref": body.get("ref", ""),
|
|
"commits_count": len(commits),
|
|
"user": body.get("user_name", ""),
|
|
},
|
|
session_id=session_id,
|
|
)
|
|
|
|
return {"ok": True, "event": "push", "session_id": session_id}
|
|
|
|
elif event_type == "Merge Request Hook":
|
|
# Merge request event
|
|
mr = body.get("object_attributes", {})
|
|
project = body.get("project", {})
|
|
session_id = f"gitlab-mr-{mr.get('iid', 'unknown')}"
|
|
|
|
action = mr.get("action", "update") # open, close, merge, update, etc.
|
|
|
|
emit_console_receipt(
|
|
"console_command",
|
|
{
|
|
"command": f"mr_{action}",
|
|
"args_hash": mr.get("last_commit", {}).get("id", ""),
|
|
"exit_code": 0,
|
|
"duration_ms": 0,
|
|
"gitlab_event": "merge_request",
|
|
"project_path": project.get("path_with_namespace", ""),
|
|
"mr_iid": mr.get("iid"),
|
|
"mr_title": mr.get("title", ""),
|
|
"mr_state": mr.get("state", ""),
|
|
"source_branch": mr.get("source_branch", ""),
|
|
"target_branch": mr.get("target_branch", ""),
|
|
"user": body.get("user", {}).get("name", ""),
|
|
},
|
|
session_id=session_id,
|
|
)
|
|
|
|
return {"ok": True, "event": "merge_request", "action": action, "session_id": session_id}
|
|
|
|
elif event_type == "Pipeline Hook":
|
|
# Pipeline event
|
|
pipeline = body.get("object_attributes", {})
|
|
project = body.get("project", {})
|
|
session_id = f"gitlab-pipeline-{pipeline.get('id', 'unknown')}"
|
|
|
|
status = pipeline.get("status", "unknown")
|
|
|
|
# Only emit for significant status changes
|
|
if status in ("running", "success", "failed", "canceled"):
|
|
receipt_type = "console_session_start" if status == "running" else "console_session_end"
|
|
|
|
if status == "running":
|
|
emit_console_receipt(
|
|
"console_session_start",
|
|
{
|
|
"agent_type": "gitlab-ci",
|
|
"model_id": "none",
|
|
"caller": "did:vm:service:gitlab-webhook",
|
|
"project_path": project.get("path_with_namespace", ""),
|
|
"pipeline_id": pipeline.get("id"),
|
|
"ref": pipeline.get("ref", ""),
|
|
"commit": pipeline.get("sha", ""),
|
|
},
|
|
session_id=session_id,
|
|
)
|
|
else:
|
|
emit_console_receipt(
|
|
"console_session_end",
|
|
{
|
|
"duration_ms": pipeline.get("duration", 0) * 1000 if pipeline.get("duration") else 0,
|
|
"commands_executed": 0,
|
|
"files_modified": 0,
|
|
"exit_reason": f"pipeline-{status}",
|
|
},
|
|
session_id=session_id,
|
|
)
|
|
|
|
return {"ok": True, "event": "pipeline", "status": status, "session_id": session_id}
|
|
|
|
else:
|
|
# Unknown event type - log but don't fail
|
|
return {"ok": True, "event": event_type, "note": "Unhandled event type"}
|
|
|
|
|
|
# ============================================================================
|
|
# HTML Dashboard
|
|
# ============================================================================
|
|
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
def format_time_ago(ts_str: str) -> str:
|
|
"""Format timestamp as relative time."""
|
|
from datetime import datetime, timezone
|
|
try:
|
|
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
now = datetime.now(timezone.utc)
|
|
diff = now - ts
|
|
seconds = diff.total_seconds()
|
|
if seconds < 60:
|
|
return f"{int(seconds)}s ago"
|
|
elif seconds < 3600:
|
|
return f"{int(seconds/60)}m ago"
|
|
elif seconds < 86400:
|
|
return f"{int(seconds/3600)}h ago"
|
|
else:
|
|
return f"{int(seconds/86400)}d ago"
|
|
except:
|
|
return ts_str[:19]
|
|
|
|
|
|
def get_event_color(event_type: str) -> str:
|
|
"""Get color for event type."""
|
|
colors = {
|
|
"console_session_start": "#22c55e", # green
|
|
"console_session_end": "#6b7280", # gray
|
|
"console_command": "#3b82f6", # blue
|
|
"console_file_edit": "#a855f7", # purple
|
|
"console_tool_call": "#06b6d4", # cyan
|
|
"console_approval_request": "#f59e0b", # amber
|
|
"console_approval": "#10b981", # emerald
|
|
"console_git_commit": "#ec4899", # pink
|
|
}
|
|
return colors.get(event_type, "#9ca3af")
|
|
|
|
|
|
def get_event_icon(event_type: str) -> str:
|
|
"""Get icon for event type."""
|
|
icons = {
|
|
"console_session_start": "▶",
|
|
"console_session_end": "■",
|
|
"console_command": "⌘",
|
|
"console_file_edit": "✎",
|
|
"console_tool_call": "⚡",
|
|
"console_approval_request": "⏳",
|
|
"console_approval": "✓",
|
|
"console_git_commit": "⬆",
|
|
}
|
|
return icons.get(event_type, "•")
|
|
|
|
|
|
@app.get("/console/dashboard", response_class=HTMLResponse)
|
|
async def console_dashboard():
|
|
"""
|
|
HTML dashboard showing Console status at a glance.
|
|
|
|
Shows:
|
|
- Active and recent sessions
|
|
- Pending approvals
|
|
- Recent events stream
|
|
"""
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
emitter = get_emitter()
|
|
events_path = Path(emitter.events_path)
|
|
|
|
# Collect data
|
|
sessions: Dict[str, Dict[str, Any]] = {}
|
|
all_events: List[Dict[str, Any]] = []
|
|
|
|
if events_path.exists():
|
|
for line in events_path.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
r = json.loads(line)
|
|
all_events.append(r)
|
|
|
|
sid = r.get("session_id")
|
|
if sid:
|
|
if sid not in sessions:
|
|
sessions[sid] = {
|
|
"session_id": sid,
|
|
"status": "active",
|
|
"started_at": None,
|
|
"ended_at": None,
|
|
"events": 0,
|
|
"agent_type": None,
|
|
"caller": None,
|
|
}
|
|
sessions[sid]["events"] += 1
|
|
|
|
if r.get("type") == "console_session_start":
|
|
sessions[sid]["started_at"] = r.get("ts")
|
|
payload = r.get("payload", {})
|
|
sessions[sid]["agent_type"] = payload.get("agent_type")
|
|
sessions[sid]["caller"] = payload.get("caller")
|
|
elif r.get("type") == "console_session_end":
|
|
sessions[sid]["ended_at"] = r.get("ts")
|
|
sessions[sid]["status"] = "ended"
|
|
except:
|
|
continue
|
|
|
|
# Get pending approvals
|
|
manager = get_approval_manager()
|
|
pending = manager.list_pending()
|
|
|
|
# Get root info
|
|
root_info = emitter.get_root_info()
|
|
|
|
# Sort sessions by most recent activity
|
|
session_list = sorted(
|
|
sessions.values(),
|
|
key=lambda s: s.get("started_at") or "",
|
|
reverse=True
|
|
)[:20]
|
|
|
|
# Recent events (last 30)
|
|
recent_events = list(reversed(all_events[-30:]))
|
|
|
|
# Build HTML
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="refresh" content="10">
|
|
<title>VaultMesh Console Dashboard</title>
|
|
<style>
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
padding: 1rem;
|
|
min-height: 100vh;
|
|
}}
|
|
.header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid #334155;
|
|
}}
|
|
.header h1 {{
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: #f8fafc;
|
|
}}
|
|
.header h1 span {{
|
|
color: #22c55e;
|
|
}}
|
|
.stats {{
|
|
display: flex;
|
|
gap: 2rem;
|
|
font-size: 0.875rem;
|
|
color: #94a3b8;
|
|
}}
|
|
.stats strong {{
|
|
color: #f8fafc;
|
|
}}
|
|
.grid {{
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.5rem;
|
|
}}
|
|
@media (max-width: 900px) {{
|
|
.grid {{ grid-template-columns: 1fr; }}
|
|
}}
|
|
.panel {{
|
|
background: #1e293b;
|
|
border-radius: 0.5rem;
|
|
padding: 1rem;
|
|
border: 1px solid #334155;
|
|
}}
|
|
.panel h2 {{
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: #94a3b8;
|
|
margin-bottom: 1rem;
|
|
}}
|
|
.session {{
|
|
padding: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
background: #0f172a;
|
|
border-radius: 0.375rem;
|
|
border-left: 3px solid;
|
|
}}
|
|
.session.active {{ border-color: #22c55e; }}
|
|
.session.ended {{ border-color: #6b7280; }}
|
|
.session-id {{
|
|
font-family: monospace;
|
|
font-size: 0.875rem;
|
|
color: #f8fafc;
|
|
}}
|
|
.session-meta {{
|
|
font-size: 0.75rem;
|
|
color: #64748b;
|
|
margin-top: 0.25rem;
|
|
}}
|
|
.approval {{
|
|
padding: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
background: #451a03;
|
|
border-radius: 0.375rem;
|
|
border-left: 3px solid #f59e0b;
|
|
}}
|
|
.approval-id {{
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
color: #fbbf24;
|
|
}}
|
|
.approval-action {{
|
|
font-size: 0.875rem;
|
|
color: #f8fafc;
|
|
margin-top: 0.25rem;
|
|
}}
|
|
.approval-cmd {{
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
color: #94a3b8;
|
|
background: #0f172a;
|
|
padding: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
border-radius: 0.25rem;
|
|
word-break: break-all;
|
|
}}
|
|
.event {{
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid #1e293b;
|
|
font-size: 0.8125rem;
|
|
}}
|
|
.event:last-child {{ border-bottom: none; }}
|
|
.event-icon {{
|
|
width: 1.5rem;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}}
|
|
.event-time {{
|
|
width: 4rem;
|
|
flex-shrink: 0;
|
|
color: #64748b;
|
|
font-size: 0.75rem;
|
|
}}
|
|
.event-type {{
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 0.25rem;
|
|
flex-shrink: 0;
|
|
}}
|
|
.event-detail {{
|
|
color: #94a3b8;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}}
|
|
.no-data {{
|
|
color: #64748b;
|
|
font-style: italic;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}}
|
|
.full-width {{ grid-column: 1 / -1; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🜂 <span>VaultMesh</span> Console Dashboard</h1>
|
|
<div class="stats">
|
|
<span>Sessions: <strong>{len(sessions)}</strong></span>
|
|
<span>Events: <strong>{root_info.get('events', 0)}</strong></span>
|
|
<span>Pending: <strong>{len(pending)}</strong></span>
|
|
<span>Root: <strong>{root_info.get('merkle_root', '0')[:12]}...</strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<div class="panel">
|
|
<h2>⏳ Pending Approvals ({len(pending)})</h2>
|
|
"""
|
|
|
|
if pending:
|
|
for p in pending:
|
|
html += f"""
|
|
<div class="approval">
|
|
<div class="approval-id">{p.approval_id}</div>
|
|
<div class="approval-action">{p.action_type}</div>
|
|
<div class="session-meta">Session: {p.session_id}</div>
|
|
<div class="approval-cmd">vm console approve {p.approval_id} --reason "..."</div>
|
|
</div>
|
|
"""
|
|
else:
|
|
html += '<div class="no-data">No pending approvals</div>'
|
|
|
|
html += """
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>📡 Sessions</h2>
|
|
"""
|
|
|
|
if session_list:
|
|
for s in session_list[:10]:
|
|
status_class = "active" if s["status"] == "active" else "ended"
|
|
agent = s.get("agent_type") or "unknown"
|
|
started = format_time_ago(s["started_at"]) if s.get("started_at") else "?"
|
|
html += f"""
|
|
<div class="session {status_class}">
|
|
<div class="session-id">{s['session_id']}</div>
|
|
<div class="session-meta">{agent} • {s['events']} events • {started}</div>
|
|
</div>
|
|
"""
|
|
else:
|
|
html += '<div class="no-data">No sessions yet</div>'
|
|
|
|
html += """
|
|
</div>
|
|
|
|
<div class="panel full-width">
|
|
<h2>📜 Recent Events</h2>
|
|
"""
|
|
|
|
if recent_events:
|
|
for e in recent_events:
|
|
etype = e.get("type", "unknown")
|
|
ts = format_time_ago(e.get("ts", ""))
|
|
color = get_event_color(etype)
|
|
icon = get_event_icon(etype)
|
|
payload = e.get("payload", {})
|
|
|
|
# Build detail string based on event type
|
|
detail = ""
|
|
if etype == "console_command":
|
|
detail = payload.get("command", "")
|
|
elif etype == "console_file_edit":
|
|
detail = payload.get("file_path", "")
|
|
elif etype == "console_approval_request":
|
|
detail = f"{payload.get('action_type', '')} → {payload.get('approval_id', '')}"
|
|
elif etype == "console_approval":
|
|
approved = "✓" if payload.get("approved") else "✗"
|
|
detail = f"{approved} {payload.get('action_type', '')} by {payload.get('approver', '')}"
|
|
elif etype == "console_session_start":
|
|
detail = f"{payload.get('agent_type', '')} by {payload.get('caller', '')}"
|
|
elif etype == "console_session_end":
|
|
detail = payload.get("exit_reason", "")
|
|
|
|
short_type = etype.replace("console_", "")
|
|
|
|
html += f"""
|
|
<div class="event">
|
|
<div class="event-icon" style="color: {color}">{icon}</div>
|
|
<div class="event-time">{ts}</div>
|
|
<div class="event-type" style="background: {color}20; color: {color}">{short_type}</div>
|
|
<div class="event-detail">{detail}</div>
|
|
</div>
|
|
"""
|
|
else:
|
|
html += '<div class="no-data">No events yet</div>'
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Auto-refresh indicator
|
|
setTimeout(() => {
|
|
document.querySelector('.header h1').innerHTML = '🜂 <span>VaultMesh</span> Console Dashboard <small style="color:#64748b;font-size:0.75rem">refreshing...</small>';
|
|
}, 9000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
|
|
# ============================================================================
|
|
# Main
|
|
# ============================================================================
|
|
|
|
def main():
|
|
"""Run the server."""
|
|
import uvicorn
|
|
|
|
print(f"[VaultMesh] Console Receipts Server")
|
|
print(f"[VaultMesh] Root: {VAULTMESH_ROOT}")
|
|
print(f"[VaultMesh] Listening on http://127.0.0.1:9110")
|
|
|
|
uvicorn.run(
|
|
app,
|
|
host="127.0.0.1",
|
|
port=9110,
|
|
log_level="info",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|