#!/usr/bin/env python3 """istrain — Mill supervisor probe. Runs on the TrueNAS HOST via cron (every minute, root): docker state -> data/supervisor.json in the dashboard's snapshot shape, so the supervisor card tells Docker-era truth (it snapshotted the VM's systemd units until 2026-07-18). Read-only: can_ctl=false on every row — site control buttons are parked until a docker control relay exists (MILL-MIGRATION.md open item). Atomic write; stdlib only (host python3, no pip).""" import json, os, subprocess, tempfile, time STACK = "/mnt/.ix-apps/app_mounts/dockge/stacks/istrain" OUT = os.path.join(STACK, "data", "supervisor.json") # (container, name, job) — mirrors the VM card's rows; names kept identical where the role carried over. ROLES = [ ("istrain-web", "Dashboard", "serves this dashboard + data API (Mill :3456, the Cloudflare origin)"), ("istrain-scanhop", "UHF · 1001", "dongle 1001: 452 ⇄ 457 retune-in-place hop → BOT/EOT/MID logs + clips + live meter"), ("istrain-airband", "Voice · 1002", "dongle 1002: rail voice, 5 NS channels → mp3 clips + heard log"), ("istrain-worker", "Transcribe", "voice clips → whisper on this box (:9000) → sidecars + transcripts log"), ("istrain-eot-decode", "EOT decode", "eot_clips → unit/telemetry; enriches eot.jsonl (never culls)"), ("istrain-bot-recover", "BOT recover", "bot_clips → clean head-end frame; enriches bot.jsonl (never culls)"), ("istrain-trim", "Trim", "hourly voice-clip prune (>24h) — heard.jsonl ingest-gated, detections kept"), ("istrain-whisper", "Whisper ASR", "faster-whisper small.en (istrain-whisper stack, :9000)"), ] DOTS = {"running": ("active", "listening"), "restarting": ("failed", "stalled"), "paused": ("inactive", "held"), "exited": ("inactive", "offline")} def main(): out = subprocess.run(["docker", "ps", "-a", "--format", "{{.Names}}\t{{.State}}\t{{.Status}}"], capture_output=True, text=True).stdout have = {} for ln in out.splitlines(): p = ln.split("\t") if len(p) >= 3: have[p[0]] = (p[1], p[2]) units = [] for cid, name, job in ROLES: state, status = have.get(cid, ("absent", "no container")) st, dot = DOTS.get(state, ("inactive", "offline")) units.append({"id": cid, "name": name, "kind": "service", "job": job, "dot": dot, "state": st, "detail": status, "since": "", "can_ctl": False}) snap = {"units": units, "ts": time.time(), "controls": False} fd, tmp = tempfile.mkstemp(dir=os.path.dirname(OUT)) with os.fdopen(fd, "w") as f: json.dump(snap, f) os.replace(tmp, OUT) if __name__ == "__main__": main()