27699a2469
Dashboard:
- New 'Always-on services' section with cards for Parakeet and Magpie
- Each card: host:port, model loaded, status pill (Healthy/Unhealthy/Starting/Not configured)
- Start, Restart, Stop buttons. Buttons disabled when not applicable for current state
- Restart counter shown when > 1 (would have surfaced the old magpie crash loop)
Backend:
- New /api/services GET: docker container state + http health for each support service
- New POST /api/services/{name}/{action} for start | stop | restart
- services.py module: docker_state, run_action via SSH
- config.py: PARAKEET_HOST/USER/CONTAINER and MAGPIE_* env vars, default to spark2_*
- health.py: use per-service hosts (no longer hard-wired to spark2_host)
Package:
- sparkConfig.yaml.ts: add 6 new optional fields
- configureSparks action: optional 'Parakeet host', 'Parakeet container', 'Magpie host', 'Magpie container' fields; descriptions explain they default to Spark 2 when blank
- Handler normalizes nulls to empty strings before merge
- main.ts: pass new env vars to container
- bump to 0.2.0:0
89 lines
3.0 KiB
Python
89 lines
3.0 KiB
Python
"""Lifecycle controls for support-service containers (Parakeet, Magpie, etc.).
|
|
|
|
These are independent always-on containers that don't go through the LLM-swap
|
|
machinery. We just run `docker start|stop|restart <container>` via SSH on the
|
|
appropriate host.
|
|
"""
|
|
from __future__ import annotations
|
|
from dataclasses import dataclass
|
|
from typing import Literal, Optional
|
|
|
|
from .config import Settings
|
|
from .ssh import ssh_run
|
|
|
|
|
|
ServiceName = Literal["parakeet", "magpie"]
|
|
ServiceAction = Literal["start", "stop", "restart"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceDef:
|
|
name: str
|
|
kind: str # 'stt' | 'tts' | …
|
|
host: str
|
|
user: str
|
|
container: str
|
|
port: int
|
|
|
|
|
|
def services_from_settings(s: Settings) -> dict[str, ServiceDef]:
|
|
return {
|
|
"parakeet": ServiceDef(
|
|
name="parakeet",
|
|
kind="stt",
|
|
host=s.parakeet_host,
|
|
user=s.parakeet_user,
|
|
container=s.parakeet_container,
|
|
port=s.parakeet_port,
|
|
),
|
|
"magpie": ServiceDef(
|
|
name="magpie",
|
|
kind="tts",
|
|
host=s.magpie_host,
|
|
user=s.magpie_user,
|
|
container=s.magpie_container,
|
|
port=s.magpie_port,
|
|
),
|
|
}
|
|
|
|
|
|
async def docker_state(settings: Settings, svc: ServiceDef) -> dict:
|
|
"""Get docker state (running, exited, restarting, etc.) + restart count."""
|
|
if not svc.host or not svc.user:
|
|
return {"state": "unconfigured", "restart_count": None, "uptime": None}
|
|
cmd = (
|
|
f"docker inspect {svc.container} "
|
|
f"--format '{{{{.State.Status}}}}|{{{{.State.StartedAt}}}}|{{{{.RestartCount}}}}|{{{{.State.ExitCode}}}}|{{{{.State.Error}}}}' "
|
|
f"2>&1 || echo 'NOT_FOUND'"
|
|
)
|
|
rc, out, _ = await ssh_run(svc.host, svc.user, cmd, settings, timeout=10)
|
|
out = out.strip()
|
|
if rc != 0 or out.startswith("NOT_FOUND") or "Error" in out and "no such object" in out.lower():
|
|
return {"state": "missing", "restart_count": None, "uptime": None, "raw": out}
|
|
parts = out.split("|")
|
|
if len(parts) < 4:
|
|
return {"state": "unknown", "raw": out}
|
|
status, started_at, restart_count, exit_code = parts[0], parts[1], parts[2], parts[3]
|
|
error = parts[4] if len(parts) > 4 else ""
|
|
return {
|
|
"state": status,
|
|
"started_at": started_at,
|
|
"restart_count": int(restart_count) if restart_count.isdigit() else None,
|
|
"exit_code": int(exit_code) if exit_code.lstrip("-").isdigit() else None,
|
|
"error": error or None,
|
|
}
|
|
|
|
|
|
async def run_action(settings: Settings, svc: ServiceDef, action: ServiceAction) -> dict:
|
|
"""Run docker start/stop/restart on the target host."""
|
|
if not svc.host or not svc.user:
|
|
return {"ok": False, "error": "service host not configured"}
|
|
cmd = f"docker {action} {svc.container}"
|
|
rc, out, err = await ssh_run(svc.host, svc.user, cmd, settings, timeout=30)
|
|
return {
|
|
"ok": rc == 0,
|
|
"rc": rc,
|
|
"stdout": out.strip(),
|
|
"stderr": err.strip(),
|
|
}
|