Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575507e44f | ||
|
|
5001093c3b | ||
|
|
45535aa921 | ||
|
|
d7d903b35b | ||
|
|
a6740f6140 | ||
|
|
38e1c6c31f | ||
|
|
0742eef52e | ||
|
|
f00bf71e8f | ||
|
|
246b06379d | ||
|
|
3fc3e91bd3 | ||
|
|
007a276fb7 | ||
|
|
83a1eed283 | ||
|
|
9c77efaa4b | ||
|
|
b9beddacf7 | ||
|
|
77ecb078a3 | ||
|
|
51fbc1828e | ||
|
|
1422afd6f6 |
+5
-58
@@ -11,48 +11,17 @@
|
||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||
*/
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import Masthead from './components/Masthead'
|
||||
import Matches from './pages/Matches'
|
||||
import Standings from './pages/Standings'
|
||||
import { adminRoutes } from './admin/routes'
|
||||
|
||||
/** 报眉日期行 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})
|
||||
}
|
||||
|
||||
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||
先知
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||
<Link to="/" className="text-ink-500 hover:text-press transition-colors">
|
||||
比赛 / 预测
|
||||
</Link>
|
||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||
评估
|
||||
</Link>
|
||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Masthead active="standings" />
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
{children}
|
||||
@@ -70,30 +39,8 @@ function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||
先知
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||
<Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
|
||||
积分榜
|
||||
</Link>
|
||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||
评估
|
||||
</Link>
|
||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接(共用 Masthead) ── */}
|
||||
<Masthead active="home" />
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
<Matches />
|
||||
|
||||
@@ -363,6 +363,19 @@ export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }>
|
||||
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`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集任务状态轮询(单任务)
|
||||
*/
|
||||
|
||||
@@ -94,6 +94,10 @@ export default function CollectionPage() {
|
||||
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
||||
stopPolling()
|
||||
loadRecentJobs() // 终态后刷新历史列表
|
||||
// 广播采集终态:数据完整性等依赖页立即刷新,不必等轮询周期
|
||||
window.dispatchEvent(new CustomEvent('profeto:ingest-done', {
|
||||
detail: { jobId: job.id, status: job.status },
|
||||
}))
|
||||
}
|
||||
} catch { /* 单次轮询失败不影响后续 */ }
|
||||
}
|
||||
@@ -333,8 +337,8 @@ export default function CollectionPage() {
|
||||
)}
|
||||
{taskStatus === 'done' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-700">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<div className="flex items-center gap-2 text-xs text-ok-700">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
|
||||
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
||||
</div>
|
||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
||||
@@ -373,7 +377,7 @@ export default function CollectionPage() {
|
||||
<div>
|
||||
{recentJobs.map(j => {
|
||||
const st = j.status
|
||||
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-amber-500 animate-pulse'
|
||||
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse'
|
||||
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
|
||||
return (
|
||||
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||
|
||||
@@ -26,9 +26,9 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
function pctColor(pct: number): string {
|
||||
if (pct >= 80) return 'bg-emerald-500'
|
||||
if (pct >= 50) return 'bg-amber-500'
|
||||
return 'bg-rose-500'
|
||||
if (pct >= 80) return 'bg-ok-500'
|
||||
if (pct >= 50) return 'bg-warn-500'
|
||||
return 'bg-bad-500'
|
||||
}
|
||||
|
||||
/** 根据问题描述生成可操作的修复链接 */
|
||||
@@ -55,6 +55,9 @@ export default function DataCompletenessPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = 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 load = useCallback(async () => {
|
||||
@@ -72,6 +75,20 @@ export default function DataCompletenessPage() {
|
||||
|
||||
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) => {
|
||||
setHighlightedLeague(code)
|
||||
@@ -92,9 +109,20 @@ export default function DataCompletenessPage() {
|
||||
title="数据完整性"
|
||||
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</button>
|
||||
<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">
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,20 +53,21 @@ export default function LogsPage() {
|
||||
}, [load])
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const userScrolledUp = useRef(false)
|
||||
const userScrolledDown = useRef(false)
|
||||
|
||||
// 检测用户是否向上滚动过
|
||||
// 检测用户是否向下滚动回看历史(旧日志在下方)
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
|
||||
userScrolledUp.current = !atBottom
|
||||
const atTop = el.scrollTop < 50
|
||||
userScrolledDown.current = !atTop
|
||||
}
|
||||
|
||||
// 加载后自动滚动到底部(仅当用户未向上滚动时)
|
||||
// 列表最新在最上面(后端倒序返回);打开页面/自动刷新时定位到顶部=最新。
|
||||
// 用户向下滚动回看历史时暂停定位,拉回顶部即恢复。
|
||||
useEffect(() => {
|
||||
if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
if (autoRefresh && !userScrolledDown.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0
|
||||
}
|
||||
}, [entries, autoRefresh])
|
||||
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板(报刊风)
|
||||
* Admin 后台 - 监控页(报刊风·三分区)
|
||||
*
|
||||
* 功能:
|
||||
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||
* - /health/ready 数据库就绪检查
|
||||
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||
* 此前只有存活/DB 两个布尔,与顶栏健康点重复,信息丰度不足。
|
||||
* 现在聚合全站已有信号,回答三个问题:
|
||||
* 1. 基础设施活着吗 —— 服务存活 / DB 就绪 / 版本 / 运行时间 / 上游 bzzoiro 可达性
|
||||
* 2. 数据管线健康吗 —— 最近采集成功率 / 死信 / 数据缺口
|
||||
* 3. LLM 服务正常吗 —— 预测成功率 / 平均延迟
|
||||
* 顶部「需要关注」聚合条:任何一项异常即亮红并直达处理页。
|
||||
* 30s 自动巡检 + 手动巡检。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth } from '../dal'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
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 { 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() {
|
||||
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 [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 [error, setError] = useState<string | null>(null)
|
||||
const [lastCheck, setLastCheck] = useState<string>('')
|
||||
@@ -22,20 +51,28 @@ export default function MonitoringPage() {
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [h, r] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// 各信号独立容错:单项失败只降级对应卡片
|
||||
const [h, r, u, j, f, c, l] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
fetchUpstreamProbe(),
|
||||
fetchIngestJobs({ limit: 10 }),
|
||||
fetchIngestFailures(),
|
||||
fetchDataCompleteness(),
|
||||
fetchLLMUsageStats() as Promise<LLMStats>,
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : 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') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,108 +82,188 @@ export default function MonitoringPage() {
|
||||
}, [refresh])
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统监控"
|
||||
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||
description="基础设施 / 数据管线 / LLM 三区巡检,每 30 秒自动刷新。"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-ink-400">{lastCheck && `最近巡检 ${lastCheck}`}</span>
|
||||
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xs text-ink-400">
|
||||
{lastCheck && `最近巡检 ${lastCheck}`}
|
||||
</span>
|
||||
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
kind="error"
|
||||
title="无法连接到后端"
|
||||
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`}
|
||||
/>
|
||||
<Alert kind="error" title="无法连接到后端" message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`} />
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 存活状态 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">存活状态</div>
|
||||
<div className="mt-2 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'}`}>
|
||||
{health ? (alive ? '正常' : String(health.status)) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据库就绪 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">数据库就绪</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<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'}`}
|
||||
{/* ── 需要关注:任何一项异常即亮红并直达处理页 ── */}
|
||||
{!loading && todos.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{todos.map(t => (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.to}
|
||||
className="flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
||||
>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 服务名 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">服务</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
|
||||
{health?.service || 'profeto'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版本 */}
|
||||
{health?.version && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">版本</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health.version}
|
||||
{/* ── 基础设施 ── */}
|
||||
<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'}`}>
|
||||
{health ? (alive ? '正常' : String(health.status)) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
{/* 运行时间 */}
|
||||
{health?.uptime_seconds != null && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">运行时间</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{Math.floor(health.uptime_seconds / 3600)}h{' '}
|
||||
{Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||
<MetricCard label="数据库就绪">
|
||||
<div className="flex items-center gap-2">
|
||||
<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' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
{/* 检查项 */}
|
||||
{health?.checks && Object.keys(health.checks).length > 0 && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
|
||||
<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>
|
||||
<MetricCard label="版本 / 运行时间">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health?.version ? String(health.version) : '—'}
|
||||
</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>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="上游 bzzoiro">
|
||||
{upstream ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${upstream.ok ? 'bg-ok-500' : 'bg-press'}`} aria-hidden="true" />
|
||||
<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>
|
||||
) : (
|
||||
<span className="font-serif text-xl text-ink-300">—</span>
|
||||
)}
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 数据管线 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">数据管线</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="最近采集">
|
||||
{recentJobs && runJobs > 0 ? (
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{okJobs}/{runJobs} <span className="text-xs font-normal text-ink-500">成功</span>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="死信待处理">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${deadLetterCount > 0 ? 'text-press' : 'text-ink-900'}`}>
|
||||
{deadLetterCount}
|
||||
</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 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 className="mt-0.5 text-2xs text-ink-400">有统计 / 已完赛</div>
|
||||
</MetricCard>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function PredictionHistoryPage() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
|
||||
<p className="text-2xl font-bold text-ok-700">{hitCount}</p>
|
||||
<p className="text-xs text-ink-500">命中</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -194,7 +194,7 @@ export default function PredictionHistoryPage() {
|
||||
<td className="px-4 py-3">
|
||||
{p.pred_1x2 ? (
|
||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
||||
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
|
||||
predHit === true ? 'border-ok-300 text-ok-700 bg-ok-50' :
|
||||
predHit === false ? 'border-press text-press bg-press-wash' :
|
||||
'border-ink-200 text-ink-600'
|
||||
}`}>
|
||||
|
||||
@@ -317,11 +317,11 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-warn-500' : 'bg-ok-500'}`} />
|
||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||
</div>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-warn-700' : 'text-ink-400'}`}>
|
||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -489,7 +489,7 @@ export default function SettingsPage() {
|
||||
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-ok-500' : 'bg-ink-300'}`} />
|
||||
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
||||
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
|
||||
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
|
||||
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShow(window.scrollY > threshold)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [threshold])
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center border border-ink-300 bg-paper-50 text-ink-600 transition-all duration-300 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 ${
|
||||
show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 报头组件(前台共用)。
|
||||
*
|
||||
* 此前 HomePage 与 StandingsLayout 各自复制一份报头,导航项已经漂移
|
||||
* (首页有「积分榜」、积分榜页有「比赛/预测」),且无当前页高亮。
|
||||
* 现在统一为 Masthead:
|
||||
* - active 声明当前版面,对应导航项加印报红高亮 + aria-current
|
||||
* - 「管理」入口改用与全站一致的线条 SVG(替代字符 ⚙)
|
||||
* - 管理类入口带 title 提示,避免访客被无声踢到登录页
|
||||
* 页脚文案各页不同,仍由调用方自行渲染。
|
||||
*/
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export type MastheadActive = 'home' | 'standings'
|
||||
|
||||
function GearIcon() {
|
||||
return (
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MastheadLink({
|
||||
to,
|
||||
active = false,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
/** 当前版面才高亮;管理类入口无高亮概念 */
|
||||
active?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
title={title}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`transition-colors ${
|
||||
active ? 'font-medium text-press' : 'text-ink-500 hover:text-press'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Masthead({ active }: { active: MastheadActive }) {
|
||||
return (
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||
先知
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}</span>
|
||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||
<MastheadLink to="/" active={active === 'home'}>比赛 / 预测</MastheadLink>
|
||||
<MastheadLink to="/standings" active={active === 'standings'}>积分榜</MastheadLink>
|
||||
{/* 管理类页面对访客需要登录,入口处先说明,避免无声跳登录页 */}
|
||||
<MastheadLink to="/admin/eval" title="评估页面向管理员开放,需登录">评估</MastheadLink>
|
||||
<MastheadLink to="/admin" title="管理后台,需登录">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GearIcon /> 管理
|
||||
</span>
|
||||
</MastheadLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -103,6 +103,28 @@
|
||||
.btn-danger {
|
||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||
}
|
||||
/* 描边确认按钮:次级动作中需要比默认 btn 更明确轮廓的场合
|
||||
(此前三处使用但从未定义,样式静默失效) */
|
||||
.btn-outline {
|
||||
@apply border-ink-900 bg-transparent text-ink-900
|
||||
hover:border-press hover:bg-press-wash hover:text-press-dark;
|
||||
}
|
||||
|
||||
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
|
||||
.modal-overlay-enter {
|
||||
animation: modal-fade 0.18s ease-out both;
|
||||
}
|
||||
.modal-panel-enter {
|
||||
animation: modal-rise 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes modal-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes modal-rise {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── 统一空态 ── */
|
||||
.empty-state {
|
||||
|
||||
@@ -21,8 +21,10 @@ globalThis.window = {
|
||||
}
|
||||
|
||||
// 捕获每次 fetch 的入参供断言
|
||||
let lastUrl = ''
|
||||
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
|
||||
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?.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')
|
||||
})
|
||||
|
||||
@@ -17,22 +17,30 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
||||
const API_BASE = '/api/v1'
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
/** 健康检查端点挂在根路径(app.py 不在 /api/v1 下,与 compose healthcheck 一致);
|
||||
* 加前缀会 404,导致管理后台健康状态永远显示「系统异常」 */
|
||||
const ROOT_ONLY_PREFIXES = ['/health']
|
||||
|
||||
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
|
||||
function build_url(path: string): string {
|
||||
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('/')) return `${API_BASE}${path}`
|
||||
return `${API_BASE}/${path}`
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public data?: unknown,
|
||||
) {
|
||||
status: number
|
||||
data?: unknown
|
||||
|
||||
constructor(message: string, status: number, data?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
// 显式赋值而非构造函数参数属性(public x):strip-types 不支持后者,
|
||||
// 会让 node --experimental-strip-types 跑 lib/http.test.ts 直接失败
|
||||
this.status = status
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
||||
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||
import BackTop from '../components/BackTop'
|
||||
import { useMatchesList } from './matches/hooks/useMatchesList'
|
||||
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
@@ -38,13 +40,8 @@ export default function Matches() {
|
||||
loadMore,
|
||||
} = useMatchesList({ onError: setError })
|
||||
|
||||
const {
|
||||
predictingId,
|
||||
prediction,
|
||||
predictionFor,
|
||||
predict,
|
||||
closePredict,
|
||||
} = useMatchPredict({ onError: setError })
|
||||
const { predictingId, prediction, predictionFor, predict, closePredict } =
|
||||
useMatchPredict({ onError: setError })
|
||||
|
||||
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
@@ -52,16 +49,8 @@ export default function Matches() {
|
||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||
|
||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||
const [showBackTop, setShowBackTop] = useState(false)
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
|
||||
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
||||
// 删除本地 showBackTop/scrollToTop,统一使用共享 BackTop 组件
|
||||
|
||||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||
const isScheduledView = status === 'scheduled'
|
||||
@@ -90,20 +79,46 @@ export default function Matches() {
|
||||
}
|
||||
}
|
||||
|
||||
// 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示
|
||||
const leagueNavRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = leagueNavRef.current
|
||||
if (!el) return
|
||||
const update = () => setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 8)
|
||||
update()
|
||||
el.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [leagues])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* ── 联赛版面切换 ── */}
|
||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
||||
{leagues.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => setLeague(l.code)}
|
||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="relative">
|
||||
<nav
|
||||
ref={leagueNavRef}
|
||||
className="flex items-center gap-6 overflow-x-auto border-b border-ink-900"
|
||||
aria-label="联赛"
|
||||
>
|
||||
{leagues.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => setLeague(l.code)}
|
||||
aria-current={league === l.code ? 'true' : undefined}
|
||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{canScrollRight && (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-paper-50 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||||
@@ -120,7 +135,7 @@ export default function Matches() {
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-3">
|
||||
<span className="ml-auto inline-flex items-center gap-3 border-l border-ink-200 pl-4">
|
||||
<span className="tabular-nums">
|
||||
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||
@@ -203,9 +218,9 @@ export default function Matches() {
|
||||
<>
|
||||
<p className="empty-state-title">本版暂无赛程</p>
|
||||
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||||
<a href="/admin/collection" className="empty-state-action">
|
||||
<Link to="/admin/collection" className="empty-state-action">
|
||||
前往数据采集 <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -265,18 +280,8 @@ export default function Matches() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* ── 回到顶部(共享组件,方角纸片风) ── */}
|
||||
<BackTop />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import BackTop from '../components/BackTop'
|
||||
import { fetchStandings } from '../admin/dal'
|
||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
@@ -13,24 +14,24 @@ import { Spinner } from '../admin/components'
|
||||
|
||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||
// 欧战资格
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
// 升级
|
||||
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
|
||||
// 降级
|
||||
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
||||
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
|
||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
|
||||
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
|
||||
// 附加赛
|
||||
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
}
|
||||
@@ -44,7 +45,7 @@ function zoneBadge(zone?: string | null) {
|
||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||
function FormDots({ form }: { form?: string | null }) {
|
||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
||||
const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{form.slice(0, 5).split('').map((c, i) => (
|
||||
@@ -62,17 +63,6 @@ export default function StandingsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
||||
|
||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
|
||||
const load = useCallback(async (code?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -134,7 +124,7 @@ export default function StandingsPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
<div className="border border-bad-300 bg-bad-50 px-4 py-3 text-sm text-bad-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -202,7 +192,7 @@ export default function StandingsPage() {
|
||||
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
||||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}>
|
||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||
</td>
|
||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||
@@ -221,18 +211,8 @@ export default function StandingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* 回到顶部(共享组件,方角纸片风) */}
|
||||
<BackTop />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS } from '../types'
|
||||
@@ -82,7 +82,7 @@ function PredictionPanel({
|
||||
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
||||
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
@@ -161,7 +161,8 @@ function PredictProgress() {
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。
|
||||
关闭窗口将停止进度显示,后台任务可能仍在执行并消耗额度。
|
||||
</p>
|
||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||
@@ -185,17 +186,52 @@ export function PredictModal({
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
|
||||
// ── 无障碍与滚动锁定 ──
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
|
||||
panelRef.current?.focus()
|
||||
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
// 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面
|
||||
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (!focusables || focusables.length === 0) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', h)
|
||||
return () => document.removeEventListener('keydown', h)
|
||||
// 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', h)
|
||||
document.body.style.overflow = prevOverflow
|
||||
// 关闭后把焦点还给触发元素
|
||||
previouslyFocused.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||
className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||
@@ -203,7 +239,11 @@ export function PredictModal({
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
|
||||
>
|
||||
{/* 弹窗报头 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
|
||||
@@ -24,12 +24,39 @@ export default {
|
||||
800: '#282420',
|
||||
900: '#17140F',
|
||||
},
|
||||
// 印报红:全站唯一强调色,克制使用
|
||||
press: {
|
||||
DEFAULT: '#9E1B1B',
|
||||
dark: '#7C1414',
|
||||
wash: '#F7E9E4',
|
||||
},
|
||||
// 印报红:全站唯一强调色,克制使用
|
||||
press: {
|
||||
DEFAULT: '#9E1B1B',
|
||||
dark: '#7C1414',
|
||||
wash: '#F7E9E4',
|
||||
},
|
||||
// ── 语义状态色:成功/警告/负面 ──
|
||||
// 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA),
|
||||
// 点/条级(500) ≥ 3:1;色相降饱和以贴近墨色印刷感,不使用 tailwind 原色。
|
||||
ok: {
|
||||
50: '#EAF3ED',
|
||||
100: '#D5E8DC',
|
||||
300: '#A3C9B1',
|
||||
500: '#43925F',
|
||||
600: '#2E7A4C',
|
||||
700: '#1F6B45',
|
||||
},
|
||||
warn: {
|
||||
50: '#FBF3E4',
|
||||
100: '#F5E5C8',
|
||||
300: '#E2C48E',
|
||||
500: '#D97706',
|
||||
600: '#A15C0B',
|
||||
700: '#8F5109',
|
||||
},
|
||||
bad: {
|
||||
50: '#FAEDED',
|
||||
100: '#F6E3E3',
|
||||
300: '#E4AFAF',
|
||||
500: '#C24A4A',
|
||||
600: '#A83B3B',
|
||||
700: '#8F3030',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||
|
||||
+21
-1
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -13,6 +14,9 @@ from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 进程启动时间(monotonic,不受系统时钟跳变影响):/health 的 uptime 来源
|
||||
_PROCESS_STARTED_MONOTONIC = time.monotonic()
|
||||
|
||||
|
||||
async def _fail_stale_ingest_jobs() -> None:
|
||||
"""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.admin_settings import router as admin_settings_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(predict_router)
|
||||
@@ -165,10 +170,25 @@ def create_app() -> FastAPI:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_settings_router)
|
||||
app.include_router(schedules_router)
|
||||
app.include_router(admin_monitoring_router)
|
||||
|
||||
@app.get("/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")
|
||||
|
||||
@@ -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}
|
||||
@@ -234,13 +234,14 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
|
||||
checks = []
|
||||
|
||||
# 检查1: 已完赛但无统计的比赛
|
||||
# 注意: MatchStats 主键是 match_id(P0-02),不是 id —— 引用 .id 会 AttributeError
|
||||
finished_no_stats = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Match)
|
||||
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(MatchStats.id.is_(None))
|
||||
.where(MatchStats.match_id.is_(None))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
@@ -276,6 +277,13 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
|
||||
db.add(c)
|
||||
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]}
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
|
||||
if root.level == logging.NOTSET or root.level > 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):
|
||||
handler = MemoryLogHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user