优化公网流量看板加载稳定性
This commit is contained in:
parent
46f66badf2
commit
eb31e12f32
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user