优化公网流量看板加载稳定性

This commit is contained in:
chenpei 2026-07-08 15:07:25 +08:00
parent 46f66badf2
commit eb31e12f32
2 changed files with 121 additions and 46 deletions

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
type DatePreset = 'today' | 'last7' | 'last30' | 'custom'
@ -76,6 +76,11 @@ interface PublicTrafficTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
interface PublicTrafficCacheEntry {
stats: PublicTrafficStats
cachedAt: number
}
// ============ 工具函数 ============
function toDateInputValue(date: Date) {
const y = date.getFullYear()
@ -239,30 +244,73 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
const [endDate, setEndDate] = useState(initialRange.end)
const [loading, setLoading] = useState(false)
const [stats, setStats] = useState<PublicTrafficStats | null>(null)
const [statsRangeKey, setStatsRangeKey] = useState('')
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const requestSeqRef = useRef(0)
const abortRef = useRef<AbortController | null>(null)
const statsCacheRef = useRef(new Map<string, PublicTrafficCacheEntry>())
const activeRangeKey = `${startDate}|${endDate}`
const loadStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
const rangeKey = `${startDate}|${endDate}`
const today = toDateInputValue(new Date())
const cacheTtlMs = endDate >= today ? 60_000 : 10 * 60_000
const cached = statsCacheRef.current.get(rangeKey)
if (!options?.forceRefresh && cached && Date.now() - cached.cachedAt <= cacheTtlMs) {
requestSeqRef.current += 1
abortRef.current?.abort()
setStats(cached.stats)
setStatsRangeKey(rangeKey)
setLoading(false)
return
}
const requestSeq = requestSeqRef.current + 1
requestSeqRef.current = requestSeq
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
if (!cached) {
setStatsRangeKey('')
}
setLoading(true)
try {
const params = new URLSearchParams()
params.set('start', startDate)
params.set('end', endDate)
if (options?.forceRefresh) params.set('refresh', '1')
const response = await fetch(`/api/admin/public-traffic?${params.toString()}`, { cache: 'no-store' })
const response = await fetch(`/api/admin/public-traffic?${params.toString()}`, {
cache: 'no-store',
signal: controller.signal,
})
if (!response.ok) {
const data = await response.json().catch(() => null)
throw new Error(data?.error || '加载公网流量统计失败')
}
setStats(await response.json())
const nextStats = await response.json()
if (requestSeq !== requestSeqRef.current) return
statsCacheRef.current.set(rangeKey, {
stats: nextStats,
cachedAt: Date.now(),
})
setStats(nextStats)
setStatsRangeKey(rangeKey)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
console.error('Load public traffic stats error:', error)
showToast(error instanceof Error ? error.message : '加载公网流量统计失败', 'error')
} finally {
setLoading(false)
if (requestSeq === requestSeqRef.current) {
setLoading(false)
}
}
}, [endDate, showToast, startDate])
useEffect(() => { loadStats() }, [loadStats])
useEffect(() => () => abortRef.current?.abort(), [])
const handlePresetChange = (nextPreset: Exclude<DatePreset, 'custom'>) => {
const range = presetRange(nextPreset)
@ -281,17 +329,18 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
}
// 派生数据
const responseBytes = stats?.summary.responseBytes || 0
const requestBytes = stats?.summary.requestBytes ?? null
const visibleStats = statsRangeKey === activeRangeKey ? stats : null
const responseBytes = visibleStats?.summary.responseBytes || 0
const requestBytes = visibleStats?.summary.requestBytes ?? null
const totalBytes = requestBytes === null ? responseBytes : responseBytes + requestBytes
const dayPeak = stats?.byDay.reduce((max, item) => Math.max(max, item.responseBytes), 0) || 0
const peakDay = stats?.byDay.find((d) => d.responseBytes === dayPeak)
const dayPeak = visibleStats?.byDay.reduce((max, item) => Math.max(max, item.responseBytes), 0) || 0
const peakDay = visibleStats?.byDay.find((d) => d.responseBytes === dayPeak)
// 按 category 分组 byPurpose
const purposeByCategory = useMemo(() => {
if (!stats) return new Map<string, PublicTrafficStats['byPurpose'][number][]>()
if (!visibleStats) return new Map<string, PublicTrafficStats['byPurpose'][number][]>()
const map = new Map<string, PublicTrafficStats['byPurpose'][number][]>()
for (const item of stats.byPurpose) {
for (const item of visibleStats.byPurpose) {
const cat = normalizeCategoryKey(item.category)
if (!map.has(cat)) map.set(cat, [])
map.get(cat)!.push(item)
@ -301,13 +350,13 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
items.sort((a: PublicTrafficStats['byPurpose'][number], b: PublicTrafficStats['byPurpose'][number]) => b.responseBytes - a.responseBytes)
})
return map
}, [stats])
}, [visibleStats])
// category 合计
const categoryTotals = useMemo(() => {
if (!stats) return [] as Array<{ category: string; bytes: number; requests: number }>
if (!visibleStats) return [] as Array<{ category: string; bytes: number; requests: number }>
const map = new Map<string, { category: string; bytes: number; requests: number }>()
for (const item of stats.byPurpose) {
for (const item of visibleStats.byPurpose) {
const cat = normalizeCategoryKey(item.category)
const existing = map.get(cat) || { category: cat, bytes: 0, requests: 0 }
existing.bytes += item.responseBytes
@ -315,12 +364,12 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
map.set(cat, existing)
}
return Array.from(map.values()).sort((a, b) => b.bytes - a.bytes)
}, [stats])
}, [visibleStats])
if (!stats) {
if (!visibleStats) {
return (
<div className="flex min-h-[300px] items-center justify-center text-gray-400 dark:text-gray-500">
{loading ? '加载中...' : '暂无数据'}
{loading ? '正在读取固化汇总...' : '暂无数据'}
</div>
)
}
@ -383,13 +432,13 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
</div>
</div>
{!stats.available && (
{!visibleStats.available && (
<div className="card-apple border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
暂未读取到公网入口日志。请配置 PUBLIC_TRAFFIC_LOG_PATHS 环境变量,或把穿透服务器 Nginx access log 同步到应用服务器。
</div>
)}
{stats.available && (
{visibleStats.available && (
<>
{/* ===== 顶部汇总卡片 ===== */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
@ -411,17 +460,17 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
{/* 请求次数 */}
<div className="card-apple p-5">
<div className="text-sm text-gray-500 dark:text-gray-400">请求次数</div>
<div className="mt-2 text-3xl font-bold tabular-nums text-gray-900 dark:text-white">{formatNumber(stats.summary.totalRequests)}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{formatNumber(stats.summary.uniqueIpCount)} 个客户端 IP</div>
<div className="mt-2 text-3xl font-bold tabular-nums text-gray-900 dark:text-white">{formatNumber(visibleStats.summary.totalRequests)}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{formatNumber(visibleStats.summary.uniqueIpCount)} 个客户端 IP</div>
</div>
{/* 日志时间范围 */}
<div className="card-apple p-5">
<div className="text-sm text-gray-500 dark:text-gray-400">日志范围</div>
<div className="mt-2 text-lg font-semibold tabular-nums text-gray-900 dark:text-white">
{formatTime(stats.summary.firstSeen).slice(5, 17)}
{formatTime(visibleStats.summary.firstSeen).slice(5, 17)}
</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">至 {formatTime(stats.summary.lastSeen).slice(5, 17)}</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">至 {formatTime(visibleStats.summary.lastSeen).slice(5, 17)}</div>
</div>
</div>
@ -522,8 +571,8 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">按域名</h3>
</div>
<div className="space-y-3 p-5">
{stats.byDomain.length === 0 && <div className="py-4 text-sm text-gray-400 dark:text-gray-500">无数据</div>}
{stats.byDomain.map((item) => {
{visibleStats.byDomain.length === 0 && <div className="py-4 text-sm text-gray-400 dark:text-gray-500">无数据</div>}
{visibleStats.byDomain.map((item) => {
const pct = responseBytes > 0 ? Math.round((item.responseBytes / responseBytes) * 100) : 0
return (
<div key={item.domain}>
@ -548,9 +597,9 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">每日流量</h3>
</div>
<div className="p-5">
{stats.byDay.length === 0 && <div className="py-4 text-sm text-gray-400 dark:text-gray-500">无数据</div>}
{visibleStats.byDay.length === 0 && <div className="py-4 text-sm text-gray-400 dark:text-gray-500">无数据</div>}
<div className="flex items-end gap-1" style={{ height: '120px' }}>
{stats.byDay.map((item) => {
{visibleStats.byDay.map((item) => {
const heightPct = dayPeak > 0 ? (item.responseBytes / dayPeak) * 100 : 0
const isPeak = item.responseBytes === dayPeak && dayPeak > 0
return (
@ -568,10 +617,10 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
)
})}
</div>
{stats.byDay.length > 0 && (
{visibleStats.byDay.length > 0 && (
<div className="mt-2 flex justify-between text-xs text-gray-400 dark:text-gray-500">
<span>{stats.byDay[0]?.date.slice(5)}</span>
<span>{stats.byDay[stats.byDay.length - 1]?.date.slice(5)}</span>
<span>{visibleStats.byDay[0]?.date.slice(5)}</span>
<span>{visibleStats.byDay[visibleStats.byDay.length - 1]?.date.slice(5)}</span>
</div>
)}
</div>
@ -582,15 +631,15 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
<div className="card-apple overflow-hidden">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">高消耗资源 Top {stats.topPaths.length}</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">高消耗资源 Top {visibleStats.topPaths.length}</h3>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">点击展开查看原始路径</p>
</div>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{stats.topPaths.length === 0 && (
{visibleStats.topPaths.length === 0 && (
<div className="py-8 text-center text-sm text-gray-400 dark:text-gray-500">无数据</div>
)}
{stats.topPaths.map((item, idx) => {
{visibleStats.topPaths.map((item, idx) => {
const meta = getCategoryMeta(item.purposeCategory)
const rowKey = `${item.method}-${item.path}`
const expanded = expandedPaths.has(rowKey)
@ -650,22 +699,22 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
{/* ===== 底部统计信息 ===== */}
<div className="card-apple p-4 text-xs text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>已扫描 {formatNumber(stats.source.scannedFiles)} 个日志文件</span>
<span>{visibleStats.source.cache?.persisted ? '数据源:固化汇总表' : `已扫描 ${formatNumber(visibleStats.source.scannedFiles)} 个日志文件`}</span>
<span>·</span>
<span>{formatNumber(stats.source.scannedLines)} 行,命中 {formatNumber(stats.source.matchedLines)} 行</span>
{stats.source.truncated && <span className="text-amber-600 dark:text-amber-400">· 读取达上限,结果可能不完整</span>}
<span>{formatNumber(visibleStats.source.matchedLines)} 条有效请求</span>
{visibleStats.source.truncated && <span className="text-amber-600 dark:text-amber-400">· 读取达上限,结果可能不完整</span>}
</div>
{!stats.source.requestBytesAvailable && (
{!visibleStats.source.requestBytesAvailable && (
<div className="mt-1 text-gray-400 dark:text-gray-500">
日志未含 request_length 字段,上传入站流量无法精确拆分。
</div>
)}
{stats.source.cache && (
{visibleStats.source.cache && (
<div className="mt-1 text-gray-400 dark:text-gray-500">
数据缓存:
{stats.source.cache.hit ? '已读取固化结果' : '本次重新统计'}
{stats.source.cache.refreshedAt && `,更新时间 ${formatTime(stats.source.cache.refreshedAt)}`}
{stats.source.cache.stale && ',正在后台刷新'}
{visibleStats.source.cache.hit ? '已读取固化结果' : '本次重新统计'}
{visibleStats.source.cache.refreshedAt && `,更新时间 ${formatTime(visibleStats.source.cache.refreshedAt)}`}
{visibleStats.source.cache.stale && ',正在后台刷新'}
</div>
)}
</div>

View File

@ -188,6 +188,13 @@ interface PublicTrafficRollupRow {
status_other: number
}
interface PublicTrafficRollupAggregationState {
dayMap: Map<string, PublicTrafficStats['byDay'][number]>
domainMap: Map<string, PublicTrafficStats['byDomain'][number]>
purposeMap: Map<string, PublicTrafficStats['byPurpose'][number]>
topPathMap: Map<string, PublicTrafficStats['topPaths'][number]>
}
const MONTHS: Record<string, number> = {
Jan: 0,
Feb: 1,
@ -238,6 +245,15 @@ function createAggregationState(): PublicTrafficAggregationState {
}
}
function createRollupAggregationState(): PublicTrafficRollupAggregationState {
return {
dayMap: new Map(),
domainMap: new Map(),
purposeMap: new Map(),
topPathMap: new Map(),
}
}
function splitEnvList(value: string | undefined): string[] {
return String(value || '')
.split(/[\n,;]/)
@ -1338,7 +1354,11 @@ export async function upsertPublicTrafficRollups(
return { received: inputs.length, accepted: rows.length, skipped: inputs.length - rows.length }
}
function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
function addRollupRow(
stats: PublicTrafficStats,
aggregation: PublicTrafficRollupAggregationState,
row: PublicTrafficRollupRow,
) {
const responseBytes = bigintToNumber(row.response_bytes)
const requestBytes = row.request_bytes === null ? null : bigintToNumber(row.request_bytes)
const requestBytesKnownCount = Number(row.request_bytes_known_count || 0)
@ -1368,7 +1388,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
if (firstSeen && (!stats.summary.firstSeen || firstSeen < stats.summary.firstSeen)) stats.summary.firstSeen = firstSeen
if (lastSeen && (!stats.summary.lastSeen || lastSeen > stats.summary.lastSeen)) stats.summary.lastSeen = lastSeen
let dayItem = stats.byDay.find((item) => item.date === row.business_date)
let dayItem = aggregation.dayMap.get(row.business_date)
if (!dayItem) {
dayItem = {
date: row.business_date,
@ -1378,6 +1398,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
totalRequests: 0,
}
stats.byDay.push(dayItem)
aggregation.dayMap.set(row.business_date, dayItem)
}
dayItem.responseBytes += responseBytes
dayItem.totalRequests += totalRequests
@ -1386,7 +1407,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
dayItem.requestBytesKnownCount += requestBytesKnownCount
}
let domainItem = stats.byDomain.find((item) => item.domain === row.domain)
let domainItem = aggregation.domainMap.get(row.domain)
if (!domainItem) {
domainItem = {
domain: row.domain,
@ -1397,6 +1418,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
statusCounts: emptyStatusCounts(),
}
stats.byDomain.push(domainItem)
aggregation.domainMap.set(row.domain, domainItem)
}
domainItem.responseBytes += responseBytes
domainItem.totalRequests += totalRequests
@ -1408,7 +1430,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
domainItem.statusCounts[bucket] += statusCounts[bucket]
}
let purposeItem = stats.byPurpose.find((item) => item.key === row.purpose_key)
let purposeItem = aggregation.purposeMap.get(row.purpose_key)
if (!purposeItem) {
purposeItem = {
key: row.purpose_key,
@ -1421,6 +1443,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
totalRequests: 0,
}
stats.byPurpose.push(purposeItem)
aggregation.purposeMap.set(row.purpose_key, purposeItem)
}
purposeItem.responseBytes += responseBytes
purposeItem.totalRequests += totalRequests
@ -1429,7 +1452,8 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
purposeItem.requestBytesKnownCount += requestBytesKnownCount
}
let topPathItem = stats.topPaths.find((item) => item.method === row.method && item.path === row.path)
const topPathKey = `${row.method}\u001f${row.path}`
let topPathItem = aggregation.topPathMap.get(topPathKey)
if (!topPathItem) {
topPathItem = {
path: row.path,
@ -1441,6 +1465,7 @@ function addRollupRow(stats: PublicTrafficStats, row: PublicTrafficRollupRow) {
totalRequests: 0,
}
stats.topPaths.push(topPathItem)
aggregation.topPathMap.set(topPathKey, topPathItem)
}
topPathItem.responseBytes += responseBytes
topPathItem.totalRequests += totalRequests
@ -1533,7 +1558,8 @@ async function getPublicTrafficStatsFromRollups(range: PublicTrafficRange): Prom
},
}
for (const row of rows) addRollupRow(stats, row)
const aggregation = createRollupAggregationState()
for (const row of rows) addRollupRow(stats, aggregation, row)
stats.source.matchedLines = stats.summary.totalRequests
stats.source.requestBytesAvailable = stats.summary.requestBytesKnownCount > 0