后台新增 API 状态检测页:实时检测 Supabase/Open-Meteo/METAR/KNMI/MADIS/Telegram 连通性

This commit is contained in:
2569718930@qq.com
2026-05-18 23:25:16 +08:00
parent 5b1402fc7c
commit c38dc80f58
11 changed files with 259 additions and 9 deletions
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
const raw = await res.text();
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
return applyAuthResponseCookies(response, auth.response);
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Health check failed" }); }
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { requireOpsAdmin } from "@/lib/ops-admin";
import { HealthPageClient } from "@/components/ops/health/HealthPageClient";
export const metadata: Metadata = { title: "API 状态 — PolyWeather Ops" };
export default async function HealthPage() {
await requireOpsAdmin("/ops/health");
return <HealthPageClient />;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
export const metadata: Metadata = {
title: "PolyWeather - Global Weather Intelligence Map",
description:
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and history reconciliation.",
"PolyWeather dashboard with METAR, MGM, DEB fusion forecast, multi-model comparison, and AI weather decision cards.",
};
export default function HomePage() {
@@ -21,8 +21,8 @@ const FAQ_ITEMS = [
{
q_zh: "Pro 包含哪些功能?",
q_en: "What features does Pro include?",
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
a_en: "Unlocks: intraday METAR rule analysis (including peak window), historical reconciliation + future-date analysis, and cross-platform smart weather push.",
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、未来日期分析、城市决策卡、全平台智能气象推送。",
a_en: "Unlocks: intraday METAR rule analysis (including peak window), future-date analysis, city decision cards, and cross-platform smart weather push.",
},
{
q_zh: "当前订阅价格是多少?",
@@ -707,8 +707,8 @@ export function AccountCenter() {
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
historyFuture: isEn
? "Historical + Future-date Analysis"
: "历史对账 + 未来日期分析",
? "Future-date + Decision Card Analysis"
: "未来日期分析 + 城市决策卡",
smartPush: isEn
? "Cross-platform Smart Weather Push"
: "全平台智能气象查询",
@@ -814,8 +814,8 @@ export function AccountCenter() {
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
trialEndsSoonTitle: isEn ? "Trial ending soon" : "试用即将结束",
trialEndsSoonBody: isEn
? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and history."
: "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和历史对账。",
? "Your 3-day trial is almost over. Upgrade to Pro to keep full intraday analysis and decision cards."
: "你的 3 天试用即将结束。升级 Pro 后可继续使用完整日内分析和城市决策卡。",
trialExpiredTitle: isEn ? "Trial ended" : "试用已结束",
trialExpiredBody: isEn
? "Your trial access has ended. Renew with Pro to restore full access."
@@ -259,6 +259,21 @@ function ScanTerminalScreen() {
: selectedRow;
const scanStatus = terminalData?.status || "ready";
const staleReason = terminalData?.stale_reason || null;
const proPreviewItems = isEn
? [
"Intraday METAR rule analysis",
"Multi-model high-temp forecast",
"Real-time observation deviation",
"Future-date decision cards",
"Telegram group price $5",
]
: [
"日内机场报文规则分析",
"多模型高温预测",
"实时观测偏差",
"未来日期城市决策卡",
"Telegram 群内价 5U",
];
useEffect(() => {
if (!activeDetailRow) return;
@@ -405,6 +420,45 @@ function ScanTerminalScreen() {
userLocalTime={userLocalTime}
/>
{!isPro ? (
<section
className="scan-upgrade-announcement"
aria-label={isEn ? "Pro preview" : "Pro 能力预览"}
>
<div className="scan-upgrade-announcement-copy">
<span>{isEn ? "What Pro unlocks" : "开通 Pro 后可看到"}</span>
<strong>
{isEn
? "Full weather decision context, not just the public map."
: "不只是公开地图,而是完整天气交易辅助信息。"}
</strong>
<p>
{isEn
? "Guests and free users can browse the map. Pro adds live evidence, model deltas and city-level decision cards for current and future dates."
: "游客和免费用户可浏览地图;Pro 会补齐实时证据、模型偏差和当前/未来日期的城市决策卡。"}
</p>
</div>
<ul>
{proPreviewItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
{proAccess.authenticated ? (
<button
type="button"
className="scan-primary-button"
onClick={openScanPaywall}
>
{isEn ? "View Pro" : "查看 Pro"}
</button>
) : (
<a href={accountHref} className="scan-primary-button">
{isEn ? "Sign in for Pro" : "登录查看 Pro"}
</a>
)}
</section>
) : null}
<section className="scan-list-section">
<div className="scan-list-header">
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
type ServiceResult = { ok: boolean; status?: number; latency_ms?: number; error?: string };
type HealthPayload = { ok: boolean; checked_at: string; services: Record<string, ServiceResult> };
const LABELS: Record<string, string> = {
supabase: "Supabase",
open_meteo: "Open-Meteo",
metar: "METAR (AviationWeather)",
knmi: "KNMI (Amsterdam)",
madis: "MADIS (NOAA)",
telegram: "Telegram Bot",
};
function StatusIcon({ svc }: { svc: ServiceResult }) {
if (svc.ok) return <CheckCircle2 className="h-4 w-4 text-emerald-400" />;
if (svc.error && svc.error.includes("not configured")) return <AlertTriangle className="h-4 w-4 text-slate-500" />;
return <XCircle className="h-4 w-4 text-red-400" />;
}
function StatusText({ svc }: { svc: ServiceResult }) {
if (svc.ok) return <span className="text-xs text-emerald-400">{svc.latency_ms}ms</span>;
if (svc.error && svc.error.includes("not configured")) return <span className="text-xs text-slate-500"></span>;
return <span className="text-xs text-red-400">{svc.error ?? "连接失败"}</span>;
}
export function HealthPageClient() {
const [data, setData] = useState<HealthPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/ops/health-check", { cache: "no-store" });
if (!res.ok) { setError(`HTTP ${res.status}`); setLoading(false); return; }
setData(await res.json());
} catch (e) { setError(String(e).slice(0, 200)); }
setLoading(false);
};
useEffect(() => { void load(); }, []);
if (loading) return <div className="text-slate-400 animate-pulse">...</div>;
if (error) return <div className="text-red-400">: {error}</div>;
if (!data) return <div className="text-slate-500"></div>;
const services = Object.entries(data.services);
const okCount = services.filter(([, v]) => v.ok).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">
API {" "}
<span className={data.ok ? "text-emerald-400" : "text-red-400"}>
{data.ok ? "全部正常" : `${okCount}/${services.length} 正常`}
</span>
</h1>
<div className="flex gap-2 items-center">
<span className="text-xs text-slate-500">{data.checked_at?.slice(11, 19) ?? ""}</span>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{services.map(([key, svc]) => (
<Card key={key} className={svc.ok ? "border-emerald-400/20" : "border-red-400/30"}>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-white">{LABELS[key] ?? key}</span>
<StatusIcon svc={svc} />
</div>
<div className="flex items-center gap-2">
{svc.status ? <span className="text-xs text-slate-500">HTTP {svc.status}</span> : null}
<StatusText svc={svc} />
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
@@ -21,6 +21,7 @@ const navGroups = [
label: "监控",
items: [
{ href: "/ops/overview", icon: LayoutDashboard, label: "总览" },
{ href: "/ops/health", icon: Activity, label: "API 状态" },
{ href: "/ops/system", icon: Cpu, label: "系统状态" },
{ href: "/ops/training", icon: Database, label: "训练数据" },
{ href: "/ops/analytics", icon: BarChart3, label: "转化分析" },
@@ -60,13 +60,13 @@ const FEATURES = {
"zh-CN": [
"市场扫描台 + V4-Pro 深度复核",
"今日日内机场报文规则分析(含高温时段)",
"历史对账 + 未来日期分析",
"未来日期分析 + 城市决策卡",
"全平台智能气象推送",
],
"en-US": [
"Market Scan Terminal + V4-Pro review",
"Intraday METAR rule-based analysis with peak-time window",
"Historical reconciliation + future-date analysis",
"Future-date analysis + city decision cards",
"Cross-platform alerts",
],
};
+6
View File
@@ -7,6 +7,7 @@ from web.services.ops_api import (
extend_ops_subscription,
get_ops_analytics_funnel,
get_ops_config,
get_ops_health_check,
get_ops_logs,
get_ops_truth_history,
get_ops_weekly_leaderboard,
@@ -136,3 +137,8 @@ async def ops_logs(
lines: int = 100,
):
return get_ops_logs(request, level=level, lines=lines)
@router.get("/api/ops/health-check")
async def ops_health_check(request: Request):
return get_ops_health_check(request)
+71
View File
@@ -433,3 +433,74 @@ def get_ops_logs(
"lines": log_lines[-safe_lines:],
"total": len(log_lines),
}
def get_ops_health_check(request: Request) -> dict[str, Any]:
_require_ops(request)
import os, requests as _r, time as _time
results: dict[str, dict] = {}
timeout = 3
# Supabase
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
supabase_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if supabase_url and supabase_key:
try:
r = _r.get(f"{supabase_url}/rest/v1/", headers={"apikey": supabase_key, "Authorization": f"Bearer {supabase_key}"}, timeout=timeout)
results["supabase"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round(r.elapsed.total_seconds() * 1000)}
except Exception as e:
results["supabase"] = {"ok": False, "error": str(e)[:100]}
else:
results["supabase"] = {"ok": False, "error": "not configured"}
# Open-Meteo
try:
t0 = _time.perf_counter()
r = _r.get("https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=auto&forecast_days=1", timeout=timeout)
results["open_meteo"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)}
except Exception as e:
results["open_meteo"] = {"ok": False, "error": str(e)[:100]}
# METAR (aviationweather)
try:
t0 = _time.perf_counter()
r = _r.get("https://aviationweather.gov/api/data/metar?ids=KJFK&format=json", timeout=timeout)
results["metar"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)}
except Exception as e:
results["metar"] = {"ok": False, "error": str(e)[:100]}
# KNMI
knmi_key = str(os.getenv("KNMI_API_KEY") or "").strip()
if knmi_key:
try:
t0 = _time.perf_counter()
r = _r.get("https://api.dataplatform.knmi.nl/open-data/v1/datasets", headers={"Authorization": knmi_key}, timeout=timeout)
results["knmi"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)}
except Exception as e:
results["knmi"] = {"ok": False, "error": str(e)[:100]}
else:
results["knmi"] = {"ok": False, "error": "not configured"}
# MADIS (NOAA)
try:
t0 = _time.perf_counter()
r = _r.get("https://madis-data.ncep.noaa.gov/madisPublic1/data/LDAD/hfmetar/netCDF/", timeout=timeout)
results["madis"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)}
except Exception as e:
results["madis"] = {"ok": False, "error": str(e)[:100]}
# Telegram Bot
bot_token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
if bot_token:
try:
t0 = _time.perf_counter()
r = _r.get(f"https://api.telegram.org/bot{bot_token}/getMe", timeout=timeout)
results["telegram"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)}
except Exception as e:
results["telegram"] = {"ok": False, "error": str(e)[:100]}
else:
results["telegram"] = {"ok": False, "error": "not configured"}
all_ok = all(v.get("ok") for v in results.values())
return {"ok": all_ok, "checked_at": __import__("datetime").datetime.utcnow().isoformat() + "Z", "services": results}