Add Web Dashboard with multi-device control and callback hooks
Features: - Web Dashboard: FastAPI-based dashboard with Vue.js frontend - Multi-device support (ADB, HDC, iOS) - Real-time WebSocket updates for task progress - Device management with status tracking - Task queue with execution controls (start/stop/re-execute) - Detailed task information display (thinking, actions, completion messages) - Screenshot viewing per device - LAN deployment support with configurable CORS - Callback Hooks: Interrupt and modify task execution - step_callback: Called after each step with StepResult - before_action_callback: Called before executing action - Support for task interruption and dynamic task switching - Example scripts demonstrating callback usage - Configuration: Environment-based configuration - .env file support for all settings - .env.example template with documentation - Model API configuration (base URL, model name, API key) - Dashboard configuration (host, port, CORS, device type) - Phone agent configuration (delays, max steps, language) Technical improvements: - Fixed forward reference issue with StepResult - Added package exports for callback types and configs - Enhanced dependencies with FastAPI, WebSocket support - Thread-safe task execution with device locking - Async WebSocket broadcasting from sync thread pool Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
13
dashboard/api/__init__.py
Normal file
13
dashboard/api/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
API endpoints for the dashboard.
|
||||
"""
|
||||
|
||||
from dashboard.api.devices import router as devices_router
|
||||
from dashboard.api.tasks import router as tasks_router
|
||||
from dashboard.api.websocket import router as websocket_router
|
||||
|
||||
__all__ = [
|
||||
"devices_router",
|
||||
"tasks_router",
|
||||
"websocket_router",
|
||||
]
|
||||
193
dashboard/api/devices.py
Normal file
193
dashboard/api/devices.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Device management API endpoints.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from dashboard.dependencies import get_device_manager, get_ws_manager
|
||||
from dashboard.models.device import DeviceSchema, DeviceStatus
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
from dashboard.services.websocket_manager import WebSocketManager
|
||||
|
||||
router = APIRouter(prefix="/devices", tags=["devices"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[DeviceSchema])
|
||||
async def list_devices(
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""List all connected devices.
|
||||
|
||||
Returns:
|
||||
List of device schemas
|
||||
"""
|
||||
devices = await device_manager.refresh_devices()
|
||||
return [
|
||||
DeviceSchema(
|
||||
device_id=d.device_id,
|
||||
status=d.status,
|
||||
device_type=d.device_type,
|
||||
model=d.model,
|
||||
android_version=d.android_version,
|
||||
current_app=d.current_app,
|
||||
last_seen=d.last_seen,
|
||||
is_connected=d.is_connected,
|
||||
)
|
||||
for d in devices
|
||||
]
|
||||
|
||||
|
||||
@router.get("/refresh")
|
||||
async def refresh_devices(
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
ws_manager: WebSocketManager = Depends(get_ws_manager),
|
||||
):
|
||||
"""Rescan for connected devices.
|
||||
|
||||
Returns:
|
||||
Refresh confirmation
|
||||
"""
|
||||
devices = await device_manager.refresh_devices()
|
||||
|
||||
# Broadcast device update
|
||||
for device in devices:
|
||||
await ws_manager.broadcast_device_update(
|
||||
device.device_id,
|
||||
{
|
||||
"status": device.status,
|
||||
"is_connected": device.is_connected,
|
||||
"model": device.model,
|
||||
"current_app": device.current_app,
|
||||
"last_seen": device.last_seen.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
return {"message": "Devices refreshed", "count": len(devices)}
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=DeviceSchema)
|
||||
async def get_device(
|
||||
device_id: str,
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""Get device details.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Device schema
|
||||
|
||||
Raises:
|
||||
HTTPException: If device not found
|
||||
"""
|
||||
device = await device_manager.get_device(device_id)
|
||||
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
return DeviceSchema(
|
||||
device_id=device.device_id,
|
||||
status=device.status,
|
||||
device_type=device.device_type,
|
||||
model=device.model,
|
||||
android_version=device.android_version,
|
||||
current_app=device.current_app,
|
||||
last_seen=device.last_seen,
|
||||
is_connected=device.is_connected,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{device_id}/connect")
|
||||
async def connect_device(
|
||||
device_id: str,
|
||||
address: str | None = None,
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""Connect to device via WiFi.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier (for route matching)
|
||||
address: Device address (IP:PORT)
|
||||
|
||||
Returns:
|
||||
Connection result
|
||||
"""
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
|
||||
success = await device_manager.connect_device(address)
|
||||
|
||||
if success:
|
||||
return {"message": f"Connected to {address}"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to connect to {address}")
|
||||
|
||||
|
||||
@router.post("/{device_id}/disconnect")
|
||||
async def disconnect_device(
|
||||
device_id: str,
|
||||
address: str | None = None,
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""Disconnect from device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier (for route matching)
|
||||
address: Device address (IP:PORT)
|
||||
|
||||
Returns:
|
||||
Disconnection result
|
||||
"""
|
||||
if not address:
|
||||
raise HTTPException(status_code=400, detail="Address is required")
|
||||
|
||||
success = await device_manager.disconnect_device(address)
|
||||
|
||||
if success:
|
||||
return {"message": f"Disconnected from {address}"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to disconnect from {address}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{device_id}/screenshot")
|
||||
async def get_device_screenshot(
|
||||
device_id: str,
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""Get current device screenshot.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Screenshot data (base64 encoded)
|
||||
"""
|
||||
screenshot = await device_manager.get_screenshot(device_id)
|
||||
|
||||
if screenshot is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to capture screenshot")
|
||||
|
||||
return {"device_id": device_id, "screenshot": screenshot}
|
||||
|
||||
|
||||
@router.get("/{device_id}/current-app")
|
||||
async def get_current_app(
|
||||
device_id: str,
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""Get current app for device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Current app package name
|
||||
"""
|
||||
app = await device_manager.get_current_app(device_id)
|
||||
|
||||
return {"device_id": device_id, "current_app": app}
|
||||
156
dashboard/api/tasks.py
Normal file
156
dashboard/api/tasks.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Task management API endpoints.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from dashboard.config import config
|
||||
from dashboard.dependencies import get_task_executor
|
||||
from dashboard.models.task import TaskCreateRequest, TaskSchema, TaskStatus
|
||||
from dashboard.services.task_executor import TaskExecutor
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.post("/execute", response_model=dict)
|
||||
async def execute_task(
|
||||
request: TaskCreateRequest,
|
||||
executor: TaskExecutor = Depends(get_task_executor),
|
||||
):
|
||||
"""Execute task on device.
|
||||
|
||||
Args:
|
||||
request: Task creation request
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
# Fill in model config from environment if using defaults
|
||||
if request.base_url == "http://localhost:8000/v1":
|
||||
request.base_url = config.MODEL_BASE_URL
|
||||
if request.model_name == "autoglm-phone-9b":
|
||||
request.model_name = config.MODEL_NAME
|
||||
if request.api_key == "EMPTY":
|
||||
request.api_key = config.MODEL_API_KEY
|
||||
|
||||
task_id = await executor.execute_task(request)
|
||||
|
||||
return {"task_id": task_id, "message": "Task started"}
|
||||
|
||||
|
||||
@router.post("/{task_id}/stop")
|
||||
async def stop_task(
|
||||
task_id: str,
|
||||
executor: TaskExecutor = Depends(get_task_executor),
|
||||
):
|
||||
"""Stop running task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
Stop confirmation
|
||||
"""
|
||||
await executor.stop_task(task_id)
|
||||
|
||||
return {"message": f"Task {task_id} stop requested"}
|
||||
|
||||
|
||||
@router.get("", response_model=List[TaskSchema])
|
||||
async def list_tasks(
|
||||
executor: TaskExecutor = Depends(get_task_executor),
|
||||
):
|
||||
"""List all tasks (active and recent).
|
||||
|
||||
Returns:
|
||||
List of task schemas
|
||||
"""
|
||||
return await executor.list_tasks()
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskSchema)
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
executor: TaskExecutor = Depends(get_task_executor),
|
||||
):
|
||||
"""Get task status.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
Task schema
|
||||
|
||||
Raises:
|
||||
HTTPException: If task not found
|
||||
"""
|
||||
task = await executor.get_task_status(task_id)
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}/screenshot")
|
||||
async def get_task_screenshot(
|
||||
task_id: str,
|
||||
executor: TaskExecutor = Depends(get_task_executor),
|
||||
):
|
||||
"""Get latest screenshot from task execution.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
Screenshot data (base64 encoded)
|
||||
|
||||
Raises:
|
||||
HTTPException: If task not found or device unavailable
|
||||
"""
|
||||
task = await executor.get_task_status(task_id)
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
# Get screenshot from device manager
|
||||
from dashboard.dependencies import get_device_manager
|
||||
|
||||
device_manager = get_device_manager()
|
||||
screenshot = await device_manager.get_screenshot(task.device_id)
|
||||
|
||||
if screenshot is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to capture screenshot from device"
|
||||
)
|
||||
|
||||
return {"task_id": task_id, "device_id": task.device_id, "screenshot": screenshot}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_task_stats(
|
||||
executor: TaskExecutor = Depends(get_task_executor),
|
||||
):
|
||||
"""Get task execution statistics.
|
||||
|
||||
Returns:
|
||||
Task statistics summary
|
||||
"""
|
||||
tasks = await executor.list_tasks()
|
||||
|
||||
total = len(tasks)
|
||||
running = sum(1 for t in tasks if t.status == TaskStatus.RUNNING)
|
||||
completed = sum(1 for t in tasks if t.status == TaskStatus.COMPLETED)
|
||||
failed = sum(1 for t in tasks if t.status == TaskStatus.FAILED)
|
||||
stopped = sum(1 for t in tasks if t.status == TaskStatus.STOPPED)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"running": running,
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"stopped": stopped,
|
||||
"active_count": executor.get_active_task_count(),
|
||||
}
|
||||
124
dashboard/api/websocket.py
Normal file
124
dashboard/api/websocket.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
WebSocket API endpoints for real-time updates.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
|
||||
|
||||
from dashboard.dependencies import get_ws_manager, get_device_manager
|
||||
from dashboard.services.websocket_manager import WebSocketManager
|
||||
from dashboard.services.device_manager import DeviceManager
|
||||
|
||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
||||
|
||||
|
||||
@router.websocket("")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
client_id: str | None = Query(None),
|
||||
ws_manager: WebSocketManager = Depends(get_ws_manager),
|
||||
device_manager: DeviceManager = Depends(get_device_manager),
|
||||
):
|
||||
"""WebSocket endpoint for real-time updates.
|
||||
|
||||
This endpoint provides real-time updates for:
|
||||
- Device connection/disconnection
|
||||
- Task execution progress
|
||||
- Screenshot updates
|
||||
- Task completion
|
||||
|
||||
Query parameters:
|
||||
client_id: Optional client ID (auto-generated if not provided)
|
||||
|
||||
Message types (client -> server):
|
||||
- {"type": "subscribe", "device_id": "device_id"} - Subscribe to device updates
|
||||
- {"type": "unsubscribe", "device_id": "device_id"} - Unsubscribe from device updates
|
||||
- {"type": "ping"} - Ping server
|
||||
|
||||
Message types (server -> client):
|
||||
- {"type": "device_update", "data": {...}} - Device status update
|
||||
- {"type": "task_started", "data": {...}} - Task started
|
||||
- {"type": "task_step", "data": {...}} - Task step update
|
||||
- {"type": "task_completed", "data": {...}} - Task completed
|
||||
- {"type": "task_failed", "data": {...}} - Task failed
|
||||
- {"type": "task_stopped", "data": {...}} - Task stopped
|
||||
- {"type": "screenshot", "data": {...}} - Screenshot update
|
||||
- {"type": "error", "data": {...}} - Error occurred
|
||||
- {"type": "pong"} - Pong response
|
||||
"""
|
||||
# Accept connection
|
||||
client_id = await ws_manager.connect(websocket, client_id)
|
||||
|
||||
try:
|
||||
# Send initial devices
|
||||
devices = await device_manager.refresh_devices()
|
||||
for device in devices:
|
||||
await websocket.send_json({
|
||||
"type": "device_update",
|
||||
"data": {
|
||||
"device_id": device.device_id,
|
||||
"status": device.status.value,
|
||||
"device_type": device.device_type.value,
|
||||
"model": device.model,
|
||||
"android_version": device.android_version,
|
||||
"current_app": device.current_app,
|
||||
"is_connected": device.is_connected,
|
||||
}
|
||||
})
|
||||
|
||||
# Message loop
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "subscribe":
|
||||
# Subscribe to device updates
|
||||
device_id = data.get("device_id", "*")
|
||||
ws_manager.subscribe_to_device(client_id, device_id)
|
||||
|
||||
elif msg_type == "unsubscribe":
|
||||
# Unsubscribe from device updates
|
||||
device_id = data.get("device_id")
|
||||
if device_id:
|
||||
ws_manager.unsubscribe_from_device(client_id, device_id)
|
||||
|
||||
elif msg_type == "ping":
|
||||
# Respond to ping
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect(client_id)
|
||||
except Exception:
|
||||
ws_manager.disconnect(client_id)
|
||||
|
||||
|
||||
@router.websocket("/device/{device_id}")
|
||||
async def device_websocket(
|
||||
device_id: str,
|
||||
websocket: WebSocket,
|
||||
ws_manager: WebSocketManager = Depends(get_ws_manager),
|
||||
):
|
||||
"""Device-specific WebSocket endpoint for real-time updates.
|
||||
|
||||
This endpoint provides real-time updates for a specific device.
|
||||
Automatically subscribes to the device's updates.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
"""
|
||||
# Accept connection and auto-subscribe to device
|
||||
client_id = await ws_manager.connect(websocket)
|
||||
ws_manager.subscribe_to_device(client_id, device_id)
|
||||
|
||||
try:
|
||||
# Keep connection alive
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
# Handle client messages
|
||||
if data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect(client_id)
|
||||
except Exception:
|
||||
ws_manager.disconnect(client_id)
|
||||
Reference in New Issue
Block a user