import http.client
import json
import os
import re
import socket
import ssl
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DOCKER_SOCKET = "/var/run/docker.sock"
PROJECT = os.environ.get("COMPOSE_PROJECT_NAME", "myapp")
PROJECT_LABEL = os.environ.get("PROJECT_LABEL") or PROJECT
PROJECTS = [
p.strip()
for p in os.environ.get("COMPOSE_PROJECTS", PROJECT).split(",")
if p.strip()
]
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG_DIR = os.environ.get("CONFIG_DIR", "/config")
PROBE_TIMEOUT = 3
BACKEND_STACK = os.environ.get("BACKEND_STACK", "laravel")
BACKEND_CONTAINER_PORT = int(os.environ.get("BACKEND_CONTAINER_PORT") or 8000)
MOBILE_CONTAINER_PORT = os.environ.get("MOBILE_CLIENT_PORT") or "8081"
def enabled(env_name):
val = os.environ.get(env_name, "true").lower()
if val == "auto":
return True
return val not in ("false", "0", "no", "off")
def extra_services():
out = []
for raw in os.environ.get("EXTRA_SERVICES", "").split(";"):
parts = [p.strip() for p in raw.split("|")]
if len(parts) < 3 or not parts[0]:
continue
ident, label, port = parts[0], parts[1], parts[2]
path = parts[3] if len(parts) > 3 else "/"
project, _, service = ident.partition("/")
out.append({
"id": ident.replace("/", "-"),
"service": service or project,
"project": project,
"label": label,
"group": "Other stacks",
"probe": f"http://host.docker.internal:{port}{path}",
"expect": [200],
"link": f"http://localhost:{port}",
"note": f"compose project '{project}'",
})
return out
def extra_apps():
out = []
for name in os.environ.get("EXTRA_APPS", "").split():
port = os.environ.get(f"{re.sub(r'[^A-Z0-9]', '_', name.upper())}_PORT")
if not port:
continue
if name in os.environ.get("EXTRA_APPS_MOBILE", "").split():
out.append({
"service": name,
"label": name,
"group": "Frontend",
"probe": f"http://{name}:{port}/status",
"expect": [200],
"link": f"http://localhost:{port}",
"note": "Metro",
})
else:
out.append({
"service": name,
"label": name,
"group": "Frontend",
"probe": f"http://{name}:{port}/",
"hostHeader": "localhost",
"expect": [200],
"link": f"http://localhost:{port}",
"note": "Vite dev server",
"clear": VITE_CLEAR,
})
return out
def spec_id(spec):
return spec.get("id") or spec["service"]
def spec_key(spec):
return f"{spec.get('project', PROJECT)}/{spec['service']}"
def host_url(port_env, default_port, path=""):
port = os.environ.get(port_env, default_port)
return f"http://localhost:{port}{path}"
VITE_CLEAR = (
"rm -rf /app/node_modules/.vite /app/apps/*/node_modules/.vite "
"/app/apps/*/.vite /app/apps/*/node_modules/.cache 2>/dev/null; true"
)
METRO_CLEAR = (
'rm -rf "/app/apps/${MOBILE_APP}/.expo" '
'"/app/apps/${MOBILE_APP}/node_modules/.cache" '
"/app/node_modules/.cache /tmp/metro-* /tmp/haste-* "
"/tmp/react-* /root/.expo 2>/dev/null; true"
)
BACKEND_CLEAR = (
"php artisan optimize:clear"
if BACKEND_STACK == "laravel"
else "rm -rf node_modules/.cache .cache dist/.cache 2>/dev/null; true"
)
def deploy_command(target):
return os.environ.get(f"DEPLOY_{target.upper()}_CMD") or None
def deploy_envs():
return [e for e in os.environ.get("DEPLOY_ENVS", "staging production").split() if e]
SERVICES = [
{
"service": "backend",
"label": "Backend API",
"group": "Backend",
"probe": f"http://backend:{BACKEND_CONTAINER_PORT}"
+ os.environ.get("BACKEND_HEALTH_PATH", "/api/health"),
"expect": [200, 401, 403, 405],
"accept": "application/json",
"link": host_url("BACKEND_PORT", "8000"),
"note": f"{BACKEND_STACK} backend",
"clear": BACKEND_CLEAR,
},
{
"service": "queue",
"label": "Queue worker",
"group": "Backend",
"probe": None,
"link": None,
"note": "background worker",
},
{
"service": "scheduler",
"label": "Scheduler",
"group": "Backend",
"probe": None,
"link": None,
"note": "scheduled tasks",
},
{
"service": "web",
"label": "Web app",
"group": "Frontend",
"probe": "http://web:5173/",
"hostHeader": "localhost",
"expect": [200],
"link": host_url("WEB_PORT", "5173"),
"note": "Vite dev server",
"clear": VITE_CLEAR,
"deploy": "web",
},
{
"service": "admin",
"label": "Admin",
"group": "Frontend",
"probe": "http://admin:5174/",
"hostHeader": "localhost",
"expect": [200],
"link": host_url("ADMIN_PORT", "5174"),
"note": "Vite dev server",
"clear": VITE_CLEAR,
},
{
"service": "landing",
"label": "Landing",
"group": "Frontend",
"probe": "http://landing:5175/",
"hostHeader": "localhost",
"expect": [200],
"link": host_url("LANDING_PORT", "5175"),
"note": "Vite dev server",
"clear": VITE_CLEAR,
},
{
"service": "desktop",
"label": "Desktop renderer",
"group": "Frontend",
"probe": "http://desktop:5176/",
"hostHeader": "localhost",
"expect": [200],
"link": host_url("DESKTOP_PORT", "5176"),
"note": f"{os.environ.get('DESKTOP_STACK', 'none')} · shell runs on the host (run-stack desktop)",
"clear": VITE_CLEAR,
"deploy": "desktop",
},
{
"service": "frontend-packages",
"label": "Package watcher",
"group": "Frontend",
"probe": None,
"link": None,
"note": "watches the workspace packages",
},
{
"service": "mobile-client",
"label": "Mobile client (Metro)",
"group": "Frontend",
"probe": f"http://mobile-client:{MOBILE_CONTAINER_PORT}/status",
"expect": [200],
"link": host_url("MOBILE_CLIENT_PORT", "8081"),
"note": "Metro · Expo or React Native",
"clear": METRO_CLEAR,
"reload": True,
"deploy": "mobile",
},
{
"service": "mobile-packages",
"label": "Mobile package watcher",
"group": "Frontend",
"probe": None,
"link": None,
"note": "watches the mobile packages",
},
{
"service": "mobile-deps",
"label": "Mobile dependency install",
"group": "Frontend",
"probe": None,
"link": None,
"note": "one-shot · exits when install finishes",
"oneshot": True,
},
{
"service": "frontend-deps",
"label": "Dependency install",
"group": "Frontend",
"probe": None,
"link": None,
"note": "one-shot · exits when install finishes",
"oneshot": True,
},
{
"service": "postgres",
"label": "PostgreSQL",
"group": "Infrastructure",
"probe": None,
"tcp": ("postgres", 5432),
"link": None,
"note": lambda: f"localhost:{os.environ.get('POSTGRES_PORT', '5434')} · postgres 16",
},
{
"service": "mysql",
"label": "MySQL",
"group": "Infrastructure",
"probe": None,
"tcp": ("mysql", 3306),
"link": None,
"note": lambda: f"localhost:{os.environ.get('MYSQL_PORT', '3307')} · mysql 8",
},
{
"service": "redis",
"label": "Redis",
"group": "Infrastructure",
"probe": None,
"tcp": ("redis", 6379),
"link": None,
"note": lambda: f"localhost:{os.environ.get('REDIS_PORT', '6380')} · redis 7",
},
{
"service": "mailpit",
"label": "Mailpit",
"group": "Infrastructure",
"probe": "http://mailpit:8025/",
"link": host_url("MAILPIT_UI_PORT", "8025"),
"note": "captures all outgoing mail",
},
{
"service": "minio",
"label": "MinIO",
"group": "Infrastructure",
"probe": "http://minio:9000/minio/health/live",
"expect": [200],
"link": host_url("MINIO_CONSOLE_PORT", "9001"),
"note": lambda: f"S3 api :{os.environ.get('MINIO_PORT', '9000')} · bucket {os.environ.get('MINIO_BUCKET', 'local')}",
},
] + extra_apps() + extra_services()
if not enabled("RUN_QUEUE"):
SERVICES = [s for s in SERVICES if s.get("service") != "queue"]
if not enabled("RUN_SCHEDULER"):
SERVICES = [s for s in SERVICES if s.get("service") != "scheduler"]
if not enabled("RUN_ADMIN"):
SERVICES = [s for s in SERVICES if s.get("service") != "admin"]
if not enabled("RUN_LANDING"):
SERVICES = [s for s in SERVICES if s.get("service") != "landing"]
if not enabled("RUN_MOBILE"):
SERVICES = [s for s in SERVICES if not s.get("service", "").startswith("mobile")]
if not enabled("RUN_DESKTOP"):
SERVICES = [s for s in SERVICES if s.get("service") != "desktop"]
if not enabled("RUN_REDIS"):
SERVICES = [s for s in SERVICES if s.get("service") != "redis"]
if not enabled("RUN_MAILPIT"):
SERVICES = [s for s in SERVICES if s.get("service") != "mailpit"]
if not enabled("RUN_MINIO"):
SERVICES = [s for s in SERVICES if s.get("service") != "minio"]
DB_ENGINE = os.environ.get("DB_ENGINE", "postgres")
SERVICES = [
s
for s in SERVICES
if s.get("service") not in ("postgres", "mysql") or s.get("service") == DB_ENGINE
]
class DockerConnection(http.client.HTTPConnection):
def __init__(self, timeout=PROBE_TIMEOUT):
super().__init__("localhost")
self.socket_timeout = timeout
def connect(self):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(self.socket_timeout)
sock.connect(DOCKER_SOCKET)
self.sock = sock
def docker_containers_for(project):
try:
conn = DockerConnection()
filters = urllib.parse.quote(
json.dumps({"label": [f"com.docker.compose.project={project}"]})
)
conn.request("GET", f"/v1.43/containers/json?all=1&filters={filters}")
payload = json.loads(conn.getresponse().read())
except Exception as exc:
return {}, str(exc)
out = {}
for c in payload:
labels = c.get("Labels") or {}
name = labels.get("com.docker.compose.service")
if not name:
continue
out[f"{project}/{name}"] = {
"container": (c.get("Names") or ["?"])[0].lstrip("/"),
"state": c.get("State"),
"status": c.get("Status"),
"image": c.get("Image"),
}
return out, None
def docker_containers():
out, err = {}, None
for project in PROJECTS:
part, e = docker_containers_for(project)
out.update(part)
err = err or e
return out, err
def probe_http(url, expect, host_header=None, accept=None):
parts = urllib.parse.urlsplit(url)
cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection
kwargs = {"timeout": PROBE_TIMEOUT}
if parts.scheme == "https":
kwargs["context"] = ssl._create_unverified_context()
try:
conn = cls(parts.netloc, **kwargs)
headers = {"Accept": accept or "text/html,*/*;q=0.8"}
if host_header:
headers["Host"] = host_header
conn.request("GET", parts.path or "/", headers=headers)
code = conn.getresponse().status
conn.close()
except Exception as exc:
return {"reachable": False, "detail": type(exc).__name__}
ok = code in expect if expect else code < 500
return {"reachable": ok, "code": code, "detail": f"HTTP {code}"}
def probe_tcp(host, port):
try:
with socket.create_connection((host, port), timeout=PROBE_TIMEOUT):
return {"reachable": True, "detail": f"tcp {port} open"}
except Exception as exc:
return {"reachable": False, "detail": type(exc).__name__}
def check(spec, containers):
info = containers.get(spec_key(spec))
entry = {
"service": spec_id(spec),
"label": spec["label"],
"group": spec["group"],
"link": spec.get("link"),
"note": spec["note"]() if callable(spec.get("note")) else spec.get("note"),
"container": info,
"launches": spec.get("launches") or [],
"restartable": not spec.get("oneshot"),
"clearable": bool(spec.get("clear")),
"reloadable": bool(spec.get("reload")),
"startable": True,
"shell": (
f"docker exec -it {info['container']} sh" if info else None
),
"deploy": (
{
"target": spec["deploy"],
"envs": deploy_envs(),
"defaultEnv": os.environ.get("DEPLOY_DEFAULT_ENV", "staging"),
"command": deploy_command(spec["deploy"]),
}
if spec.get("deploy") and deploy_command(spec["deploy"])
else None
),
}
state = (info or {}).get("state")
if spec.get("oneshot"):
status = (info or {}).get("status", "")
if state == "exited" and "(0)" in status:
entry["status"] = "ok"
entry["detail"] = "completed"
elif state == "running":
entry["status"] = "pending"
entry["detail"] = "installing"
elif state == "exited":
entry["status"] = "down"
entry["detail"] = status or "failed"
else:
entry["status"] = "unknown"
entry["detail"] = "not created"
return entry
if state != "running":
entry["status"] = "down"
entry["detail"] = (info or {}).get("status") or "not running"
return entry
if spec.get("probe"):
result = probe_http(spec["probe"], spec.get("expect"), spec.get("hostHeader"), spec.get("accept"))
elif spec.get("tcp"):
result = probe_tcp(*spec["tcp"])
else:
entry["status"] = "ok"
entry["detail"] = info.get("status", "running")
return entry
entry["status"] = "ok" if result["reachable"] else "pending"
entry["detail"] = result["detail"]
return entry
def build_status():
containers, docker_error = docker_containers()
with ThreadPoolExecutor(max_workers=len(SERVICES)) as pool:
services = list(pool.map(lambda s: check(s, containers), SERVICES))
return {
"project": PROJECT,
"projectLabel": PROJECT_LABEL,
"dockerError": docker_error,
"services": services,
"summary": {
"ok": sum(1 for s in services if s["status"] == "ok"),
"pending": sum(1 for s in services if s["status"] == "pending"),
"down": sum(1 for s in services if s["status"] in ("down", "unknown")),
"total": len(services),
},
}
def docker_logs(container, tail=120):
if not container:
return "no container"
try:
conn = DockerConnection()
conn.request(
"GET",
f"/v1.43/containers/{container}/logs?stdout=1&stderr=1&tail={tail}",
)
raw = conn.getresponse().read()
except Exception as exc:
return f"could not read logs: {exc}"
out, i = [], 0
while i + 8 <= len(raw):
if raw[i] in (0, 1, 2) and raw[i + 1 : i + 4] == b"\x00\x00\x00":
size = int.from_bytes(raw[i + 4 : i + 8], "big")
out.append(raw[i + 8 : i + 8 + size])
i += 8 + size
else: out.append(raw[i:])
break
text = b"".join(out).decode("utf-8", "replace") if out else raw.decode("utf-8", "replace")
return text.strip() or "(no output)"
def backend_env():
values = {}
try:
with open("/backend.env", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
values[k.strip()] = v.strip().strip('"').strip("'")
except OSError:
pass
return values
def credentials():
env = backend_env()
def expand(value):
if not isinstance(value, str):
return value
for key, val in env.items():
value = value.replace("${" + key + "}", val)
return value
def walk(node):
if isinstance(node, dict):
return {k: walk(v) for k, v in node.items()}
if isinstance(node, list):
return [walk(v) for v in node]
return expand(node)
config = {}
try:
with open(os.path.join(CONFIG_DIR, "credentials.json"), encoding="utf-8") as fh:
config = walk(json.load(fh))
except (OSError, ValueError):
config = {}
return {
"countryId": config.get("countryId"),
"countryNote": config.get("note"),
"loginEndpoint": host_url(
"BACKEND_PORT", "8000", os.environ.get("BACKEND_LOGIN_PATH", "/api/auth/login")
),
"accounts": config.get("accounts", []),
"infra": [
{
"label": "PostgreSQL",
"value": "postgresql://{u}:{p}@localhost:{port}/{db}".format(
u=os.environ.get("DB_USERNAME", "myapp"),
p=os.environ.get("DB_PASSWORD", "secret"),
port=os.environ.get("POSTGRES_PORT", "5434"),
db=os.environ.get("DB_DATABASE", "myapp"),
),
},
{"label": "Redis", "value": f"redis://localhost:{os.environ.get('REDIS_PORT', '6380')}"},
{"label": "Mailpit", "value": host_url("MAILPIT_UI_PORT", "8025")},
],
}
def open_mobile(platform):
if platform not in ("ios", "android", "ios-device", "android-device"):
return {"ok": False, "detail": "platform must be ios, android, ios-device, or android-device"}, 400
port = int(os.environ.get("HOST_OPEN_PORT", "8091"))
try:
conn = http.client.HTTPConnection(
"host.docker.internal",
port,
timeout=int(os.environ.get("HOST_OPEN_TIMEOUT", "3600")),
)
conn.request("POST", f"/{platform}")
resp = conn.getresponse()
body = json.loads(resp.read() or b"{}")
conn.close()
return body, resp.status
except Exception as exc:
return {
"ok": False,
"detail": (
f"Host opener unreachable ({type(exc).__name__}). "
"Run run-stack up so it can launch Simulator / Android."
),
}, 503
def host_up(services):
port = int(os.environ.get("HOST_OPEN_PORT", "8091"))
try:
payload = json.dumps({"services": list(services)}).encode()
conn = http.client.HTTPConnection(
"host.docker.internal",
port,
timeout=int(os.environ.get("HOST_OPEN_TIMEOUT", "3600")),
)
conn.request(
"POST",
"/up",
body=payload,
headers={"Content-Type": "application/json"},
)
resp = conn.getresponse()
body = json.loads(resp.read() or b"{}")
conn.close()
return body, resp.status
except Exception as exc:
return {
"ok": False,
"detail": (
f"Host opener unreachable ({type(exc).__name__}). "
"Run run-stack up so Start can create missing services."
),
}, 503
def docker_exec(container, cmd, timeout=120):
try:
conn = DockerConnection(timeout=timeout)
body = json.dumps(
{"AttachStdout": True, "AttachStderr": True, "Cmd": cmd}
).encode()
conn.request(
"POST",
f"/v1.43/containers/{container}/exec",
body=body,
headers={"Content-Type": "application/json"},
)
exec_id = json.loads(conn.getresponse().read())["Id"]
conn.close()
conn = DockerConnection(timeout=timeout)
conn.request(
"POST",
f"/v1.43/exec/{exec_id}/start",
body=json.dumps({"Detach": False, "Tty": True}).encode(),
headers={"Content-Type": "application/json"},
)
out = conn.getresponse().read().decode("utf-8", "replace")
conn.close()
conn = DockerConnection(timeout=timeout)
conn.request("GET", f"/v1.43/exec/{exec_id}/json")
code = json.loads(conn.getresponse().read()).get("ExitCode")
conn.close()
except Exception as exc:
return None, f"{type(exc).__name__}: {exc}"
return code, out.strip()
def running_container(spec):
containers, err = docker_containers()
if err:
return None, ({"ok": False, "detail": f"Docker unreachable: {err}"}, 503)
info = containers.get(spec_key(spec))
if not info or info.get("state") != "running":
return None, ({"ok": False, "detail": "container is not running"}, 409)
return info, None
def find_spec(service):
return next((s for s in SERVICES if spec_id(s) == service), None)
def clear_cache(service):
spec = find_spec(service)
if not spec or not spec.get("clear"):
return {"ok": False, "detail": "service has no cache to clear"}, 400
info, error = running_container(spec)
if error:
return error
script = spec["clear"].replace(
"${MOBILE_APP}", os.environ.get("MOBILE_APP", "mobile-client")
)
code, out = docker_exec(info["container"], ["sh", "-lc", script])
if code is None:
return {"ok": False, "detail": f"Could not clear cache: {out}"}, 502
if code != 0:
return {"ok": False, "detail": out or f"exit {code}"}, 500
payload, status = restart_service(service)
if not payload.get("ok"):
return {"ok": False, "detail": "Cache cleared, but restart failed: "
+ payload.get("detail", "")}, status
return {"ok": True, "detail": f"Cache cleared · {service} restarted"}, 200
def deploy(service, env):
spec = find_spec(service)
target = (spec or {}).get("deploy")
command = deploy_command(target) if target else None
if not command:
return {"ok": False, "detail": "no deploy target configured for this service"}, 400
env = env or os.environ.get("DEPLOY_DEFAULT_ENV", "staging")
if env not in deploy_envs():
return {"ok": False, "detail": f"unknown environment '{env}'"}, 400
info, error = running_container(spec)
if error:
return error
timeout = int(os.environ.get("DEPLOY_TIMEOUT", "3600"))
script = f"cd /app && export DEPLOY_ENV={env} DEPLOY_TARGET={target} && {command}"
code, out = docker_exec(info["container"], ["bash", "-lc", script], timeout=timeout)
if code is None:
return {"ok": False, "detail": f"Could not start the deploy: {out}"}, 502
tail = "\n".join(out.splitlines()[-40:])
if code != 0:
return {"ok": False, "detail": f"exit {code}", "output": tail}, 500
return {"ok": True, "detail": f"Deployed {target} → {env}", "output": tail}, 200
def restart_service(service):
spec = find_spec(service)
if not spec or spec.get("oneshot"):
return {"ok": False, "detail": "service cannot be restarted from here"}, 400
containers, err = docker_containers()
if err:
return {"ok": False, "detail": f"Docker unreachable: {err}"}, 503
info = containers.get(spec_key(spec))
if not info:
return {"ok": False, "detail": "no container — run run-stack up"}, 409
try:
conn = DockerConnection(timeout=60)
conn.request("POST", f"/v1.43/containers/{info['container']}/restart?t=10")
code = conn.getresponse().status
conn.close()
except Exception as exc:
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}, 502
if code in (204, 304):
return {"ok": True, "detail": f"Restarted {info['container']}"}, 200
return {"ok": False, "detail": f"Docker returned HTTP {code}"}, 502
def stop_service(service):
spec = find_spec(service)
if not spec or spec.get("oneshot"):
return {"ok": False, "detail": "service cannot be stopped from here"}, 400
containers, err = docker_containers()
if err:
return {"ok": False, "detail": f"Docker unreachable: {err}"}, 503
info = containers.get(spec_key(spec))
if not info:
return {"ok": False, "detail": "no container — run run-stack up"}, 409
try:
conn = DockerConnection(timeout=60)
conn.request("POST", f"/v1.43/containers/{info['container']}/stop?t=10")
code = conn.getresponse().status
conn.close()
except Exception as exc:
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}, 502
if code in (204, 304):
return {"ok": True, "detail": f"Stopped {info['container']}"}, 200
return {"ok": False, "detail": f"Docker returned HTTP {code}"}, 502
def start_service(service):
spec = find_spec(service)
if not spec:
return {"ok": False, "detail": "unknown service"}, 400
containers, err = docker_containers()
if err:
return {"ok": False, "detail": f"Docker unreachable: {err}"}, 503
info = containers.get(spec_key(spec))
if info and info.get("state") == "running":
return {"ok": True, "detail": f"{info['container']} is already running"}, 200
if info and info.get("container"):
try:
conn = DockerConnection(timeout=60)
conn.request("POST", f"/v1.43/containers/{info['container']}/start")
code = conn.getresponse().status
conn.close()
except Exception as exc:
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}, 502
if code in (204, 304):
return {"ok": True, "detail": f"Started {info['container']}"}, 200
return {"ok": False, "detail": f"Docker returned HTTP {code}"}, 502
return host_up([spec["service"]])
def metro_request(path):
try:
conn = http.client.HTTPConnection(
"mobile-client", int(MOBILE_CONTAINER_PORT), timeout=PROBE_TIMEOUT
)
conn.request("GET", path)
resp = conn.getresponse()
body = resp.read().decode("utf-8", "replace").strip()
code = resp.status
conn.close()
return code, body, None
except Exception as exc:
return None, None, f"{type(exc).__name__}: {exc}"
def reload_service(service):
spec = find_spec(service)
if not spec or not spec.get("reload"):
return {"ok": False, "detail": "service cannot be reloaded from here"}, 400
info, error = running_container(spec)
if error:
return error
status_code, status_body, err = metro_request("/status")
if err:
return {"ok": False, "detail": f"Metro unreachable: {err}"}, 502
if status_code != 200:
return {"ok": False, "detail": f"Metro /status returned HTTP {status_code}"}, 502
reload_code, _, reload_err = metro_request("/reload")
if reload_err:
return {"ok": False, "detail": f"Metro /reload failed: {reload_err}"}, 502
detail = f"Reload signal sent · {status_body or 'packager-status:running'}"
if reload_code and reload_code >= 500:
return {"ok": False, "detail": f"Metro /reload returned HTTP {reload_code}"}, 502
return {"ok": True, "detail": detail}, 200
def test_login(payload):
if not isinstance(payload, dict) or not payload:
return {"ok": False, "detail": "no login body configured for this account"}
path = os.environ.get("BACKEND_LOGIN_PATH", "/api/auth/login")
try:
conn = http.client.HTTPConnection("backend", BACKEND_CONTAINER_PORT, timeout=10)
conn.request(
"POST",
path,
body=json.dumps(payload).encode(),
headers={"Accept": "application/json", "Content-Type": "application/json"},
)
resp = conn.getresponse()
code = resp.status
body = json.loads(resp.read() or b"{}")
conn.close()
except Exception as exc:
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"}
if code == 200:
return {"ok": True, "detail": f"HTTP 200 · {path}"}
return {"ok": False, "detail": f"HTTP {code} · {body.get('message', 'login failed')}"}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args): pass
def _send(self, code, body, content_type):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _json(self, payload, code=200):
self._send(code, json.dumps(payload).encode(), "application/json")
def _static(self, name, content_type):
try:
with open(os.path.join(HERE, name), "rb") as fh:
self._send(200, fh.read(), content_type)
except OSError:
self._send(404, b"not found", "text/plain")
def do_GET(self):
parts = urllib.parse.urlsplit(self.path)
path, query = parts.path, urllib.parse.parse_qs(parts.query)
if path == "/api/status":
self._json(build_status())
elif path == "/api/credentials":
self._json(credentials())
elif path == "/api/logs":
service = (query.get("service") or [""])[0]
spec = next((s for s in SERVICES if spec_id(s) == service), None)
if not spec:
return self._json({"error": "unknown service"}, 404)
containers, _ = docker_containers()
info = containers.get(spec_key(spec))
tail = min(int((query.get("tail") or ["120"])[0]), 500)
self._json(
{
"service": service,
"container": info,
"probe": spec.get("probe") or (
"tcp://%s:%s" % spec["tcp"] if spec.get("tcp") else None
),
"logs": docker_logs((info or {}).get("container"), tail),
}
)
elif path in ("/", "/index.html"):
self._static("index.html", "text/html; charset=utf-8")
elif path == "/app.js":
self._static("app.js", "text/javascript; charset=utf-8")
elif path == "/healthz":
self._send(200, b"ok", "text/plain")
else:
self._send(404, b"not found", "text/plain")
def do_POST(self):
path = urllib.parse.urlsplit(self.path).path
length = int(self.headers.get("Content-Length") or 0)
try:
body = json.loads(self.rfile.read(length) or b"{}")
except ValueError:
return self._json({"ok": False, "detail": "invalid JSON"}, 400)
if path == "/api/open-mobile":
payload, code = open_mobile(body.get("platform", ""))
return self._json(payload, code)
if path == "/api/restart":
payload, code = restart_service(body.get("service", ""))
return self._json(payload, code)
if path == "/api/stop":
payload, code = stop_service(body.get("service", ""))
return self._json(payload, code)
if path == "/api/start":
payload, code = start_service(body.get("service", ""))
return self._json(payload, code)
if path == "/api/reload":
payload, code = reload_service(body.get("service", ""))
return self._json(payload, code)
if path == "/api/clear-cache":
payload, code = clear_cache(body.get("service", ""))
return self._json(payload, code)
if path == "/api/deploy":
payload, code = deploy(body.get("service", ""), body.get("env", ""))
return self._json(payload, code)
if path != "/api/test-login":
return self._send(404, b"not found", "text/plain")
self._json(test_login(body.get("login") or {}))
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8080"))
print(f"[dashboard] listening on 0.0.0.0:{port}", flush=True)
ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()