后台新增 API 状态检测页:实时检测 Supabase/Open-Meteo/METAR/KNMI/MADIS/Telegram 连通性
This commit is contained in:
@@ -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" }); }
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user