From 8f0cafbb6779d11136bf9643e5c4399e4a502dc3 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sat, 18 Jul 2026 01:22:35 +0800 Subject: [PATCH] feat: add production health smoke --- frontend/src/app/api/health/route.ts | 72 ++++++++++++++++++++++++++ scripts/production_smoke.py | 73 +++++++++++++++++++++++++++ tests/test_production_health_smoke.py | 28 ++++++++++ 3 files changed, 173 insertions(+) create mode 100644 frontend/src/app/api/health/route.ts create mode 100644 scripts/production_smoke.py create mode 100644 tests/test_production_health_smoke.py diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts new file mode 100644 index 00000000..776dce61 --- /dev/null +++ b/frontend/src/app/api/health/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; + +type Check = { + status: "ok" | "degraded" | "blocked"; + message?: string; + latencyMs?: number; +}; + +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + +function envCheck(names: string[]): Check { + const missing = names.filter((name) => !process.env[name]); + return missing.length + ? { status: "blocked", message: `missing:${missing.join(",")}` } + : { status: "ok" }; +} + +function anyEnvCheck(names: string[]): Check { + return names.some((name) => process.env[name]) + ? { status: "ok" } + : { status: "blocked", message: `missing_one_of:${names.join("|")}` }; +} + +async function jyotishApiCheck(): Promise { + const started = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(`${jyotishApiBase}/api/health`, { + cache: "no-store", + signal: controller.signal, + }); + return { + status: response.ok ? "ok" : "degraded", + message: response.ok ? undefined : `http:${response.status}`, + latencyMs: Date.now() - started, + }; + } catch (error) { + return { + status: "blocked", + message: error instanceof Error ? error.name : "jyotish_api_unavailable", + latencyMs: Date.now() - started, + }; + } finally { + clearTimeout(timeout); + } +} + +function aggregate(checks: Record) { + if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked"; + if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded"; + return "ok"; +} + +export async function GET() { + const checks = { + web: { status: "ok" } satisfies Check, + supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), + supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]), + modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]), + jyotishApi: await jyotishApiCheck(), + }; + const status = aggregate(checks); + return NextResponse.json( + { + status, + timestamp: new Date().toISOString(), + checks, + }, + { status: status === "ok" ? 200 : 503 }, + ); +} diff --git a/scripts/production_smoke.py b/scripts/production_smoke.py new file mode 100644 index 00000000..2442fa75 --- /dev/null +++ b/scripts/production_smoke.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Production smoke check for Jyotisha web deployment.""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def fetch(url: str, timeout: float) -> tuple[int, str, float]: + started = time.monotonic() + request = Request(url, headers={"User-Agent": "jyotisha-production-smoke/1.0"}) + try: + with urlopen(request, timeout=timeout) as response: + return response.status, response.read().decode("utf-8", "replace"), time.monotonic() - started + except HTTPError as error: + return error.code, error.read().decode("utf-8", "replace"), time.monotonic() - started + except URLError as error: + raise RuntimeError(str(error.reason)) from error + + +def check(base_url: str, timeout: float) -> dict: + base = base_url.rstrip("/") + checks: list[dict] = [] + + status, body, elapsed = fetch(f"{base}/", timeout) + checks.append( + { + "name": "homepage", + "ok": status == 200 and ("Jyotisha" in body or "账户与出生资料" in body), + "status": status, + "latency_ms": round(elapsed * 1000), + } + ) + + status, body, elapsed = fetch(f"{base}/api/health", timeout) + health = json.loads(body) if body.strip().startswith("{") else {} + checks.append( + { + "name": "health", + "ok": status in {200, 503} and health.get("status") in {"ok", "degraded", "blocked"}, + "status": status, + "latency_ms": round(elapsed * 1000), + "health_status": health.get("status"), + "checks": sorted((health.get("checks") or {}).keys()), + } + ) + + return { + "base_url": base, + "ok": all(item["ok"] for item in checks), + "checks": checks, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default="https://jyotisha.chat") + parser.add_argument("--timeout", type=float, default=8.0) + args = parser.parse_args() + try: + report = check(args.base_url, args.timeout) + except Exception as error: # noqa: BLE001 - CLI smoke should report compact failure. + report = {"base_url": args.base_url, "ok": False, "error": str(error)} + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_production_health_smoke.py b/tests/test_production_health_smoke.py new file mode 100644 index 00000000..6e02ff1e --- /dev/null +++ b/tests/test_production_health_smoke.py @@ -0,0 +1,28 @@ +from scripts.production_smoke import check + + +def test_health_route_declares_required_checks() -> None: + source = open("frontend/src/app/api/health/route.ts", encoding="utf-8").read() + + for expected in ( + "supabasePublicConfig", + "supabaseServiceRole", + "modelProvider", + "jyotishApi", + "JYOTISH_API_BASE", + ): + assert expected in source + + +def test_production_smoke_accepts_health_degraded_status(monkeypatch) -> None: + def fake_fetch(url: str, timeout: float) -> tuple[int, str, float]: + if url.endswith("/api/health"): + return 503, '{"status":"blocked","checks":{"web":{},"jyotishApi":{}}}', 0.01 + return 200, "Jyotisha", 0.01 + + monkeypatch.setattr("scripts.production_smoke.fetch", fake_fetch) + + report = check("https://example.invalid", 1.0) + + assert report["ok"] is True + assert report["checks"][1]["health_status"] == "blocked"