feat(monitoring): 监控页三分区仪表 + 上游探针(信息丰度增强)

此前监控页 6 张卡只有 2 个布尔有效(版本/运行时间/检查项三张死卡,
后端 /health 从未返回对应字段),与顶栏健康点重复。

后端:
- /health 补 version(importlib.metadata,失败降级 None)与
  uptime_seconds(monotonic,不受时钟跳变影响) —— 激活两张死卡
- 新增 GET /admin/monitoring/upstream:bzzoiro 可达性探针,
  轻量 GET 根端点,不携带 Key、不消耗采集配额;HTTP<500 视为可达
  (DNS/网络/TLS 层面),业务失败由死信与日志上报

前端: Monitoring 重排为三分区仪表
- 基础设施: 存活 / DB 就绪 / 版本+运行时间 / 上游探针(延迟)
- 数据管线: 最近采集成功率 / 死信待处理 / 数据缺口 / 统计覆盖
- LLM: 预测总数 / 平均延迟 / 有效率(<80% 警示色)
- 顶部「需要关注」聚合条: 任一异常亮红并直达处理页,全清显示状态行
- 各信号独立容错(Promise.allSettled),单项失败只降级对应卡片

测试: 新增 test_monitoring_endpoints.py 5 例(ASGITransport 真路由 +
mock 探针外呼);全量 331 passed 8 skipped
This commit is contained in:
WorkBuddy
2026-09-22 20:44:16 +08:00
parent f00bf71e8f
commit 5001093c3b
5 changed files with 414 additions and 106 deletions
+114
View File
@@ -0,0 +1,114 @@
"""监控增强端点测试:/health 扩展字段 + 上游探针。
沿用 test_api_critical.py 的模式:直接调用 handler,不启动完整 app
lifespan(异步 DB 引擎与同步 TestClient 不兼容);探针的外呼用 mock。
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
class TestHealthExtended:
async def _get_health(self):
"""ASGITransport 走真实路由:/health 无鉴权、不依赖 DB,不触发 lifespan。"""
from httpx import ASGITransport, AsyncClient
from src.api.app import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/health")
@pytest.mark.asyncio
async def test_reports_version_and_uptime(self):
resp = await self._get_health()
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "healthy"
assert isinstance(body["uptime_seconds"], int)
assert body["uptime_seconds"] >= 0
# 包已随 pip install . 安装,元数据可读;异常时为 None(前端降级隐藏)
assert body["version"] is None or isinstance(body["version"], str)
@pytest.mark.asyncio
async def test_metadata_failure_degrades_to_none(self):
"""包元数据不可读时 version=None,不影响存活判定。"""
from importlib.metadata import PackageNotFoundError
with patch("importlib.metadata.version", side_effect=PackageNotFoundError("profeto")):
resp = await self._get_health()
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "healthy"
assert body["version"] is None
assert isinstance(body["uptime_seconds"], int)
class TestUpstreamProbe:
async def _probe_with(self, mock_client_factory):
from src.api.routes import admin_monitoring
with patch.object(admin_monitoring.httpx, "AsyncClient", mock_client_factory), \
patch.object(
admin_monitoring, "get_runtime_value",
AsyncMock(return_value="https://sports.bzzoiro.com/api/v2"),
):
return await admin_monitoring.upstream_probe()
@pytest.mark.asyncio
async def test_reachable_upstream(self):
"""HTTP 200 → ok=True 且带延迟与状态码。"""
resp = MagicMock()
resp.status_code = 200
client = MagicMock()
client.get = AsyncMock(return_value=resp)
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
factory = MagicMock(return_value=client)
out = await self._probe_with(factory)
assert out["ok"] is True
assert out["status_code"] == 200
assert out["latency_ms"] >= 0
assert out["endpoint"].endswith("/api/v2")
client.get.assert_awaited_once_with("https://sports.bzzoiro.com/api/v2")
@pytest.mark.asyncio
async def test_5xx_counts_as_unreachable(self):
resp = MagicMock()
resp.status_code = 502
client = MagicMock()
client.get = AsyncMock(return_value=resp)
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
factory = MagicMock(return_value=client)
out = await self._probe_with(factory)
assert out["ok"] is False
assert out["status_code"] == 502
@pytest.mark.asyncio
async def test_network_error_degrades_gracefully(self):
"""连接失败不抛 500:返回 ok=False + error 摘要(探针失败不是故障)。"""
def factory(*_a, **_kw):
client = MagicMock()
client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
return client
out = await self._probe_with(factory)
assert out["ok"] is False
assert "connection refused" in out["error"]
assert out["latency_ms"] >= 0