feat: Hands Dashboard Routes and UI (Phase 3.6)

Add dashboard for managing autonomous Hands:

Routes (src/dashboard/routes/hands.py):
- GET /api/hands - List all Hands with status
- GET /api/hands/{name} - Get Hand details
- POST /api/hands/{name}/trigger - Manual trigger
- POST /api/hands/{name}/pause - Pause scheduled Hand
- POST /api/hands/{name}/resume - Resume paused Hand
- GET /api/approvals - List pending approvals
- POST /api/approvals/{id}/approve - Approve request
- POST /api/approvals/{id}/reject - Reject request
- GET /api/executions - List execution history

Templates:
- hands.html - Main dashboard page
- partials/hands_list.html - Active Hands list
- partials/approvals_list.html - Pending approvals
- partials/hand_executions.html - Execution history

Integration:
- Wired up in app.py
- Navigation links in base.html
This commit is contained in:
Alexander Payne
2026-02-26 12:46:48 -05:00
parent 73cf780656
commit d7aaae74d5
7 changed files with 628 additions and 0 deletions

View File

@@ -36,6 +36,7 @@ from dashboard.routes.work_orders import router as work_orders_router
from dashboard.routes.tasks import router as tasks_router
from dashboard.routes.scripture import router as scripture_router
from dashboard.routes.self_coding import router as self_coding_router
from dashboard.routes.hands import router as hands_router
from router.api import router as cascade_router
logging.basicConfig(
@@ -201,6 +202,7 @@ app.include_router(work_orders_router)
app.include_router(tasks_router)
app.include_router(scripture_router)
app.include_router(self_coding_router)
app.include_router(hands_router)
app.include_router(cascade_router)

View File

@@ -0,0 +1,325 @@
"""Hands Dashboard Routes.
API endpoints and HTMX views for managing autonomous Hands:
- Hand status and control
- Approval queue management
- Execution history
- Manual triggering
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from hands import HandRegistry, HandRunner, HandScheduler
from hands.models import HandConfig, HandStatus, TriggerType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/hands", tags=["hands"])
# Global instances (would be properly injected in production)
_registry: Optional[HandRegistry] = None
_scheduler: Optional[HandScheduler] = None
_runner: Optional[HandRunner] = None
def get_registry() -> HandRegistry:
"""Get or create HandRegistry singleton."""
global _registry
if _registry is None:
_registry = HandRegistry()
return _registry
def get_scheduler() -> HandScheduler:
"""Get or create HandScheduler singleton."""
global _scheduler
if _scheduler is None:
_scheduler = HandScheduler(get_registry())
return _scheduler
def get_runner() -> HandRunner:
"""Get or create HandRunner singleton."""
global _runner
if _runner is None:
_runner = HandRunner(get_registry())
return _runner
# ── API Endpoints ─────────────────────────────────────────────────────────
@router.get("/api/hands")
async def api_list_hands():
"""List all Hands with their status."""
registry = get_registry()
hands = []
for hand in registry.list_hands():
state = registry.get_state(hand.name)
hands.append({
"name": hand.name,
"description": hand.description,
"enabled": hand.enabled,
"status": state.status.value,
"schedule": hand.schedule.cron if hand.schedule else None,
"last_run": state.last_run.isoformat() if state.last_run else None,
"next_run": state.next_run.isoformat() if state.next_run else None,
"run_count": state.run_count,
})
return hands
@router.get("/api/hands/{name}")
async def api_get_hand(name: str):
"""Get detailed information about a Hand."""
registry = get_registry()
try:
hand = registry.get_hand(name)
state = registry.get_state(name)
return {
"name": hand.name,
"description": hand.description,
"enabled": hand.enabled,
"version": hand.version,
"author": hand.author,
"status": state.status.value,
"schedule": {
"cron": hand.schedule.cron if hand.schedule else None,
"timezone": hand.schedule.timezone if hand.schedule else "UTC",
},
"tools": {
"required": hand.tools_required,
"optional": hand.tools_optional,
},
"approval_gates": [
{"action": g.action, "description": g.description}
for g in hand.approval_gates
],
"output": {
"dashboard": hand.output.dashboard,
"channel": hand.output.channel,
"format": hand.output.format,
},
"state": state.to_dict(),
}
except Exception as e:
return JSONResponse(
status_code=404,
content={"error": f"Hand not found: {name}"},
)
@router.post("/api/hands/{name}/trigger")
async def api_trigger_hand(name: str):
"""Manually trigger a Hand to run."""
scheduler = get_scheduler()
success = await scheduler.trigger_hand_now(name)
if success:
return {"success": True, "message": f"Hand {name} triggered"}
else:
return JSONResponse(
status_code=500,
content={"success": False, "error": f"Failed to trigger Hand {name}"},
)
@router.post("/api/hands/{name}/pause")
async def api_pause_hand(name: str):
"""Pause a scheduled Hand."""
scheduler = get_scheduler()
if scheduler.pause_hand(name):
return {"success": True, "message": f"Hand {name} paused"}
else:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Failed to pause Hand {name}"},
)
@router.post("/api/hands/{name}/resume")
async def api_resume_hand(name: str):
"""Resume a paused Hand."""
scheduler = get_scheduler()
if scheduler.resume_hand(name):
return {"success": True, "message": f"Hand {name} resumed"}
else:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Failed to resume Hand {name}"},
)
@router.get("/api/approvals")
async def api_get_pending_approvals():
"""Get all pending approval requests."""
registry = get_registry()
approvals = await registry.get_pending_approvals()
return [
{
"id": a.id,
"hand_name": a.hand_name,
"action": a.action,
"description": a.description,
"created_at": a.created_at.isoformat(),
"expires_at": a.expires_at.isoformat() if a.expires_at else None,
}
for a in approvals
]
@router.post("/api/approvals/{approval_id}/approve")
async def api_approve_request(approval_id: str):
"""Approve a pending request."""
registry = get_registry()
if await registry.resolve_approval(approval_id, approved=True):
return {"success": True, "message": "Request approved"}
else:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Failed to approve request"},
)
@router.post("/api/approvals/{approval_id}/reject")
async def api_reject_request(approval_id: str):
"""Reject a pending request."""
registry = get_registry()
if await registry.resolve_approval(approval_id, approved=False):
return {"success": True, "message": "Request rejected"}
else:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Failed to reject request"},
)
@router.get("/api/executions")
async def api_get_executions(hand_name: Optional[str] = None, limit: int = 50):
"""Get recent Hand executions."""
registry = get_registry()
executions = await registry.get_recent_executions(hand_name, limit)
return executions
# ── HTMX Page Routes ─────────────────────────────────────────────────────
@router.get("", response_class=HTMLResponse)
async def hands_page(request: Request):
"""Main Hands dashboard page."""
from dashboard.app import templates
return templates.TemplateResponse(
"hands.html",
{
"request": request,
"title": "Hands",
},
)
@router.get("/list", response_class=HTMLResponse)
async def hands_list_partial(request: Request):
"""HTMX partial for Hands list."""
from dashboard.app import templates
registry = get_registry()
hands_data = []
for hand in registry.list_hands():
state = registry.get_state(hand.name)
hands_data.append({
"config": hand,
"state": state,
})
return templates.TemplateResponse(
"partials/hands_list.html",
{
"request": request,
"hands": hands_data,
},
)
@router.get("/approvals", response_class=HTMLResponse)
async def approvals_partial(request: Request):
"""HTMX partial for approval queue."""
from dashboard.app import templates
registry = get_registry()
approvals = await registry.get_pending_approvals()
return templates.TemplateResponse(
"partials/approvals_list.html",
{
"request": request,
"approvals": approvals,
},
)
@router.get("/executions", response_class=HTMLResponse)
async def executions_partial(request: Request, hand_name: Optional[str] = None):
"""HTMX partial for execution history."""
from dashboard.app import templates
registry = get_registry()
executions = await registry.get_recent_executions(hand_name, limit=20)
return templates.TemplateResponse(
"partials/hand_executions.html",
{
"request": request,
"executions": executions,
"hand_name": hand_name,
},
)
@router.get("/{name}/detail", response_class=HTMLResponse)
async def hand_detail_partial(request: Request, name: str):
"""HTMX partial for Hand detail."""
from dashboard.app import templates
registry = get_registry()
try:
hand = registry.get_hand(name)
state = registry.get_state(name)
return templates.TemplateResponse(
"partials/hand_detail.html",
{
"request": request,
"hand": hand,
"state": state,
},
)
except Exception:
return templates.TemplateResponse(
"partials/error.html",
{
"request": request,
"message": f"Hand {name} not found",
},
)

View File

@@ -41,6 +41,7 @@
<a href="/router/status" class="mc-test-link">ROUTER</a>
<a href="/self-modify/queue" class="mc-test-link">UPGRADES</a>
<a href="/self-coding" class="mc-test-link">SELF-CODING</a>
<a href="/hands" class="mc-test-link">HANDS</a>
<a href="/work-orders/queue" class="mc-test-link">WORK ORDERS</a>
<a href="/creative/ui" class="mc-test-link">CREATIVE</a>
<a href="/mobile" class="mc-test-link" title="Mobile-optimized view">MOBILE</a>
@@ -73,6 +74,7 @@
<a href="/memory" class="mc-mobile-link">MEMORY</a>
<a href="/work-orders/queue" class="mc-mobile-link">WORK ORDERS</a>
<a href="/self-coding" class="mc-mobile-link">SELF-CODING</a>
<a href="/hands" class="mc-mobile-link">HANDS</a>
<a href="/creative/ui" class="mc-mobile-link">CREATIVE</a>
<a href="/voice/button" class="mc-mobile-link">VOICE</a>
<a href="/mobile" class="mc-mobile-link">MOBILE</a>

View File

@@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block title %}Hands — Timmy Time{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">Hands</h1>
<p class="text-muted small mb-0">Autonomous scheduled agents</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-info" hx-get="/hands/list" hx-target="#hands-container">
Refresh
</button>
</div>
</div>
<!-- Main Content Grid -->
<div class="row g-4">
<!-- Left Column: Hands List -->
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent border-secondary d-flex justify-content-between align-items-center">
<h5 class="mb-0">Active Hands</h5>
<span class="badge bg-info" hx-get="/hands/api/hands" hx-trigger="every 30s" hx-swap="none">
Auto-refresh
</span>
</div>
<div class="card-body p-0">
<div id="hands-container" hx-get="/hands/list" hx-trigger="load">
<div class="d-flex justify-content-center py-5">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Loading Hands...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Executions -->
<div class="card border-0 shadow-sm mt-4">
<div class="card-header bg-transparent border-secondary">
<h5 class="mb-0">Recent Executions</h5>
</div>
<div class="card-body p-0">
<div id="executions-container" hx-get="/hands/executions" hx-trigger="load">
<div class="d-flex justify-content-center py-3">
<div class="spinner-border spinner-border-sm text-muted" role="status"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Approvals & Info -->
<div class="col-lg-4">
<!-- Pending Approvals -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-transparent border-secondary d-flex justify-content-between align-items-center">
<h5 class="mb-0">Pending Approvals</h5>
<span class="badge bg-warning text-dark" id="approval-count">-</span>
</div>
<div class="card-body p-0">
<div id="approvals-container" hx-get="/hands/approvals" hx-trigger="load, every 10s">
<div class="d-flex justify-content-center py-3">
<div class="spinner-border spinner-border-sm text-muted" role="status"></div>
</div>
</div>
</div>
</div>
<!-- What are Hands -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent border-secondary">
<h5 class="mb-0">What are Hands?</h5>
</div>
<div class="card-body">
<p class="small mb-2">Hands are autonomous agents that run on schedules:</p>
<ul class="list-unstyled small mb-0">
<li class="mb-1">🔮 <strong>Oracle</strong> — Bitcoin intelligence</li>
<li class="mb-1">🔍 <strong>Scout</strong> — OSINT monitoring</li>
<li class="mb-1">✍️ <strong>Scribe</strong> — Content production</li>
<li class="mb-1">💰 <strong>Ledger</strong> — Treasury tracking</li>
<li class="mb-1">🔧 <strong>Forge</strong> — Model management</li>
<li class="mb-1">🎨 <strong>Weaver</strong> — Creative pipeline</li>
<li class="mb-1">🛡️ <strong>Sentinel</strong> — System health</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<style>
.hand-card {
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.hand-card:hover {
background-color: rgba(255, 255, 255, 0.03);
}
.hand-card.running {
border-left-color: #0dcaf0;
}
.hand-card.scheduled {
border-left-color: #198754;
}
.hand-card.paused {
border-left-color: #ffc107;
}
.hand-card.error {
border-left-color: #dc3545;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.running { background-color: #0dcaf0; animation: pulse 1.5s infinite; }
.status-dot.scheduled { background-color: #198754; }
.status-dot.paused { background-color: #ffc107; }
.status-dot.error { background-color: #dc3545; }
.status-dot.idle { background-color: #6c757d; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{# Approvals list partial #}
{% if approvals %}
<div class="list-group list-group-flush" id="approval-list">
{% for approval in approvals %}
<div class="list-group-item p-3" id="approval-{{ approval.id }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="mb-0">{{ approval.hand_name }}</h6>
<small class="text-muted">{{ approval.action }}</small>
</div>
<span class="badge bg-warning text-dark">PENDING</span>
</div>
<p class="small mb-2">{{ approval.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{{ approval.created_at.strftime('%H:%M') if approval.created_at else 'Unknown' }}
</small>
<div class="btn-group btn-group-sm">
<button class="btn btn-success"
hx-post="/hands/api/approvals/{{ approval.id }}/approve"
hx-target="#approvals-container"
hx-swap="outerHTML">
✓ Approve
</button>
<button class="btn btn-danger"
hx-post="/hands/api/approvals/{{ approval.id }}/reject"
hx-target="#approvals-container"
hx-swap="outerHTML">
✗ Reject
</button>
</div>
</div>
</div>
{% endfor %}
</div>
<script>document.getElementById('approval-count').textContent = '{{ approvals|length }}';</script>
{% else %}
<div class="text-center py-4 text-muted">
<p class="mb-0 small">No pending approvals.</p>
</div>
<script>document.getElementById('approval-count').textContent = '0';</script>
{% endif %}

View File

@@ -0,0 +1,38 @@
{# Hand executions partial #}
{% if executions %}
<div class="list-group list-group-flush">
{% for exec in executions %}
<div class="list-group-item py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
{% if exec.outcome == 'success' %}
<span class="badge bg-success"></span>
{% elif exec.outcome == 'failure' %}
<span class="badge bg-danger"></span>
{% elif exec.outcome == 'approval_pending' %}
<span class="badge bg-warning text-dark"></span>
{% else %}
<span class="badge bg-secondary"></span>
{% endif %}
<span class="fw-medium">{{ exec.hand_name }}</span>
<small class="text-muted">({{ exec.trigger }})</small>
</div>
<small class="text-muted">
{{ exec.started_at[:16] if exec.started_at else 'Unknown' }}
</small>
</div>
{% if exec.error %}
<div class="small text-danger mt-1">
Error: {{ exec.error[:50] }}{% if exec.error|length > 50 %}...{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-3 text-muted">
<p class="mb-0 small">No executions yet.</p>
</div>
{% endif %}

View File

@@ -0,0 +1,77 @@
{# Hands list partial #}
{% if hands %}
<div class="list-group list-group-flush">
{% for item in hands %}
{% set hand = item.config %}
{% set state = item.state %}
<div class="list-group-item hand-card {{ state.status.value }} p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center gap-2">
<span class="status-dot {{ state.status.value }}"></span>
<h6 class="mb-0">{{ hand.name }}</h6>
{% if not hand.enabled %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</div>
<div class="btn-group btn-group-sm">
{% if state.status.value == 'running' %}
<button class="btn btn-outline-info" disabled>Running...</button>
{% else %}
<button class="btn btn-outline-success"
hx-post="/hands/api/hands/{{ hand.name }}/trigger"
hx-swap="none"
hx-confirm="Trigger {{ hand.name }} now?">
Run
</button>
{% endif %}
{% if state.is_paused %}
<button class="btn btn-outline-warning"
hx-post="/hands/api/hands/{{ hand.name }}/resume"
hx-target="#hands-container">
Resume
</button>
{% else %}
<button class="btn btn-outline-secondary"
hx-post="/hands/api/hands/{{ hand.name }}/pause"
hx-target="#hands-container">
Pause
</button>
{% endif %}
</div>
</div>
<p class="small text-muted mb-2">{{ hand.description }}</p>
<div class="d-flex justify-content-between align-items-center small">
<div class="text-muted">
{% if hand.schedule %}
<span class="me-2">🕐 {{ hand.schedule.cron }}</span>
{% endif %}
<span class="me-2">▶️ {{ state.run_count }} runs</span>
{% if state.last_run %}
<span title="Last run: {{ state.last_run }}">Last: {{ state.last_run.strftime('%H:%M') }}</span>
{% endif %}
</div>
<span class="badge bg-{{ 'success' if state.status.value == 'scheduled' else 'warning' if state.status.value == 'paused' else 'danger' if state.status.value == 'error' else 'info' }}">
{{ state.status.value.upper() }}
</span>
</div>
{% if hand.tools_required %}
<div class="mt-2">
<small class="text-muted">Tools:</small>
{% for tool in hand.tools_required %}
<span class="badge bg-dark me-1">{{ tool }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<p class="mb-0">No Hands configured.</p>
<small>Create Hand packages in the hands/ directory.</small>
</div>
{% endif %}