From ecd2c7c6d56ca41dd1439f44c433fdca0cb11b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20SAILLANT?= <108685187+electron-rare@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:18:07 +0100 Subject: [PATCH] feat(T-AI-323): platform health endpoint + intelligence bridge - web/app/api/ops/platform/route.ts: Next.js API route probing next-js / yjs-realtime / eda-queue (BullMQ active+failed counts) - intelligence_tui.sh: web_platform_health() now calls /api/ops/platform as primary source; falls back to direct socket probes when app is down Source field distinguishes "platform-api" vs "direct-probes" in payload Co-Authored-By: Claude Sonnet 4.6 --- tools/cockpit/intelligence_tui.sh | 48 ++++++++++++++++--------- web/app/api/ops/platform/route.ts | 60 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 web/app/api/ops/platform/route.ts diff --git a/tools/cockpit/intelligence_tui.sh b/tools/cockpit/intelligence_tui.sh index 023263c..3332393 100755 --- a/tools/cockpit/intelligence_tui.sh +++ b/tools/cockpit/intelligence_tui.sh @@ -167,54 +167,70 @@ def parse_all_open_tasks(text: str) -> list[str]: def web_platform_health() -> dict[str, object]: - """Probe Next.js, Yjs realtime server, and Redis/BullMQ queue.""" + """Probe Next.js, Yjs realtime server, and Redis/BullMQ queue. + + Primary: calls /api/ops/platform on the running Next.js app (T-AI-323). + Fallback: direct socket probes when the app is not running. + """ + import json import urllib.request + platform_url = "http://localhost:3000/api/ops/platform" + try: + req = urllib.request.urlopen(platform_url, timeout=4) + data = json.loads(req.read()) + # Normalise probe keys for downstream consumers + probes = data.get("probes", {}) + return { + "status": data.get("status", "unknown"), + "probes": { + "nextjs": probes.get("next-js", {"status": "unknown"}), + "realtime": probes.get("yjs-realtime", {"status": "unknown"}), + "queue": probes.get("eda-queue", {"status": "unknown"}), + }, + "up_count": data.get("up_count", 0), + "total": data.get("total", 3), + "source": "platform-api", + } + except Exception: + pass # fall through to direct probes + + # --- Fallback: direct probes (app not running) --- probes: dict[str, object] = {} - # Next.js web app try: req = urllib.request.urlopen("http://localhost:3000/", timeout=3) probes["nextjs"] = {"status": "up", "http_status": req.status} except Exception as exc: probes["nextjs"] = {"status": "down", "error": str(exc)[:120]} - # Yjs realtime server (default port 1234, see web/realtime/server.mjs) try: req = urllib.request.urlopen("http://localhost:1234/", timeout=3) probes["realtime"] = {"status": "up", "http_status": req.status} except Exception as exc: probes["realtime"] = {"status": "down", "error": str(exc)[:120]} - # Redis / BullMQ queue depth try: import socket sock = socket.create_connection(("127.0.0.1", 6379), timeout=2) sock.sendall(b"LLEN bull:yiacad-eda:wait\r\n") data = sock.recv(256).decode("utf-8", errors="replace").strip() sock.close() - if data.startswith(":"): - depth = int(data[1:]) - probes["queue"] = {"status": "up", "queue_name": "yiacad-eda", "depth": depth} - else: - probes["queue"] = {"status": "up", "queue_name": "yiacad-eda", "depth": None, "raw": data[:80]} + depth = int(data[1:]) if data.startswith(":") else None + probes["queue"] = {"status": "up", "queue_name": "yiacad-eda", "depth": depth} except Exception as exc: probes["queue"] = {"status": "down", "error": str(exc)[:120]} - up_count = sum(1 for p in probes.values() if p.get("status") == "up") + up_count = sum(1 for p in probes.values() if isinstance(p, dict) and p.get("status") == "up") total = len(probes) - if up_count == total: - overall = "up" - elif up_count == 0: - overall = "down" - else: - overall = "degraded" + overall = "up" if up_count == total else ("down" if up_count == 0 else "degraded") return { "status": overall, "probes": probes, "up_count": up_count, "total": total, + "source": "direct-probes", } diff --git a/web/app/api/ops/platform/route.ts b/web/app/api/ops/platform/route.ts new file mode 100644 index 0000000..8ce6ec7 --- /dev/null +++ b/web/app/api/ops/platform/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { getEdaQueue } from "@/lib/eda-queue"; + +export const runtime = "nodejs"; + +type ProbeResult = { + status: "up" | "degraded" | "down"; + latency_ms?: number; + detail?: string | number; +}; + +async function probeUrl(url: string, timeoutMs = 1500): Promise { + const t0 = Date.now(); + try { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + return { status: res.ok ? "up" : "degraded", latency_ms: Date.now() - t0 }; + } catch { + return { status: "down", latency_ms: Date.now() - t0 }; + } +} + +async function probeQueue(): Promise { + try { + const queue = getEdaQueue(); + const counts = await queue.getJobCounts("wait", "active", "failed"); + const depth = counts.wait ?? 0; + const active = counts.active ?? 0; + const failed = counts.failed ?? 0; + const status = failed > 10 ? "degraded" : "up"; + return { status, detail: depth, ...(active ? { active } : {}), ...(failed ? { failed } : {}) } as ProbeResult & Record; + } catch (err) { + return { status: "down", detail: String(err) }; + } +} + +export async function GET() { + const yjsUrl = + process.env.YJS_WS_HTTP_URL ?? + `http://localhost:${process.env.YJS_WS_PORT ?? "1234"}/`; + const nextUrl = `http://localhost:${process.env.PORT ?? "3000"}/`; + + const [nextProbe, yjsProbe, queueProbe] = await Promise.all([ + probeUrl(nextUrl), + probeUrl(yjsUrl), + probeQueue(), + ]); + + const probes = { + "next-js": nextProbe, + "yjs-realtime": yjsProbe, + "eda-queue": queueProbe, + }; + + const upCount = Object.values(probes).filter((p) => p.status === "up").length; + const total = Object.keys(probes).length; + const status = + upCount === total ? "up" : upCount === 0 ? "down" : "degraded"; + + return NextResponse.json({ status, up_count: upCount, total, probes }); +}