Improve ops monitoring and chart loading

This commit is contained in:
2569718930@qq.com
2026-05-31 03:21:58 +08:00
parent ca72072da0
commit 2a6d8748f4
38 changed files with 3110 additions and 637 deletions
+16
View File
@@ -6,12 +6,14 @@ from web.core import GrantPointsRequest
from web.services.ops_api import (
extend_ops_subscription,
get_ops_analytics_funnel,
get_ops_billing_risk,
get_ops_config,
get_ops_sensitive_config,
get_ops_memberships_growth,
get_ops_memberships_overview,
get_ops_health_check,
get_ops_logs,
get_ops_source_health,
get_ops_truth_history,
get_ops_weekly_leaderboard,
get_ops_user_subscriptions,
@@ -92,6 +94,11 @@ async def ops_payments(request: Request, limit: int = 50):
return list_ops_payments(request, limit=limit)
@router.get("/api/ops/billing-risk")
async def ops_billing_risk(request: Request, days: int = 30, limit: int = 80):
return get_ops_billing_risk(request, days=days, limit=limit)
@router.post("/api/ops/users/grant-points")
async def ops_grant_points(request: Request, body: GrantPointsRequest):
return grant_ops_points(request, body)
@@ -213,6 +220,15 @@ async def ops_health_check(request: Request):
return get_ops_health_check(request)
@router.get("/api/ops/source-health")
async def ops_source_health(
request: Request,
cities: str = "",
limit: int = 80,
):
return get_ops_source_health(request, cities=cities, limit=limit)
@router.get("/api/ops/training/accuracy")
async def ops_training_accuracy(request: Request):
return get_ops_training_accuracy(request)
+1
View File
@@ -73,6 +73,7 @@ TRACKABLE_ANALYTICS_EVENTS = {
"trial_created",
"payment_start",
"payment_success",
"degraded_auth_profile",
"signup_completed",
"dashboard_active",
"paywall_feature_clicked",
+45
View File
@@ -67,6 +67,41 @@ _OBSERVATION_SOURCE_PROFILES: Dict[str, Dict[str, Any]] = {
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"ims": {
"label": "IMS",
"native_update_interval_sec": 600,
"fresh_window_sec": 900,
"expected_grace_sec": 600,
"stale_after_sec": 2700,
},
"madis": {
"label": "NOAA MADIS",
"native_update_interval_sec": 900,
"fresh_window_sec": 600,
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"aeroweb": {
"label": "AEROWEB",
"native_update_interval_sec": 900,
"fresh_window_sec": 900,
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"ncm": {
"label": "NCM",
"native_update_interval_sec": 900,
"fresh_window_sec": 900,
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"singapore_mss": {
"label": "Singapore MSS",
"native_update_interval_sec": 600,
"fresh_window_sec": 900,
"expected_grace_sec": 600,
"stale_after_sec": 2700,
},
"metar": {
"label": "METAR",
"native_update_interval_sec": 900,
@@ -117,6 +152,16 @@ def canonical_observation_source_code(value: Any) -> str:
return "cwa"
if "mgm" in raw:
return "mgm"
if "ims" in raw:
return "ims"
if "madis" in raw:
return "madis"
if "aeroweb" in raw:
return "aeroweb"
if "ncm" in raw:
return "ncm"
if "singapore_mss" in raw or raw == "mss":
return "singapore_mss"
if "noaa" in raw:
return "noaa"
if "wunderground" in raw or raw == "wu":
+669 -21
View File
@@ -3,13 +3,18 @@
from __future__ import annotations
import os
from typing import Any, Dict
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import HTTPException, Request
import requests as _requests
from src.database.db_manager import DBManager
from src.utils.runtime_secrets import get_runtime_secret, get_runtime_secret_status
from web.services.observation_freshness import (
build_observation_freshness,
canonical_observation_source_code,
)
from web.core import GrantPointsRequest
import web.routes as legacy_routes
@@ -20,6 +25,104 @@ def _require_ops(request: Request) -> Dict[str, Any] | None:
return legacy_routes._require_ops_admin(request)
def _parse_iso_datetime(value: Any) -> Optional[datetime]:
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _to_utc_iso(value: datetime) -> str:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _supabase_rest_rows(
table: str,
params: Dict[str, Any],
*,
timeout: int = 10,
) -> List[Dict[str, Any]]:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if not supabase_url or not service_role_key:
raise HTTPException(status_code=503, detail="Supabase not configured")
headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
}
resp = _requests.get(
f"{supabase_url}/rest/v1/{table}",
headers=headers,
params=params,
timeout=timeout,
)
if not resp.ok:
raise HTTPException(
status_code=502,
detail=f"Supabase query failed for {table}: {resp.status_code}",
)
rows = resp.json() if resp.content else []
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def _payment_explorer_url(chain: Any, tx_hash: Any) -> str:
tx = str(tx_hash or "").strip()
if not tx:
return ""
chain_text = str(chain or "").strip().lower()
base = "https://etherscan.io" if "eth" in chain_text else "https://polygonscan.com"
return f"{base}/tx/{tx}"
def _app_analytics_actor_key(row: Dict[str, Any]) -> str:
payload = row.get("payload")
payload = payload if isinstance(payload, dict) else {}
user_id = str(row.get("user_id") or payload.get("user_id") or "").strip().lower()
client_id = str(row.get("client_id") or "").strip()
session_id = str(row.get("session_id") or "").strip()
if user_id:
return f"user:{user_id}"
if client_id:
return f"client:{client_id}"
if session_id:
return f"session:{session_id}"
return f"event:{row.get('id')}"
def _risk_issue(
*,
category: str,
severity: str,
title: str,
detail: str,
user_id: Any = "",
created_at: Any = "",
reference: Any = "",
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return {
"category": category,
"severity": severity,
"title": title,
"detail": detail,
"user_id": str(user_id or ""),
"created_at": str(created_at or ""),
"reference": str(reference or ""),
"payload": payload or {},
}
def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]:
_require_ops(request)
db = DBManager()
@@ -321,35 +424,351 @@ def list_ops_payments(
) -> Dict[str, Any]:
"""List successful payment records from Supabase."""
_require_ops(request)
import os
import requests as _requests
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if not supabase_url or not service_role_key:
raise HTTPException(status_code=503, detail="Supabase not configured")
headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
}
safe_limit = max(1, min(int(limit or 50), 200))
resp = _requests.get(
f"{supabase_url}/rest/v1/payments",
headers=headers,
params={
rows = _supabase_rest_rows(
"payments",
{
"select": "id,user_id,amount,currency,chain,tx_hash,status,created_at",
"order": "created_at.desc",
"limit": str(safe_limit),
},
timeout=10,
)
rows = resp.json() if resp.ok and resp.content else []
if not isinstance(rows, list):
rows = []
return {"payments": rows, "total": len(rows)}
def get_ops_billing_risk(
request: Request,
days: int = 30,
limit: int = 80,
) -> Dict[str, Any]:
"""Summarize trial, payment, referral, and points risk signals for ops."""
_require_ops(request)
db = DBManager()
now = datetime.now(timezone.utc)
safe_days = max(1, min(int(days or 30), 120))
safe_limit = max(10, min(int(limit or 80), 200))
since_dt = now - timedelta(days=safe_days)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query_errors: List[Dict[str, str]] = []
def collect(table: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
try:
return _supabase_rest_rows(table, params)
except Exception as exc:
query_errors.append({"table": table, "error": str(exc)[:180]})
return []
intents = collect(
"payment_intents",
{
"select": (
"id,user_id,plan_code,chain_id,status,expires_at,tx_hash,"
"metadata,created_at,updated_at"
),
"order": "updated_at.desc",
"limit": str(max(safe_limit * 3, 100)),
},
)
referral_attributions = collect(
"referral_attributions",
{
"select": (
"id,referrer_user_id,referred_user_id,code,status,"
"converted_payment_intent_id,converted_tx_hash,converted_at,"
"created_at,updated_at"
),
"order": "updated_at.desc",
"limit": str(safe_limit),
},
)
referral_rewards = collect(
"referral_rewards",
{
"select": (
"id,referral_attribution_id,referrer_user_id,referred_user_id,"
"payment_intent_id,tx_hash,reward_days,reward_points,created_at"
),
"order": "created_at.desc",
"limit": str(safe_limit),
},
)
trial_claims = collect(
"trial_claims",
{
"select": "id,user_id,email,telegram_user_id,claimed_at,created_at",
"order": "created_at.desc",
"limit": str(min(safe_limit, 80)),
},
)
issues: List[Dict[str, Any]] = []
stuck_intents: List[Dict[str, Any]] = []
points_issues: List[Dict[str, Any]] = []
for intent in intents:
status = str(intent.get("status") or "").strip().lower()
updated_at = _parse_iso_datetime(intent.get("updated_at"))
created_at = _parse_iso_datetime(intent.get("created_at"))
expires_at = _parse_iso_datetime(intent.get("expires_at"))
age_min = (
int((now - (updated_at or created_at or now)).total_seconds() // 60)
if (updated_at or created_at)
else 0
)
intent_id = str(intent.get("id") or "")
user_id = str(intent.get("user_id") or "")
metadata = intent.get("metadata")
metadata = metadata if isinstance(metadata, dict) else {}
redemption = metadata.get("points_redemption")
redemption = redemption if isinstance(redemption, dict) else {}
if status == "submitted" and age_min >= 10:
row = {
"id": intent_id,
"user_id": user_id,
"plan_code": intent.get("plan_code"),
"status": status,
"age_min": age_min,
"tx_hash": intent.get("tx_hash"),
"updated_at": intent.get("updated_at"),
}
stuck_intents.append(row)
issues.append(
_risk_issue(
category="payment_intent",
severity="high",
title="Submitted intent 超过 10 分钟未确认",
detail=f"{intent_id} 已提交 {age_min} 分钟,可能需要补单或检查确认循环。",
user_id=user_id,
created_at=intent.get("updated_at") or intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
elif status == "created" and expires_at and expires_at < now:
row = {
"id": intent_id,
"user_id": user_id,
"plan_code": intent.get("plan_code"),
"status": status,
"expires_at": intent.get("expires_at"),
"age_min": age_min,
}
stuck_intents.append(row)
issues.append(
_risk_issue(
category="payment_intent",
severity="medium",
title="Created intent 已过期但未关闭",
detail=f"{intent_id} 已过期,用户可能离开支付流程。",
user_id=user_id,
created_at=intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
if bool(redemption.get("applied")):
planned = int(redemption.get("points_to_consume") or 0)
consumed = bool(redemption.get("consumed"))
consumed_points = int(redemption.get("consumed_points") or 0)
if status == "confirmed" and not consumed:
row = {
"intent_id": intent_id,
"user_id": user_id,
"status": status,
"planned_points": planned,
"consumed_points": consumed_points,
"updated_at": intent.get("updated_at"),
}
points_issues.append(row)
issues.append(
_risk_issue(
category="points_redemption",
severity="high",
title="订单已确认但积分未扣减",
detail=f"{intent_id} 标记使用积分,但 confirmed metadata 未显示 consumed。",
user_id=user_id,
created_at=intent.get("updated_at") or intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
elif status == "confirmed" and planned > 0 and 0 < consumed_points < planned:
row = {
"intent_id": intent_id,
"user_id": user_id,
"status": status,
"planned_points": planned,
"consumed_points": consumed_points,
"updated_at": intent.get("updated_at"),
}
points_issues.append(row)
issues.append(
_risk_issue(
category="points_redemption",
severity="medium",
title="积分抵扣只扣了部分积分",
detail=f"{intent_id} 计划扣 {planned},实际扣 {consumed_points}",
user_id=user_id,
created_at=intent.get("updated_at") or intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
reward_by_attribution = {
str(row.get("referral_attribution_id") or ""): row
for row in referral_rewards
if row.get("referral_attribution_id") is not None
}
monthly_cap_hits: List[Dict[str, Any]] = []
referral_settlement_issues: List[Dict[str, Any]] = []
for attribution in referral_attributions:
status = str(attribution.get("status") or "").strip().lower()
attribution_id = str(attribution.get("id") or "")
updated_at = _parse_iso_datetime(
attribution.get("updated_at") or attribution.get("converted_at") or attribution.get("created_at")
)
if status == "capped" and (not updated_at or updated_at >= month_start):
row = {
"id": attribution_id,
"code": attribution.get("code"),
"referrer_user_id": attribution.get("referrer_user_id"),
"referred_user_id": attribution.get("referred_user_id"),
"updated_at": attribution.get("updated_at"),
}
monthly_cap_hits.append(row)
issues.append(
_risk_issue(
category="referral",
severity="medium",
title="邀请奖励月度上限命中",
detail=f"邀请码 {attribution.get('code') or ''} 的推荐奖励已被月度上限拦截。",
user_id=attribution.get("referrer_user_id"),
created_at=attribution.get("updated_at") or attribution.get("created_at"),
reference=attribution_id,
payload=row,
)
)
if status == "converted" and attribution_id not in reward_by_attribution:
row = {
"id": attribution_id,
"code": attribution.get("code"),
"referrer_user_id": attribution.get("referrer_user_id"),
"referred_user_id": attribution.get("referred_user_id"),
"converted_payment_intent_id": attribution.get("converted_payment_intent_id"),
"converted_at": attribution.get("converted_at"),
}
referral_settlement_issues.append(row)
issues.append(
_risk_issue(
category="referral",
severity="high",
title="推荐已转化但没有奖励记录",
detail=f"归因 {attribution_id} 已 converted,但 referral_rewards 未找到对应记录。",
user_id=attribution.get("referrer_user_id"),
created_at=attribution.get("converted_at") or attribution.get("updated_at"),
reference=attribution_id,
payload=row,
)
)
events = db.list_app_analytics_events(limit=20000, since_iso=since_dt.isoformat())
signup_rows = [
row
for row in events
if str(row.get("event_type") or "").strip().lower()
in {"signup_success", "signup_completed"}
]
trial_actor_keys = {
_app_analytics_actor_key(row)
for row in events
if str(row.get("event_type") or "").strip().lower() == "trial_created"
}
trial_gaps: List[Dict[str, Any]] = []
for row in signup_rows[:300]:
actor_key = _app_analytics_actor_key(row)
if actor_key in trial_actor_keys:
continue
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
gap = {
"event_id": row.get("id"),
"actor_key": actor_key,
"user_id": row.get("user_id") or payload.get("user_id"),
"created_at": row.get("created_at"),
}
trial_gaps.append(gap)
if len(trial_gaps) <= 20:
issues.append(
_risk_issue(
category="signup_trial",
severity="high",
title="注册成功后未记录试用开通",
detail="该用户进入 signup_success,但同窗口内没有 trial_created 事件。",
user_id=gap.get("user_id"),
created_at=gap.get("created_at"),
reference=str(gap.get("event_id") or ""),
payload=gap,
)
)
payment_incidents = db.list_payment_audit_events(
limit=safe_limit,
event_type="payment_intent_failed",
)
unresolved_incidents = [
item
for item in payment_incidents
if not str((item.get("payload") or {}).get("resolved_at") or "").strip()
]
issues.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
recent_rewards = [
{
"id": row.get("id"),
"referral_attribution_id": row.get("referral_attribution_id"),
"referrer_user_id": row.get("referrer_user_id"),
"referred_user_id": row.get("referred_user_id"),
"payment_intent_id": row.get("payment_intent_id"),
"reward_points": int(row.get("reward_points") or 0),
"reward_days": int(row.get("reward_days") or 0),
"tx_hash": row.get("tx_hash"),
"explorer_url": _payment_explorer_url("polygon", row.get("tx_hash")),
"created_at": row.get("created_at"),
}
for row in referral_rewards[:safe_limit]
]
return {
"checked_at": _to_utc_iso(now),
"window_days": safe_days,
"summary": {
"issues": len(issues),
"stuck_intents": len(stuck_intents),
"trial_gaps": len(trial_gaps),
"payment_incidents": len(unresolved_incidents),
"points_discount_issues": len(points_issues),
"referral_settlement_issues": len(referral_settlement_issues),
"monthly_cap_hits": len(monthly_cap_hits),
"recent_referral_rewards": len(recent_rewards),
"recent_trial_claims": len(trial_claims),
},
"issues": issues[:safe_limit],
"stuck_intents": stuck_intents[:safe_limit],
"trial_gaps": trial_gaps[:safe_limit],
"payment_incidents": unresolved_incidents[:safe_limit],
"points_discount_issues": points_issues[:safe_limit],
"referral_settlement_issues": referral_settlement_issues[:safe_limit],
"monthly_cap_hits": monthly_cap_hits[:safe_limit],
"recent_referral_rewards": recent_rewards,
"recent_trial_claims": trial_claims,
"query_errors": query_errors,
}
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
admin = _require_ops(request) or {}
db = DBManager()
@@ -987,6 +1406,235 @@ def get_ops_logs(
}
def _safe_source_text(value: Any) -> str:
return str(value or "").strip()
def _source_observed_at(source: dict[str, Any]) -> Any:
for key in (
"observed_at",
"obs_time",
"report_time",
"observation_time",
"observation_time_local",
"time",
"timestamp",
):
value = source.get(key)
if _safe_source_text(value):
return value
return None
def _source_code(source: dict[str, Any], fallback: str = "") -> str:
for key in ("source_code", "source", "provider_code", "network_provider"):
value = _safe_source_text(source.get(key))
if value:
return canonical_observation_source_code(value)
return canonical_observation_source_code(fallback)
def _source_label(source: dict[str, Any], code: str, fallback: str = "") -> str:
for key in ("source_label", "station_label", "station_name", "label", "provider_label"):
value = _safe_source_text(source.get(key))
if value:
return value
return fallback or code.upper()
def _source_health_entry(
*,
city: str,
role: str,
source: dict[str, Any],
fallback_code: str = "",
fallback_label: str = "",
) -> dict[str, Any] | None:
if not isinstance(source, dict) or not source:
return None
code = _source_code(source, fallback_code)
label = _source_label(source, code, fallback_label)
observed_at = _source_observed_at(source)
freshness = source.get("freshness") if isinstance(source.get("freshness"), dict) else None
if freshness:
status = _safe_source_text(freshness.get("freshness_status")) or "unknown"
age_sec = freshness.get("age_sec")
observed_at = freshness.get("observed_at") or freshness.get("observed_at_local") or observed_at
expected_next = freshness.get("expected_next_update_at")
reason = freshness.get("freshness_reason")
else:
age_min = source.get("obs_age_min")
try:
age_min_int = int(age_min) if age_min is not None else None
except Exception:
age_min_int = None
freshness = build_observation_freshness(
source_code=code,
source_label=label,
observed_at=observed_at,
age_min=age_min_int,
)
status = str(freshness.get("freshness_status") or "unknown")
age_sec = freshness.get("age_sec")
expected_next = freshness.get("expected_next_update_at")
reason = freshness.get("freshness_reason")
return {
"city": city,
"role": role,
"source_code": code,
"source_label": label,
"station_code": source.get("station_code") or source.get("icao"),
"station_label": source.get("station_label") or source.get("station_name"),
"status": status,
"reason": reason,
"age_sec": age_sec,
"age_min": round(float(age_sec) / 60, 1) if isinstance(age_sec, (int, float)) else None,
"observed_at": observed_at,
"expected_next_update_at": expected_next,
"temp": source.get("temp"),
}
def _expected_city_source_codes(city: str, payload: dict[str, Any]) -> list[str]:
city_key = str(city or "").strip().lower().replace(" ", "")
expected: list[str] = []
if city_key in {"ankara", "istanbul"}:
expected.append("mgm")
if city_key == "amsterdam":
expected.append("knmi")
if city_key in {"telaviv", "telavivyafo"}:
expected.append("ims")
provider = canonical_observation_source_code(payload.get("official_network_source"))
if provider and provider not in {"metar", "none"}:
expected.append(provider)
return list(dict.fromkeys(expected))
def _collect_city_source_health(city: str, entry: dict[str, Any] | None) -> dict[str, Any]:
payload = (entry or {}).get("payload") if isinstance(entry, dict) else {}
if not isinstance(payload, dict):
payload = {}
sources: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
def add(role: str, source: Any, fallback_code: str = "", fallback_label: str = "") -> None:
item = _source_health_entry(
city=city,
role=role,
source=source if isinstance(source, dict) else {},
fallback_code=fallback_code,
fallback_label=fallback_label,
)
if not item:
return
key = (str(item.get("role")), str(item.get("source_code")))
if key in seen:
return
seen.add(key)
sources.append(item)
add("settlement", payload.get("current"), fallback_label="Settlement")
add("airport_metar", payload.get("airport_current"), fallback_code="metar", fallback_label="METAR")
add("airport_primary", payload.get("airport_primary"), fallback_label="Airport station")
official = payload.get("official") if isinstance(payload.get("official"), dict) else {}
add("official_airport_primary", official.get("airport_primary"), fallback_label="Official airport station")
add("official_airport_primary", official.get("airport_primary_current"), fallback_label="Official airport station")
mgm = payload.get("mgm") if isinstance(payload.get("mgm"), dict) else {}
if mgm:
mgm_current = mgm.get("current") if isinstance(mgm.get("current"), dict) else {}
add(
"official_network",
{
**mgm_current,
"source_code": "mgm",
"source_label": "MGM",
"obs_time": mgm.get("obs_time") or mgm_current.get("time"),
},
fallback_code="mgm",
fallback_label="MGM",
)
for nearby in payload.get("official_nearby") or payload.get("mgm_nearby") or []:
if isinstance(nearby, dict):
add("nearby_official", nearby, fallback_label="Nearby official")
expected_codes = _expected_city_source_codes(city, payload)
present_codes = {str(item.get("source_code") or "") for item in sources}
for code in expected_codes:
if code and code not in present_codes:
sources.append(
{
"city": city,
"role": "expected_source",
"source_code": code,
"source_label": code.upper(),
"status": "missing",
"reason": "expected_source_not_present_in_cached_detail",
"age_sec": None,
"age_min": None,
"observed_at": None,
"expected_next_update_at": None,
"temp": None,
}
)
priority = {"stale": 4, "missing": 4, "delayed": 3, "unknown": 2, "expected_wait": 1, "fresh": 0}
worst = max(sources, key=lambda item: priority.get(str(item.get("status") or ""), 2), default=None)
full_age_sec = (
round(max(0.0, __import__("time").time() - float(entry.get("updated_at_ts") or 0.0)), 1)
if entry
else None
)
return {
"city": city,
"cache_exists": bool(entry),
"cache_updated_at": entry.get("updated_at") if entry else None,
"cache_age_sec": full_age_sec,
"source_count": len(sources),
"worst_status": str(worst.get("status") if worst else "missing"),
"sources": sources,
}
def get_ops_source_health(
request: Request,
cities: str = "",
limit: int = 80,
) -> dict[str, Any]:
_require_ops(request)
requested = legacy_routes._normalize_city_list(cities) if cities else []
if requested:
selected = requested
else:
all_cities = getattr(legacy_routes, "CITIES", {})
selected = list(all_cities.keys()) if isinstance(all_cities, dict) else []
safe_limit = max(1, min(int(limit or 80), 200))
rows = []
for city in selected[:safe_limit]:
entry = legacy_routes._CACHE_DB.get_city_cache("full", city)
if not entry:
entry = legacy_routes._CACHE_DB.get_city_cache("panel", city)
rows.append(_collect_city_source_health(city, entry))
status_counts: dict[str, int] = {}
for row in rows:
for source in row.get("sources") or []:
status = str(source.get("status") or "unknown")
status_counts[status] = status_counts.get(status, 0) + 1
return {
"checked_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"cities": rows,
"status_counts": status_counts,
"total_cities": len(rows),
}
def get_ops_health_check(request: Request) -> dict[str, Any]:
_require_ops(request)
import os