Initialize repository snapshot
This commit is contained in:
935
scripts/console_receipts_server.py
Normal file
935
scripts/console_receipts_server.py
Normal file
@@ -0,0 +1,935 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user