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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
325
src/dashboard/routes/hands.py
Normal file
325
src/dashboard/routes/hands.py
Normal 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",
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
140
src/dashboard/templates/hands.html
Normal file
140
src/dashboard/templates/hands.html
Normal 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 %}
|
||||
44
src/dashboard/templates/partials/approvals_list.html
Normal file
44
src/dashboard/templates/partials/approvals_list.html
Normal 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 %}
|
||||
38
src/dashboard/templates/partials/hand_executions.html
Normal file
38
src/dashboard/templates/partials/hand_executions.html
Normal 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 %}
|
||||
77
src/dashboard/templates/partials/hands_list.html
Normal file
77
src/dashboard/templates/partials/hands_list.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user