mirror of
https://github.com/wu736139669/OpsPilot.git
synced 2026-08-05 06:23:35 +00:00
Init OpsPilot - AI Coding Agent Control Center
A web dashboard for managing multiple Claude Code agents via tmux. Features: task step tracking, remote confirmation, live terminal, directory picker, real-time WebSocket updates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
OpsPilot - AI Coding Agent Control Center
|
||||
Web dashboard on port 5016, manages multiple Claude Code agents via tmux.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
import uvicorn
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
STATE_DIR = BASE_DIR / "state"
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
FRONTEND_DIR = BASE_DIR / "frontend"
|
||||
|
||||
for d in [STATE_DIR, LOGS_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="OpsPilot - Agent Control Center")
|
||||
|
||||
ws_clients: list[WebSocket] = []
|
||||
log_buffer: list[dict] = []
|
||||
MAX_LOGS = 500
|
||||
|
||||
|
||||
def add_log(level: str, source: str, message: str):
|
||||
entry = {
|
||||
"time": datetime.now().strftime("%H:%M:%S"),
|
||||
"ts": datetime.now().isoformat(),
|
||||
"level": level,
|
||||
"source": source,
|
||||
"message": message,
|
||||
}
|
||||
log_buffer.append(entry)
|
||||
if len(log_buffer) > MAX_LOGS:
|
||||
log_buffer.pop(0)
|
||||
with open(LOGS_DIR / "events.log", "a") as f:
|
||||
f.write(f"[{entry['time']}] [{level}] [{source}] {message}\n")
|
||||
|
||||
|
||||
def load_agents() -> dict:
|
||||
"""Load agent registry from disk."""
|
||||
path = STATE_DIR / "agents.json"
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_agents(agents: dict):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_DIR / "agents.json", "w") as f:
|
||||
json.dump(agents, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def load_projects() -> dict:
|
||||
path = STATE_DIR / "projects.json"
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_projects(projects: dict):
|
||||
with open(STATE_DIR / "projects.json", "w") as f:
|
||||
json.dump(projects, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def load_tasks() -> dict:
|
||||
"""Load task registry. Keyed by project name."""
|
||||
path = STATE_DIR / "tasks.json"
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_tasks(tasks: dict):
|
||||
with open(STATE_DIR / "tasks.json", "w") as f:
|
||||
json.dump(tasks, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def get_tmux_status(session_name: str) -> dict:
|
||||
"""Check if a tmux session exists and get its windows."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "list-windows", "-t", session_name, "-F", "#{window_index}:#{window_name}:#{window_active}"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
windows = []
|
||||
active_window = None
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
windows.append({"index": parts[0], "name": parts[1], "active": parts[2] == "1"})
|
||||
if parts[2] == "1":
|
||||
active_window = parts[0]
|
||||
return {"running": True, "windows": windows, "active_window": active_window}
|
||||
except Exception:
|
||||
return {"running": False, "windows": [], "active_window": None}
|
||||
|
||||
|
||||
async def broadcast(data: dict):
|
||||
disconnected = []
|
||||
for ws in ws_clients:
|
||||
try:
|
||||
await ws.send_json(data)
|
||||
except Exception:
|
||||
disconnected.append(ws)
|
||||
for ws in disconnected:
|
||||
ws_clients.remove(ws)
|
||||
|
||||
|
||||
# ── REST API ──────────────────────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard():
|
||||
html_path = FRONTEND_DIR / "dashboard.html"
|
||||
if html_path.exists():
|
||||
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
||||
return HTMLResponse("<h1>OpsPilot Dashboard</h1><p>dashboard.html not found</p>")
|
||||
|
||||
|
||||
@app.get("/api/state")
|
||||
async def get_state():
|
||||
agents = load_agents()
|
||||
projects = load_projects()
|
||||
# Merge live tmux status
|
||||
for name, agent in agents.items():
|
||||
session = agent.get("tmux_session", "")
|
||||
if session:
|
||||
agent["tmux"] = get_tmux_status(session)
|
||||
return {
|
||||
"agents": agents,
|
||||
"projects": projects,
|
||||
"logs": log_buffer[-100:],
|
||||
"time": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/agents")
|
||||
async def register_agent(data: dict):
|
||||
"""Register or update an agent."""
|
||||
agents = load_agents()
|
||||
name = data.get("name", "unnamed")
|
||||
agents[name] = {
|
||||
"name": name,
|
||||
"project": data.get("project", ""),
|
||||
"tmux_session": data.get("tmux_session", ""),
|
||||
"status": data.get("status", "idle"),
|
||||
"task": data.get("task", ""),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
"items_processed": data.get("items_processed", 0),
|
||||
**(data.get("meta", {})),
|
||||
}
|
||||
save_agents(agents)
|
||||
add_log("info", name, f"Agent registered: {data.get('status', 'idle')}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok", "agent": agents[name]}
|
||||
|
||||
|
||||
@app.post("/api/agents/{name}/status")
|
||||
async def update_agent_status(name: str, data: dict):
|
||||
agents = load_agents()
|
||||
if name not in agents:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
agents[name].update({
|
||||
"status": data.get("status", agents[name]["status"]),
|
||||
"task": data.get("task", agents[name].get("task", "")),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
"items_processed": data.get("items_processed", agents[name].get("items_processed", 0)),
|
||||
})
|
||||
if "message" in data:
|
||||
agents[name]["message"] = data["message"]
|
||||
save_agents(agents)
|
||||
add_log("info", name, f"Status: {data.get('status')} - {data.get('message', '')}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/projects")
|
||||
async def upsert_project(data: dict):
|
||||
projects = load_projects()
|
||||
name = data.get("name", "unnamed")
|
||||
# Merge with existing if present
|
||||
existing = projects.get(name, {})
|
||||
projects[name] = {
|
||||
"name": name,
|
||||
"path": data.get("path", existing.get("path", "")),
|
||||
"status": data.get("status", existing.get("status", "active")),
|
||||
"description": data.get("description", existing.get("description", "")),
|
||||
"agents": data.get("agents", existing.get("agents", [])),
|
||||
"kanban_status": data.get("kanban_status", existing.get("kanban_status", "in_progress")),
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
save_projects(projects)
|
||||
add_log("info", "project", f"Project updated: {name}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Task & Step Management ────────────────────────
|
||||
|
||||
@app.post("/api/tasks")
|
||||
async def create_task(data: dict):
|
||||
"""Create a new task for a project."""
|
||||
project_name = data.get("project", "")
|
||||
tasks = load_tasks()
|
||||
|
||||
task_id = str(int(time.time() * 1000))
|
||||
steps_raw = data.get("steps", [])
|
||||
# Support both step formats: ["name", ...] or [{"name": "...", "status": "..."}, ...]
|
||||
steps = []
|
||||
for s in steps_raw:
|
||||
if isinstance(s, str):
|
||||
steps.append({"name": s, "status": "pending", "started_at": None, "completed_at": None, "duration_seconds": 0})
|
||||
else:
|
||||
steps.append({
|
||||
"name": s.get("name", ""),
|
||||
"status": s.get("status", "pending"),
|
||||
"started_at": s.get("started_at"),
|
||||
"completed_at": s.get("completed_at"),
|
||||
"duration_seconds": s.get("duration_seconds", 0),
|
||||
})
|
||||
|
||||
task = {
|
||||
"id": task_id,
|
||||
"project": project_name,
|
||||
"title": data.get("title", "Untitled Task"),
|
||||
"description": data.get("description", ""),
|
||||
"status": "queued", # queued, running, completed, failed
|
||||
"steps": steps,
|
||||
"current_step": 0,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
}
|
||||
tasks[task_id] = task
|
||||
save_tasks(tasks)
|
||||
add_log("info", "task", f"Task created: {task['title']} for {project_name} ({len(steps)} steps)")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok", "task": task}
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/start")
|
||||
async def start_task(task_id: str):
|
||||
"""Mark a task as running and start its first step."""
|
||||
tasks = load_tasks()
|
||||
if task_id not in tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task = tasks[task_id]
|
||||
task["status"] = "running"
|
||||
task["started_at"] = datetime.now().isoformat()
|
||||
if task["steps"]:
|
||||
task["steps"][0]["status"] = "running"
|
||||
task["steps"][0]["started_at"] = datetime.now().isoformat()
|
||||
task["current_step"] = 0
|
||||
save_tasks(tasks)
|
||||
add_log("info", "task", f"Task started: {task['title']}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok", "task": task}
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/steps/{step_index}/complete")
|
||||
async def complete_step(task_id: str, step_index: int, data: dict = None):
|
||||
"""Mark a step as completed and advance to the next."""
|
||||
tasks = load_tasks()
|
||||
if task_id not in tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task = tasks[task_id]
|
||||
steps = task["steps"]
|
||||
if step_index >= len(steps):
|
||||
raise HTTPException(status_code=400, detail="Invalid step index")
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
step = steps[step_index]
|
||||
step["status"] = "completed"
|
||||
step["completed_at"] = now
|
||||
if step.get("started_at"):
|
||||
try:
|
||||
start = datetime.fromisoformat(step["started_at"])
|
||||
step["duration_seconds"] = int((datetime.now() - start).total_seconds())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start next step if exists
|
||||
next_idx = step_index + 1
|
||||
if next_idx < len(steps):
|
||||
steps[next_idx]["status"] = "running"
|
||||
steps[next_idx]["started_at"] = now
|
||||
task["current_step"] = next_idx
|
||||
else:
|
||||
# All steps done
|
||||
task["status"] = "completed"
|
||||
task["completed_at"] = now
|
||||
task["current_step"] = len(steps)
|
||||
|
||||
save_tasks(tasks)
|
||||
add_log("info", "task", f"Step {step_index+1}/{len(steps)} completed: {step['name']}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok", "task": task, "completed_step": step}
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/steps/{step_index}/fail")
|
||||
async def fail_step(task_id: str, step_index: int):
|
||||
"""Mark a step as failed."""
|
||||
tasks = load_tasks()
|
||||
if task_id not in tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task = tasks[task_id]
|
||||
step = task["steps"][step_index]
|
||||
step["status"] = "failed"
|
||||
step["completed_at"] = datetime.now().isoformat()
|
||||
if step.get("started_at"):
|
||||
try:
|
||||
step["duration_seconds"] = int((datetime.now() - datetime.fromisoformat(step["started_at"])).total_seconds())
|
||||
except Exception:
|
||||
pass
|
||||
save_tasks(tasks)
|
||||
add_log("error", "task", f"Step failed: {step['name']}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok", "step": step}
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/status")
|
||||
async def update_task_status(task_id: str, data: dict):
|
||||
"""Update task status."""
|
||||
tasks = load_tasks()
|
||||
if task_id not in tasks:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task = tasks[task_id]
|
||||
task["status"] = data.get("status", task["status"])
|
||||
if data.get("status") == "completed":
|
||||
task["completed_at"] = datetime.now().isoformat()
|
||||
save_tasks(tasks)
|
||||
add_log("info", "task", f"Task status: {task['status']}")
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok", "task": task}
|
||||
|
||||
|
||||
# ── Tmux Control ──────────────────────────────────
|
||||
|
||||
@app.get("/api/fs/list")
|
||||
async def list_directory(path: str = ""):
|
||||
"""List subdirectories for the path picker."""
|
||||
import os as _os
|
||||
target = path or str(Path.home() / "Documents" / "Projects")
|
||||
target = _os.path.expanduser(target)
|
||||
if not _os.path.isdir(target):
|
||||
return {"path": target, "parent": str(Path(target).parent), "dirs": [], "error": "Not a directory"}
|
||||
try:
|
||||
items = []
|
||||
for name in sorted(_os.listdir(target)):
|
||||
full = _os.path.join(target, name)
|
||||
if _os.path.isdir(full) and not name.startswith('.'):
|
||||
items.append({"name": name, "path": full})
|
||||
parent = str(Path(target).parent) if target != "/" else "/"
|
||||
return {"path": target, "parent": parent, "dirs": items}
|
||||
except PermissionError:
|
||||
return {"path": target, "parent": str(Path(target).parent), "dirs": [], "error": "Permission denied"}
|
||||
|
||||
|
||||
@app.post("/api/tmux/send")
|
||||
async def tmux_send_keys(data: dict):
|
||||
"""Send keys to a tmux session/window."""
|
||||
session = data.get("session", "")
|
||||
window = data.get("window", "0")
|
||||
keys = data.get("keys", "")
|
||||
if not session or not keys:
|
||||
raise HTTPException(status_code=400, detail="session and keys required")
|
||||
target = f"{session}:{window}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", target, keys, "Enter"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
add_log("info", "tmux", f"Sent to {target}: {keys[:100]}")
|
||||
return {"status": "ok", "target": target}
|
||||
except Exception as e:
|
||||
add_log("error", "tmux", str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/tmux/session")
|
||||
async def create_tmux_session(data: dict):
|
||||
"""Create a new tmux session with a Claude Code agent."""
|
||||
session_name = data.get("name", "odineye")
|
||||
project_dir = data.get("project_dir", "")
|
||||
initial_prompt = data.get("prompt", "")
|
||||
|
||||
# Check if session exists
|
||||
existing = get_tmux_status(session_name)
|
||||
if existing["running"]:
|
||||
return {"status": "exists", "session": session_name, "tmux": existing}
|
||||
|
||||
try:
|
||||
# Create detached session
|
||||
subprocess.run(
|
||||
["tmux", "new-session", "-d", "-s", session_name, "-n", "dev", "-c", project_dir or str(BASE_DIR)],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
# Send initial Claude Code command
|
||||
if initial_prompt:
|
||||
time.sleep(1) # Wait for shell to init
|
||||
escaped = initial_prompt.replace("'", "'\\''")
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", f"{session_name}:dev", "claude", "Enter"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
time.sleep(2) # Wait for Claude Code to start
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", f"{session_name}:dev", escaped, "Enter"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
add_log("info", "tmux", f"Session {session_name} created, Claude Code started")
|
||||
return {"status": "created", "session": session_name, "tmux": get_tmux_status(session_name)}
|
||||
except Exception as e:
|
||||
add_log("error", "tmux", str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/tmux/capture")
|
||||
async def tmux_capture(data: dict):
|
||||
"""Capture pane content from a tmux session."""
|
||||
session = data.get("session", "")
|
||||
window = data.get("window", "0")
|
||||
lines = int(data.get("lines", 50))
|
||||
return _capture_tmux(session, window, lines)
|
||||
|
||||
|
||||
@app.get("/api/tmux/live/{session}")
|
||||
async def tmux_live(session: str, window: str = "0", lines: int = 80):
|
||||
"""GET endpoint to capture tmux pane content (for live view)."""
|
||||
return _capture_tmux(session, window, lines)
|
||||
|
||||
|
||||
def _capture_tmux(session: str, window: str, lines: int) -> dict:
|
||||
target = f"{session}:{window}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tmux", "capture-pane", "-t", target, "-p", "-S", f"-{lines}"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
# Strip ANSI escape codes for clean display
|
||||
import re
|
||||
clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', result.stdout)
|
||||
return {"status": "ok", "content": clean, "target": target}
|
||||
except Exception as e:
|
||||
return {"status": "error", "content": str(e), "target": target}
|
||||
|
||||
|
||||
# Cache for live terminal outputs
|
||||
_tmux_cache: dict = {}
|
||||
|
||||
async def _capture_all_tmux_sessions():
|
||||
"""Periodically capture all tmux sessions for live display."""
|
||||
agents = load_agents()
|
||||
for name, agent in agents.items():
|
||||
session = agent.get("tmux_session", "")
|
||||
if session:
|
||||
window = agent.get("tmux_window", "0")
|
||||
cap = _capture_tmux(session, window, 80)
|
||||
_tmux_cache[session] = cap
|
||||
return _tmux_cache
|
||||
|
||||
|
||||
@app.post("/api/tmux/confirm")
|
||||
async def tmux_confirm(data: dict):
|
||||
"""Send confirmation (Enter, Y, or custom key) to a tmux session. Used to handle Claude Code prompts."""
|
||||
session = data.get("session", "odineye")
|
||||
window = data.get("window", "0")
|
||||
# "yes" sends "1" then Enter (selects first option), "enter" just presses Enter
|
||||
action = data.get("action", "enter")
|
||||
target = f"{session}:{window}"
|
||||
key_map = {"enter": "Enter", "yes": "1", "no": "2", "y": "y", "n": "n", "escape": "Escape", "yes_allow_all": "2"}
|
||||
key = key_map.get(action, action)
|
||||
needs_enter = action in ("yes", "y", "no", "n", "yes_allow_all")
|
||||
try:
|
||||
cmd = ["tmux", "send-keys", "-t", target, key]
|
||||
if needs_enter:
|
||||
cmd.append("Enter")
|
||||
subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
add_log("info", "tmux", f"Confirmation sent to {target}: {action} ({key})")
|
||||
return {"status": "ok", "target": target, "action": action}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/log")
|
||||
async def add_log_entry(data: dict):
|
||||
add_log(
|
||||
data.get("level", "info"),
|
||||
data.get("source", "system"),
|
||||
data.get("message", "")
|
||||
)
|
||||
await broadcast(await _build_state())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── WebSocket ─────────────────────────────────────────────
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
ws_clients.append(websocket)
|
||||
add_log("info", "ws", f"Client connected ({len(ws_clients)} total)")
|
||||
try:
|
||||
# Send initial state
|
||||
await websocket.send_json(await _build_state())
|
||||
while True:
|
||||
msg = await websocket.receive_text()
|
||||
if msg == "ping":
|
||||
await websocket.send_json(await _build_state())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if websocket in ws_clients:
|
||||
ws_clients.remove(websocket)
|
||||
|
||||
|
||||
async def _build_state() -> dict:
|
||||
agents = load_agents()
|
||||
projects = load_projects()
|
||||
for name, agent in agents.items():
|
||||
session = agent.get("tmux_session", "")
|
||||
if session:
|
||||
agent["tmux"] = get_tmux_status(session)
|
||||
# Include live terminal captures
|
||||
await _capture_all_tmux_sessions()
|
||||
# Find active task per project
|
||||
all_tasks = load_tasks()
|
||||
project_tasks = {}
|
||||
for tid, t in all_tasks.items():
|
||||
pn = t.get("project", "")
|
||||
if pn not in project_tasks:
|
||||
project_tasks[pn] = []
|
||||
project_tasks[pn].append(t)
|
||||
return {
|
||||
"agents": agents,
|
||||
"projects": projects,
|
||||
"tasks": all_tasks,
|
||||
"project_tasks": project_tasks,
|
||||
"logs": log_buffer[-100:],
|
||||
"terminals": _tmux_cache,
|
||||
"time": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
add_log("info", "system", "OpsPilot Control Center starting on port 5016")
|
||||
asyncio.create_task(_periodic_broadcast())
|
||||
|
||||
|
||||
async def _periodic_broadcast():
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
if ws_clients:
|
||||
try:
|
||||
await broadcast(await _build_state())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_log("info", "system", "Booting OpsPilot...")
|
||||
uvicorn.run(app, host="0.0.0.0", port=5016, log_level="info")
|
||||
Reference in New Issue
Block a user