16 Commits
Author SHA1 Message Date
shangfangjian a7e8b75503 Merge pull request 'feat(monitoring): 监控页三分区仪表 + 上游探针' (#22) from feat-monitoring-enrich into main
Reviewed-on: #22
2026-09-22 20:46:17 +08:00
shangfangjian 575507e44f Merge branch 'main' into feat-monitoring-enrich 2026-09-22 20:46:08 +08:00
WorkBuddy 5001093c3b 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
2026-09-22 20:44:16 +08:00
shangfangjian 45535aa921 Merge pull request 'fix(admin): 数据完整性页补齐自动刷新(文案承诺但未实现)' (#21) from fix-completeness-refresh into main
Reviewed-on: #21
2026-09-22 19:54:51 +08:00
shangfangjian d7d903b35b Merge branch 'main' into fix-completeness-refresh 2026-09-22 19:54:39 +08:00
WorkBuddy a6740f6140 fix(admin): 数据完整性页补齐自动刷新(文案承诺但未实现)
页面描述写着「每 5 秒自动刷新」,实际只有挂载时加载一次 + 手动按钮
—— 采集完成后停在页面上的数字永远不动。

- 补 5s 自动轮询(可开关,复用 Logs 页的 checkbox 样式);已有数据时
  刷新不闪骨架(loading && !data 条件已具备)
- 采集页任务终态广播 profeto:ingest-done 事件,完整性页监听后
  立即刷新,不必等下一个 5s 周期
2026-09-22 19:52:39 +08:00
shangfangjian 38e1c6c31f Merge pull request 'fix(quality): 质量检查引用已删除的 MatchStats.id 必然 500 + 检查无日志' (#20) from fix-data-quality-check into main
Reviewed-on: #20
2026-09-22 18:20:49 +08:00
WorkBuddy 0742eef52e fix(quality): 质量检查引用已删除的 MatchStats.id 必然 500 + 检查无日志
生产实测: POST /admin/data-quality/run 100% 失败,且日志无任何记录。

根因链:
1. P0-02 把 MatchStats 主键改为 match_id(无 id 属性),但质量检查
   查询仍写 MatchStats.id.is_(None) → AttributeError → 500
2. handler 无 logger 调用;未捕获异常走 uvicorn.error(默认
   propagate=False),不经过 root handler → 内存日志页/文件日志
   都看不到 traceback,排障无据可查

修复:
- MatchStats.id → MatchStats.match_id(与 P0-02 模型对齐)
- 成功路径记 info 日志(检查名=通过/未通过(计数))
- setup_logging 打开 uvicorn/uvicorn.error 的 propagate,
  未捕获异常 traceback 进入内存缓冲与滚动文件
- 回归测试 3 例(mock session):修复前红(3 failed),修复后绿;
  全量 329 passed 8 skipped 零回归
2026-09-22 18:03:20 +08:00
WorkBuddy f00bf71e8f Merge pull request 'design: 全站设计打磨(语义色 token/弹窗动效与无障碍/报头收敛/交互小项)' (#19) from design-polish into main 2026-09-22 17:46:06 +08:00
WorkBuddy 246b06379d Merge pull request 'fix(admin): 日志页默认定位到最新而非最早' (#18) from fix-logs-order into main 2026-09-22 17:45:50 +08:00
WorkBuddy 3fc3e91bd3 Merge pull request 'fix(admin): /health 被误加 /api/v1 前缀,健康状态永远「系统异常」' (#17) from fix-healthcheck-url into main 2026-09-22 17:45:48 +08:00
WorkBuddy 007a276fb7 Merge pull request 'fix(docker): 预创建 /app/logs 属主,修复文件日志 PermissionError' (#16) from fix-log-volume-permission into main 2026-09-22 17:45:47 +08:00
WorkBuddy 77ecb078a3 fix(admin): 日志页改为保持倒序,打开自动定位到顶部(最新)
按用户预期调整方案:列表保持最新在最上面(后端倒序),打开页面/
自动刷新时 scrollTop=0 定位顶部;向下滚动回看历史时暂停定位,
拉回顶部即恢复。替代上一版的时间正序+贴底方案。
2026-09-22 15:39:19 +08:00
WorkBuddy 51fbc1828e fix(admin): 日志页默认定位到最新而非最早
渲染顺序(最新在前)与滚动逻辑假设(正序+贴底=最新)相互矛盾:
自动刷新(默认开启)每 10s 强制贴底,而底部恰好是最早的日志,
用户打开页面被钉在最旧的一条上。

改为渲染时间正序(旧→新),与贴底滚动/上滚暂停跟随的 tail -f
行为一致:打开页面即定位最新。
2026-09-22 15:35:59 +08:00
WorkBuddy 1422afd6f6 fix(admin): /health 请求被误加 /api/v1 前缀,健康状态永远「系统异常」
build_url 对所有路径强制加 /api/v1,但后端 /health 与 /health/ready
挂在根路径(app.py,与 compose healthcheck 一致)→ 前端请求 /api/v1/health
404 → fetchHealth 吞错返回 status=unknown → 右上角/监控页永远「系统异常」。

- build_url 对 /health 与 /health/* 豁免前缀
- http.test.ts 补 URL 豁免用例(该测试此前因 ApiError 参数属性不被
  strip-types 支持而完全无法运行,顺手改为显式赋值使基建可用)
2026-09-22 14:22:51 +08:00
WorkBuddy 7c4a244ce9 fix(docker): 预创建 /app/logs 并赋属主,修复卷挂载点 root 权限拒绝
生产实测: applogs 命名卷挂到 /app/logs 时挂载点由 Docker 以 root:root
创建,而非 root 进程(profeto)写入 app.log 时 PermissionError(Errno 13),
best-effort 降级生效但文件日志实际未启用。

- Dockerfile: 镜像内预创建 /app/logs 并 chown(chown -R 已有,覆盖新目录);
  命名卷为空且首次挂载时 Docker 会复制镜像目录属主,重建镜像即可修复
- log_buffer: PermissionError 单独捕获,warning 给出可操作的排查方向
  (镜像预建目录/chown/重建空卷),其余异常仍保持原通用降级
2026-09-22 14:14:53 +08:00
14 changed files with 589 additions and 124 deletions
+8 -1
View File
@@ -15,7 +15,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src COPY src ./src
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .
# 将工作目录所有权移交给非 root 用户 # 日志目录预创建: compose 运行时把 applogs 命名卷挂到 /app/logs,
# 卷挂载点默认由 Docker 以 root:root 创建 —— 镜像内不预建的话,
# 非 root 进程写日志会 PermissionError(Errno 13)。
# 命名卷为空且首次挂载时,Docker 会复制镜像内该目录的内容与属主,
# 因此在这里 mkdir + chown 即可让卷目录归 profeto 所有。
RUN mkdir -p /app/logs
# 将工作目录所有权移交给非 root 用户(含上面的日志目录)
RUN chown -R profeto:profeto /app RUN chown -R profeto:profeto /app
EXPOSE 8000 EXPOSE 8000
+13
View File
@@ -363,6 +363,19 @@ export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }>
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`) return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
} }
/**
* 上游数据源(bzzoiro)可达性探针:轻量 GET,不携带 Key、不消耗配额
*/
export function fetchUpstreamProbe(): Promise<{
ok: boolean
status_code?: number
latency_ms: number
endpoint: string
error?: string
}> {
return api.get<never>(`${API_BASE}/admin/monitoring/upstream`)
}
/** /**
* 采集任务状态轮询(单任务) * 采集任务状态轮询(单任务)
*/ */
+4
View File
@@ -94,6 +94,10 @@ export default function CollectionPage() {
setTaskStatus(job.status === 'success' ? 'done' : 'error') setTaskStatus(job.status === 'success' ? 'done' : 'error')
stopPolling() stopPolling()
loadRecentJobs() // 终态后刷新历史列表 loadRecentJobs() // 终态后刷新历史列表
// 广播采集终态:数据完整性等依赖页立即刷新,不必等轮询周期
window.dispatchEvent(new CustomEvent('profeto:ingest-done', {
detail: { jobId: job.id, status: job.status },
}))
} }
} catch { /* 单次轮询失败不影响后续 */ } } catch { /* 单次轮询失败不影响后续 */ }
} }
@@ -55,6 +55,9 @@ export default function DataCompletenessPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null) const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
// 页面文案承诺「每 5 秒自动刷新」,此前并未实现(仅挂载加载一次),
// 采集完成后数字不动 —— 现补齐:5s 轮询 + 采集完成事件即时刷新。
const [autoRefresh, setAutoRefresh] = useState(true)
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({}) const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -72,6 +75,20 @@ export default function DataCompletenessPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// 5s 自动轮询(与页面文案一致);已有数据时刷新不闪骨架
useEffect(() => {
if (!autoRefresh) return
const t = setInterval(load, 5_000)
return () => clearInterval(t)
}, [autoRefresh, load])
// 采集页任务终态广播 → 立即刷新(不等下一个 5s 周期)
useEffect(() => {
const onIngestDone = () => load()
window.addEventListener('profeto:ingest-done', onIngestDone)
return () => window.removeEventListener('profeto:ingest-done', onIngestDone)
}, [load])
// 点击问题项 → 滚动到对应联赛卡片并高亮 // 点击问题项 → 滚动到对应联赛卡片并高亮
const scrollToLeague = useCallback((code: string) => { const scrollToLeague = useCallback((code: string) => {
setHighlightedLeague(code) setHighlightedLeague(code)
@@ -92,9 +109,20 @@ export default function DataCompletenessPage() {
title="数据完整性" title="数据完整性"
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。" description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
action={ action={
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
5s
</label>
<button onClick={load} disabled={loading} className="btn-sm btn-outline"> <button onClick={load} disabled={loading} className="btn-sm btn-outline">
{loading ? <><Spinner /> </> : '刷新'} {loading ? <><Spinner /> </> : '刷新'}
</button> </button>
</div>
} }
/> />
+8 -7
View File
@@ -53,20 +53,21 @@ export default function LogsPage() {
}, [load]) }, [load])
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const userScrolledUp = useRef(false) const userScrolledDown = useRef(false)
// 检测用户是否向滚动 // 检测用户是否向滚动回看历史(旧日志在下方)
const handleScroll = () => { const handleScroll = () => {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50 const atTop = el.scrollTop < 50
userScrolledUp.current = !atBottom userScrolledDown.current = !atTop
} }
// 加载后自动滚动到底部(仅当用户未向上滚动时) // 列表最新在最上面(后端倒序返回);打开页面/自动刷新时定位到顶部=最新。
// 用户向下滚动回看历史时暂停定位,拉回顶部即恢复。
useEffect(() => { useEffect(() => {
if (autoRefresh && !userScrolledUp.current && scrollRef.current) { if (autoRefresh && !userScrolledDown.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight scrollRef.current.scrollTop = 0
} }
}, [entries, autoRefresh]) }, [entries, autoRefresh])
+196 -79
View File
@@ -1,20 +1,49 @@
/** /**
* Admin 后台 - 监控面板(报刊风) * Admin 后台 - 监控(报刊风·三分区)
* *
* 功能: * 此前只有存活/DB 两个布尔,与顶栏健康点重复,信息丰度不足。
* - /health 存活检查(自动:30 秒一轮;可手动刷新) * 现在聚合全站已有信号,回答三个问题:
* - /health/ready 数据库就绪检查 * 1. 基础设施活着吗 —— 服务存活 / DB 就绪 / 版本 / 运行时间 / 上游 bzzoiro 可达性
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么) * 2. 数据管线健康吗 —— 最近采集成功率 / 死信 / 数据缺口
* 3. LLM 服务正常吗 —— 预测成功率 / 平均延迟
* 顶部「需要关注」聚合条:任何一项异常即亮红并直达处理页。
* 30s 自动巡检 + 手动巡检。
*/ */
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { fetchHealth } from '../dal' import { Link } from 'react-router-dom'
import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../dal'
import type { DataCompletenessResponse, IngestFailureItem } from '../dal'
import type { IngestJob } from '../types'
import { api } from '../api' import { api } from '../api'
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components' import { Alert, SectionHeader, Spinner } from '../components'
type UpstreamProbe = { ok: boolean; status_code?: number; latency_ms: number; endpoint: string; error?: string }
type LLMStats = { total_predictions: number; avg_latency_ms: number; success_rate: number }
interface TodoItem {
key: string
label: string
to: string
}
function MetricCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400">{label}</div>
<div className="mt-2">{children}</div>
</div>
)
}
export default function MonitoringPage() { export default function MonitoringPage() {
const [health, setHealth] = useState<any>(null) const [health, setHealth] = useState<Record<string, unknown> | null>(null)
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null) const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
const [upstream, setUpstream] = useState<UpstreamProbe | null>(null)
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
const [llmStats, setLlmStats] = useState<LLMStats | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [lastCheck, setLastCheck] = useState<string>('') const [lastCheck, setLastCheck] = useState<string>('')
@@ -22,20 +51,28 @@ export default function MonitoringPage() {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { // 各信号独立容错:单项失败只降级对应卡片
const [h, r] = await Promise.allSettled([ const [h, r, u, j, f, c, l] = await Promise.allSettled([
fetchHealth(), fetchHealth(),
api.get<{ status: string }>('/health/ready'), api.get<{ status: string }>('/health/ready'),
fetchUpstreamProbe(),
fetchIngestJobs({ limit: 10 }),
fetchIngestFailures(),
fetchDataCompleteness(),
fetchLLMUsageStats() as Promise<LLMStats>,
]) ])
setHealth(h.status === 'fulfilled' ? h.value : null) setHealth(h.status === 'fulfilled' ? h.value : null)
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null) setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
setUpstream(u.status === 'fulfilled' ? u.value : null)
setRecentJobs(j.status === 'fulfilled' && Array.isArray(j.value) ? j.value : null)
setFailures(f.status === 'fulfilled' ? f.value : [])
setCompleteness(c.status === 'fulfilled' ? c.value : null)
setLlmStats(l.status === 'fulfilled' ? l.value : null)
if (h.status === 'rejected') { if (h.status === 'rejected') {
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端') setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
} }
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false })) setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
} finally {
setLoading(false) setLoading(false)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -45,108 +82,188 @@ export default function MonitoringPage() {
}, [refresh]) }, [refresh])
const alive = health?.status === 'healthy' || health?.status === 'ok' const alive = health?.status === 'healthy' || health?.status === 'ok'
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
const missingStatsLeagues = completeness?.leagues.filter(
l => l.matches.finished > 0 && l.stats.rows === 0,
).length ?? 0
const recentFailedJobs = useMemo(
() => (recentJobs ?? []).filter(j => j.status === 'failed').length,
[recentJobs],
)
// ── 「需要关注」聚合:任何一项异常即亮红 ──
const todos: TodoItem[] = [
!alive && { key: 'alive', label: '服务存活异常', to: '/admin/logs' },
ready === 'not_ready' && { key: 'db', label: '数据库未就绪', to: '/admin/logs' },
upstream && !upstream.ok && { key: 'upstream', label: '上游 bzzoiro 不可达', to: '/admin/logs' },
recentFailedJobs > 0 && { key: 'jobs', label: `最近采集失败 ${recentFailedJobs}`, to: '/admin/collection' },
deadLetterCount > 0 && { key: 'deadletter', label: `死信待处理 ${deadLetterCount}`, to: '/admin/data-pipeline' },
missingStatsLeagues > 0 && { key: 'missing', label: `${missingStatsLeagues} 个联赛缺统计`, to: '/admin/data-completeness' },
].filter((t): t is TodoItem => t !== false)
const okJobs = (recentJobs ?? []).filter(j => j.status === 'success').length
const runJobs = (recentJobs ?? []).filter(j => j.status === 'success' || j.status === 'failed').length
const lastJobTime = recentJobs?.[0]?.created_at
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <SectionHeader
title="系统监控" title="系统监控"
description="存活与数据库就绪检查,每 30 秒自动巡检一次。" description="基础设施 / 数据管线 / LLM 三区巡检,每 30 秒自动刷新。"
/> action={
<div className="flex items-center gap-2">
<div className="flex items-center justify-between"> <span className="text-2xs text-ink-400">{lastCheck && `最近巡检 ${lastCheck}`}</span>
<span className="text-2xs text-ink-400">
{lastCheck && `最近巡检 ${lastCheck}`}
</span>
<button onClick={refresh} disabled={loading} className="btn btn-sm"> <button onClick={refresh} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '立即巡检'} {loading ? (<><Spinner /> </>) : '立即巡检'}
</button> </button>
</div> </div>
}
/>
{error && ( {error && (
<Alert <Alert kind="error" title="无法连接到后端" message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`} />
kind="error"
title="无法连接到后端"
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`}
/>
)} )}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {/* ── 需要关注:任何一项异常即亮红并直达处理页 ── */}
{/* 存活状态 */} {!loading && todos.length > 0 ? (
<div className="border border-ink-900 bg-paper-50 p-5"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div> {todos.map(t => (
<div className="mt-2 flex items-center gap-2"> <Link
<span key={t.key}
className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`} to={t.to}
aria-hidden="true" className="flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
/> >
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-press" aria-hidden="true" />
<span className="text-xs text-ink-800">{t.label}</span>
<span className="ml-auto text-press" aria-hidden="true"></span>
</Link>
))}
</div>
) : (
!loading && (
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
各项巡检正常:服务
</p>
)
)}
{/* ── 基础设施 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="存活状态">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`} aria-hidden="true" />
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}> <span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
{health ? (alive ? '正常' : String(health.status)) : '—'} {health ? (alive ? '正常' : String(health.status)) : '—'}
</span> </span>
</div> </div>
</div> </MetricCard>
{/* 数据库就绪 */} <MetricCard label="数据库就绪">
<div className="border border-ink-900 bg-paper-50 p-5"> <div className="flex items-center gap-2">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div> <span className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`} aria-hidden="true" />
<div className="mt-2 flex items-center gap-2"> <span className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}>
<span
className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`}
aria-hidden="true"
/>
<span
className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}
>
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'} {ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
</span> </span>
</div> </div>
</div> </MetricCard>
{/* 服务名 */} <MetricCard label="版本 / 运行时间">
<div className="border border-ink-900 bg-paper-50 p-5"> <div className="font-serif text-xl font-bold tabular-nums text-ink-900">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div> {health?.version ? String(health.version) : '—'}
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
{health?.service || 'profeto'}
</div> </div>
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
{health?.uptime_seconds != null
? `已运行 ${Math.floor(Number(health.uptime_seconds) / 3600)}h ${Math.floor((Number(health.uptime_seconds) % 3600) / 60)}m`
: '—'}
</div> </div>
</MetricCard>
{/* 版本 */} <MetricCard label="上游 bzzoiro">
{health?.version && ( {upstream ? (
<div className="border border-ink-900 bg-paper-50 p-5"> <div>
<div className="text-2xs tracking-[0.2em] text-ink-400"></div> <div className="flex items-center gap-2">
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900"> <span className={`inline-block h-2 w-2 ${upstream.ok ? 'bg-ok-500' : 'bg-press'}`} aria-hidden="true" />
{health.version} <span className={`font-serif text-xl font-bold ${upstream.ok ? 'text-ink-900' : 'text-press'}`}>
{upstream.ok ? '可达' : '不可达'}
</span>
</div>
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
{upstream.ok
? `HTTP ${upstream.status_code} · ${upstream.latency_ms}ms`
: upstream.error?.slice(0, 40) || `HTTP ${upstream.status_code}`}
</div> </div>
</div> </div>
) : (
<span className="font-serif text-xl text-ink-300"></span>
)} )}
</MetricCard>
</div>
</section>
{/* 运行时间 */} {/* ── 数据管线 ── */}
{health?.uptime_seconds != null && ( <section>
<div className="border border-ink-900 bg-paper-50 p-5"> <h2 className="section-head mb-3">线</h2>
<div className="text-2xs tracking-[0.2em] text-ink-400"></div> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900"> <MetricCard label="最近采集">
{Math.floor(health.uptime_seconds / 3600)}h{' '} {recentJobs && runJobs > 0 ? (
{Math.floor((health.uptime_seconds % 3600) / 60)}m <div className="font-serif text-xl font-bold tabular-nums text-ink-900">
</div> {okJobs}/{runJobs} <span className="text-xs font-normal text-ink-500"></span>
</div> </div>
) : (
<div className="font-serif text-xl text-ink-300"></div>
)} )}
<div className="mt-0.5 text-2xs text-ink-400">
{lastJobTime ? `最近 ${new Date(lastJobTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}` : '暂无记录'}
</div>
</MetricCard>
{/* 检查项 */} <MetricCard label="死信待处理">
{health?.checks && Object.keys(health.checks).length > 0 && ( <div className={`font-serif text-xl font-bold tabular-nums ${deadLetterCount > 0 ? 'text-press' : 'text-ink-900'}`}>
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1"> {deadLetterCount}
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 space-y-1">
{Object.entries(health.checks).map(([key, val]) => (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-2xs text-ink-500">{key}</span>
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
{String(val)}
</Badge>
</div> </div>
))} <div className="mt-0.5 text-2xs text-ink-400">,</div>
</MetricCard>
<MetricCard label="数据缺口">
<div className={`font-serif text-xl font-bold tabular-nums ${missingStatsLeagues > 0 ? 'text-press' : 'text-ink-900'}`}>
{missingStatsLeagues}
</div> </div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</MetricCard>
<MetricCard label="统计覆盖">
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{completeness ? `${completeness.totals.stats_coverage_pct}%` : '—'}
</div> </div>
)} <div className="mt-0.5 text-2xs text-ink-400"> / </div>
</MetricCard>
</div> </div>
</section>
{/* ── LLM ── */}
<section>
<h2 className="section-head mb-3">LLM </h2>
<div className="grid gap-4 sm:grid-cols-3">
<MetricCard label="预测总数">
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{llmStats ? llmStats.total_predictions : '—'}
</div>
</MetricCard>
<MetricCard label="平均延迟">
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{llmStats && llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
</div>
</MetricCard>
<MetricCard label="有效率">
<div className={`font-serif text-xl font-bold tabular-nums ${llmStats && llmStats.success_rate < 80 ? 'text-warn-700' : 'text-ink-900'}`}>
{llmStats ? `${llmStats.success_rate.toFixed(0)}%` : '—'}
</div>
</MetricCard>
</div>
</section>
</div> </div>
) )
} }
+19 -1
View File
@@ -21,8 +21,10 @@ globalThis.window = {
} }
// 捕获每次 fetch 的入参供断言 // 捕获每次 fetch 的入参供断言
let lastUrl = ''
let lastInit: RequestInit | undefined let lastInit: RequestInit | undefined
globalThis.fetch = async (_url: string, init?: RequestInit) => { globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => {
lastUrl = String(url)
lastInit = init lastInit = init
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }) return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
} }
@@ -63,3 +65,19 @@ test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => {
assert.equal(lastInit?.body, undefined) assert.equal(lastInit?.body, undefined)
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined) assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
}) })
// ── 健康检查豁免: /health* 挂在根路径(后端 app.py 不在 /api/v1 下),
// 加前缀会 404 → 管理后台右上角永远「系统异常」 ──
test('build_url: /health 与 /health/ready 不加 /api/v1 前缀', async () => {
await http.get('/health')
assert.equal(lastUrl, '/health')
await http.get('/health/ready')
assert.equal(lastUrl, '/health/ready')
})
test('build_url: 常规 API 路径仍统一加 /api/v1', async () => {
await http.get('/admin/stats')
assert.equal(lastUrl, '/api/v1/admin/stats')
await http.get('/matches')
assert.equal(lastUrl, '/api/v1/matches')
})
+13 -5
View File
@@ -17,22 +17,30 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
const API_BASE = '/api/v1' const API_BASE = '/api/v1'
const DEFAULT_TIMEOUT = 30_000 const DEFAULT_TIMEOUT = 30_000
/** 健康检查端点挂在根路径(app.py 不在 /api/v1 下,与 compose healthcheck 一致);
* 加前缀会 404,导致管理后台健康状态永远显示「系统异常」 */
const ROOT_ONLY_PREFIXES = ['/health']
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */ /** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
function build_url(path: string): string { function build_url(path: string): string {
if (path.startsWith('http')) return path if (path.startsWith('http')) return path
if (ROOT_ONLY_PREFIXES.some(p => path === p || path.startsWith(`${p}/`))) return path
if (path.startsWith(API_BASE)) return path if (path.startsWith(API_BASE)) return path
if (path.startsWith('/')) return `${API_BASE}${path}` if (path.startsWith('/')) return `${API_BASE}${path}`
return `${API_BASE}/${path}` return `${API_BASE}/${path}`
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor( status: number
message: string, data?: unknown
public status: number,
public data?: unknown, constructor(message: string, status: number, data?: unknown) {
) {
super(message) super(message)
this.name = 'ApiError' this.name = 'ApiError'
// 显式赋值而非构造函数参数属性(public x):strip-types 不支持后者,
// 会让 node --experimental-strip-types 跑 lib/http.test.ts 直接失败
this.status = status
this.data = data
} }
} }
+21 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import os import os
import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -13,6 +14,9 @@ from src.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 进程启动时间(monotonic,不受系统时钟跳变影响):/health 的 uptime 来源
_PROCESS_STARTED_MONOTONIC = time.monotonic()
async def _fail_stale_ingest_jobs() -> None: async def _fail_stale_ingest_jobs() -> None:
"""P1-E: 启动时将上次遗留的 pending/running ingest_jobs 标 failed。 """P1-E: 启动时将上次遗留的 pending/running ingest_jobs 标 failed。
@@ -156,6 +160,7 @@ def create_app() -> FastAPI:
from src.api.routes.auth import router as auth_router from src.api.routes.auth import router as auth_router
from src.api.routes.admin_settings import router as admin_settings_router from src.api.routes.admin_settings import router as admin_settings_router
from src.api.routes.schedules import router as schedules_router from src.api.routes.schedules import router as schedules_router
from src.api.routes.admin_monitoring import router as admin_monitoring_router
app.include_router(matches_router) app.include_router(matches_router)
app.include_router(predict_router) app.include_router(predict_router)
@@ -165,10 +170,25 @@ def create_app() -> FastAPI:
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_settings_router) app.include_router(admin_settings_router)
app.include_router(schedules_router) app.include_router(schedules_router)
app.include_router(admin_monitoring_router)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "healthy", "service": "profeto"} """存活检查。version/uptime_seconds 供管理端监控页展示。"""
version = None
try:
from importlib.metadata import version as _pkg_version
version = _pkg_version("profeto")
except Exception:
# 包元数据缺失时返回 None,前端降级隐藏版本卡片
version = None
return {
"status": "healthy",
"service": "profeto",
"version": version,
"uptime_seconds": round(time.monotonic() - _PROCESS_STARTED_MONOTONIC),
}
@app.get("/health/ready") @app.get("/health/ready")
+44
View File
@@ -0,0 +1,44 @@
"""后台管理:监控增强探针(上游可达性)。
所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。
"""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Depends
from src.api.deps import require_admin
from src.core.config import settings
from src.core.runtime_config import get_runtime_value
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/monitoring/upstream")
async def upstream_probe():
"""上游数据源(bzzoiro)可达性探针。
轻量 GET 根端点:不携带 API Key、不触发采集,不消耗配额。
HTTP <500 视为可达 —— 404/401 也说明 DNS/网络/TLS 正常,业务语义层的
失败(签名、限流)由采集管线自身的死信与日志上报,不在探针职责内。
"""
base = ((await get_runtime_value("BZZOIRO_BASE")) or settings.BZZOIRO_BASE).rstrip("/")
started = time.perf_counter()
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0),
) as client:
resp = await client.get(base)
latency_ms = round((time.perf_counter() - started) * 1000)
ok = resp.status_code < 500
logger.info("上游探针 %s%s (%sms)", base, resp.status_code, latency_ms)
return {"ok": ok, "status_code": resp.status_code, "latency_ms": latency_ms, "endpoint": base}
except Exception as e:
latency_ms = round((time.perf_counter() - started) * 1000)
logger.warning("上游探针失败 %s: %s", base, e)
return {"ok": False, "error": str(e)[:200], "latency_ms": latency_ms, "endpoint": base}
+9 -1
View File
@@ -234,13 +234,14 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
checks = [] checks = []
# 检查1: 已完赛但无统计的比赛 # 检查1: 已完赛但无统计的比赛
# 注意: MatchStats 主键是 match_id(P0-02),不是 id —— 引用 .id 会 AttributeError
finished_no_stats = ( finished_no_stats = (
await db.execute( await db.execute(
select(func.count()) select(func.count())
.select_from(Match) .select_from(Match)
.outerjoin(MatchStats, Match.id == MatchStats.match_id) .outerjoin(MatchStats, Match.id == MatchStats.match_id)
.where(Match.match_status == "finished") .where(Match.match_status == "finished")
.where(MatchStats.id.is_(None)) .where(MatchStats.match_id.is_(None))
) )
).scalar() or 0 ).scalar() or 0
@@ -276,6 +277,13 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
db.add(c) db.add(c)
await db.commit() await db.commit()
# 成功路径留痕:检查何时跑过、各项结果如何(此前 handler 无任何日志,
# 加上未捕获异常走 uvicorn.error 不进内存缓冲,线上排障无据可查)
logger.info(
"数据质量检查完成: %s",
"; ".join(f"{c.check_name}={'通过' if c.passed else '未通过'}({c.actual_value:.0f})" for c in checks),
)
return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]} return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]}
+17
View File
@@ -80,6 +80,14 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
if root.level == logging.NOTSET or root.level > logging.INFO: if root.level == logging.NOTSET or root.level > logging.INFO:
root.setLevel(getattr(logging, level.upper(), logging.INFO)) root.setLevel(getattr(logging, level.upper(), logging.INFO))
# uvicorn 的 logger 默认 propagate=False:未捕获异常的 traceback 只进
# stderr,不经过 root 的任何 handler —— Admin 日志页与文件日志都看不到,
# 线上 500 排障无据可查。打开 propagate 让它们进入内存缓冲/滚动文件。
for uv_name in ("uvicorn", "uvicorn.error"):
uv_logger = logging.getLogger(uv_name)
if not uv_logger.propagate:
uv_logger.propagate = True
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers): if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
handler = MemoryLogHandler() handler = MemoryLogHandler()
handler.setLevel(logging.INFO) handler.setLevel(logging.INFO)
@@ -112,6 +120,15 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
file_handler.addFilter(_SQLNoiseFilter()) file_handler.addFilter(_SQLNoiseFilter())
root.addHandler(file_handler) root.addHandler(file_handler)
logging.getLogger(__name__).info("文件日志已启用: %s", log_file) logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
except PermissionError:
# 容器场景最常见:挂载卷目录属主是 root,进程是非 root 用户。
# 修复方向:镜像内预创建目录并 chown(Dockerfile),或重建空卷。
logging.getLogger(__name__).warning(
"启用文件日志失败(%s):无写权限。容器部署请确认镜像已预创建该目录"
"并 chown 给运行用户(compose 卷挂载点默认 root 属主);"
"宿主机直跑请检查目录权限。本次仅保留 stdout/内存日志。",
log_file,
)
except Exception: except Exception:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True, "启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,
+66
View File
@@ -0,0 +1,66 @@
"""数据质量检查回归测试。
背景(P0-02 遗留): MatchStats 主键改为 match_id 后,质量检查查询仍引用
MatchStats.id → AttributeError → POST /admin/data-quality/run 必然 500,
前端显示「运行失败」;且 handler 无日志,未捕获异常走 uvicorn.error
(propagate=False)不进内存缓冲/文件日志,排障时无据可查。
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from src.api.routes import admin_quality
def _fake_db(scalars: list[int]) -> MagicMock:
"""按顺序返回 scalar() 计数的假 AsyncSession。"""
db = MagicMock()
results = []
for v in scalars:
r = MagicMock()
r.scalar.return_value = v
results.append(r)
db.execute = AsyncMock(side_effect=results)
db.add = MagicMock()
db.commit = AsyncMock()
return db
async def test_run_data_quality_check_no_attribute_error():
"""检查查询不得引用 MatchStats.id(P0-02 后该属性不存在)。
修复前: run_data_quality_check 抛 AttributeError → 500。
"""
db = _fake_db([3, 1])
out = await admin_quality.run_data_quality_check(db)
assert out["ok"] is True
assert out["checks"] == [
{"name": "finished_without_stats", "passed": False},
{"name": "league_without_standings", "passed": False},
]
# 检查结果落库(2 条 DataQualityCheck)
assert db.add.call_count == 2
db.commit.assert_awaited_once()
async def test_run_data_quality_check_all_passed():
db = _fake_db([0, 0])
out = await admin_quality.run_data_quality_check(db)
assert out["ok"] is True
assert all(c["passed"] for c in out["checks"])
async def test_run_data_quality_check_logs_summary(caplog):
"""成功路径必须留日志:否则线上无从得知检查何时跑过、结果如何。"""
import logging
db = _fake_db([0, 0])
with caplog.at_level(logging.INFO, logger="src.api.routes.admin_quality"):
await admin_quality.run_data_quality_check(db)
assert any("数据质量检查" in r.message for r in caplog.records)
+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