Tighten AMSC ops health check
This commit is contained in:
@@ -10,7 +10,16 @@ import {
|
|||||||
ResponsiveContainer, Cell,
|
ResponsiveContainer, Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
type ServiceResult = { ok: boolean; status?: number; latency_ms?: number; error?: string };
|
type ServiceResult = {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
latency_ms?: number;
|
||||||
|
error?: string;
|
||||||
|
credential_configured?: boolean;
|
||||||
|
points?: number;
|
||||||
|
observation_time_local?: string;
|
||||||
|
sample_city?: string;
|
||||||
|
};
|
||||||
type HealthPayload = { ok: boolean; checked_at: string; services: Record<string, ServiceResult> };
|
type HealthPayload = { ok: boolean; checked_at: string; services: Record<string, ServiceResult> };
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
@@ -135,6 +144,15 @@ export function HealthPageClient() {
|
|||||||
{svc.status ? <span className="text-xs text-slate-500">HTTP {svc.status}</span> : null}
|
{svc.status ? <span className="text-xs text-slate-500">HTTP {svc.status}</span> : null}
|
||||||
<StatusText svc={svc} />
|
<StatusText svc={svc} />
|
||||||
</div>
|
</div>
|
||||||
|
{(svc.credential_configured != null || svc.points != null || svc.observation_time_local) && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-slate-500">
|
||||||
|
{svc.credential_configured != null && (
|
||||||
|
<span>{svc.credential_configured ? "凭证已配置" : "凭证未配置"}</span>
|
||||||
|
)}
|
||||||
|
{svc.points != null && <span>跑道点 {svc.points}</span>}
|
||||||
|
{svc.observation_time_local && <span>{svc.observation_time_local}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from web.services import ops_api
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_amsc_health_uses_configured_session_header(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"35R/17L": {
|
||||||
|
"RNO": "35R/17L",
|
||||||
|
"OTIME": "2026-05-30 20:03:00",
|
||||||
|
"TDZ_TEMP": "23.6",
|
||||||
|
"MID_TEMP": "-",
|
||||||
|
"END_TEMP": "23.4",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
ok = True
|
||||||
|
status_code = 200
|
||||||
|
content = b"{}"
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def fake_get(url, **kwargs):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["headers"] = kwargs.get("headers") or {}
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
session_id = "9153$$example-session"
|
||||||
|
monkeypatch.setenv("AMSC_AWOS_BASE_URL", "https://example.test/getWindPlate")
|
||||||
|
monkeypatch.setenv("POLYWEATHER_AMSC_SESSION_ID", session_id)
|
||||||
|
monkeypatch.delenv("POLYWEATHER_AMSC_COOKIE", raising=False)
|
||||||
|
monkeypatch.setattr(ops_api._requests, "get", fake_get)
|
||||||
|
|
||||||
|
result = ops_api._check_amsc_awos_health(timeout=1)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["credential_configured"] is True
|
||||||
|
assert result["points"] == 1
|
||||||
|
assert captured["url"].endswith("?cccc=ZSPD")
|
||||||
|
assert captured["headers"]["sessionId"] == session_id
|
||||||
|
assert captured["headers"]["app"] == "AMS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_amsc_health_rejects_empty_success_response(monkeypatch):
|
||||||
|
class FakeResponse:
|
||||||
|
ok = True
|
||||||
|
status_code = 200
|
||||||
|
content = b"{}"
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"code": 200, "data": {}}
|
||||||
|
|
||||||
|
monkeypatch.setenv("AMSC_AWOS_BASE_URL", "https://example.test/getWindPlate")
|
||||||
|
monkeypatch.setenv("POLYWEATHER_AMSC_SESSION_ID", "session")
|
||||||
|
monkeypatch.setattr(ops_api._requests, "get", lambda *args, **kwargs: FakeResponse())
|
||||||
|
|
||||||
|
result = ops_api._check_amsc_awos_health(timeout=1)
|
||||||
|
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["status"] == 200
|
||||||
|
assert result["error"] == "empty_or_unauthorized_response"
|
||||||
+80
-26
@@ -510,6 +510,84 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]:
|
|||||||
return {"key": key, "value": value, "ok": True}
|
return {"key": key, "value": value, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_amsc_awos_headers() -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
|
"Referer": os.getenv("AMSC_AWOS_REFERER", "https://www.amsc.net.cn/"),
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cookie = str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip()
|
||||||
|
session_id = str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip()
|
||||||
|
if cookie:
|
||||||
|
headers["Cookie"] = cookie
|
||||||
|
elif session_id:
|
||||||
|
headers["sessionId"] = session_id
|
||||||
|
headers["app"] = "AMS"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _check_amsc_awos_health(timeout: int = 8) -> dict[str, Any]:
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from src.data_collection.amsc_awos_sources import _amsc_parse_wind_plate_payload
|
||||||
|
|
||||||
|
amsc_base = str(os.getenv("AMSC_AWOS_BASE_URL") or "").strip()
|
||||||
|
if not amsc_base:
|
||||||
|
return {"ok": False, "error": "not configured"}
|
||||||
|
|
||||||
|
credential_configured = bool(
|
||||||
|
str(os.getenv("POLYWEATHER_AMSC_COOKIE") or "").strip()
|
||||||
|
or str(os.getenv("POLYWEATHER_AMSC_SESSION_ID") or "").strip()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
response = _requests.get(
|
||||||
|
f"{amsc_base}?cccc=ZSPD",
|
||||||
|
timeout=timeout,
|
||||||
|
verify=False,
|
||||||
|
headers=_build_amsc_awos_headers(),
|
||||||
|
)
|
||||||
|
latency_ms = round((_time.perf_counter() - t0) * 1000)
|
||||||
|
try:
|
||||||
|
payload = response.json() if response.content else {}
|
||||||
|
except ValueError:
|
||||||
|
payload = {}
|
||||||
|
parsed = _amsc_parse_wind_plate_payload(
|
||||||
|
payload if isinstance(payload, dict) else {},
|
||||||
|
city_key="shanghai",
|
||||||
|
icao="ZSPD",
|
||||||
|
)
|
||||||
|
points = (
|
||||||
|
((parsed or {}).get("runway_obs") or {}).get("point_temperatures")
|
||||||
|
if isinstance(parsed, dict)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
point_count = len(points or [])
|
||||||
|
ok = bool(response.ok and parsed and point_count > 0)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"ok": ok,
|
||||||
|
"status": response.status_code,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"credential_configured": credential_configured,
|
||||||
|
"points": point_count,
|
||||||
|
}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
result["sample_city"] = "shanghai"
|
||||||
|
result["observation_time_local"] = parsed.get("observation_time_local")
|
||||||
|
if not ok:
|
||||||
|
result["error"] = "empty_or_unauthorized_response"
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"credential_configured": credential_configured,
|
||||||
|
"error": str(exc)[:100],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Subscriptions ───────────────────────────────────────────────────
|
# ── Subscriptions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1070,32 +1148,8 @@ def get_ops_health_check(request: Request) -> dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
results["amos"] = {"ok": False, "error": str(e)[:100]}
|
results["amos"] = {"ok": False, "error": str(e)[:100]}
|
||||||
|
|
||||||
# AMSC AWOS (China mainland airports) — matches actual source SSL + URL pattern
|
# AMSC AWOS (China mainland airports)
|
||||||
amsc_base = str(os.getenv("AMSC_AWOS_BASE_URL") or "").strip()
|
results["amsc_awos"] = _check_amsc_awos_health(timeout=timeout)
|
||||||
if amsc_base:
|
|
||||||
try:
|
|
||||||
t0 = _time.perf_counter()
|
|
||||||
r = _r.get(
|
|
||||||
f"{amsc_base}?cccc=ZSPD",
|
|
||||||
timeout=timeout,
|
|
||||||
verify=False,
|
|
||||||
headers={
|
|
||||||
"Accept": "application/json, text/plain, */*",
|
|
||||||
"Referer": os.getenv(
|
|
||||||
"AMSC_AWOS_REFERER", "https://www.amsc.net.cn/"
|
|
||||||
),
|
|
||||||
"User-Agent": "PolyWeather/1.0",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
results["amsc_awos"] = {
|
|
||||||
"ok": r.ok,
|
|
||||||
"status": r.status_code,
|
|
||||||
"latency_ms": round((_time.perf_counter() - t0) * 1000),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
results["amsc_awos"] = {"ok": False, "error": str(e)[:100]}
|
|
||||||
else:
|
|
||||||
results["amsc_awos"] = {"ok": False, "error": "not configured"}
|
|
||||||
|
|
||||||
# NOAA WRH (US settlement verification)
|
# NOAA WRH (US settlement verification)
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user