diff --git a/components/admin/PublicTrafficTab.tsx b/components/admin/PublicTrafficTab.tsx index 23992fd..26a2cca 100644 --- a/components/admin/PublicTrafficTab.tsx +++ b/components/admin/PublicTrafficTab.tsx @@ -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(null) + const [statsRangeKey, setStatsRangeKey] = useState('') const [expandedPaths, setExpandedPaths] = useState>(new Set()) + const requestSeqRef = useRef(0) + const abortRef = useRef(null) + const statsCacheRef = useRef(new Map()) + 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) => { 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() + if (!visibleStats) return new Map() const map = new Map() - 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() - 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 (
- {loading ? '加载中...' : '暂无数据'} + {loading ? '正在读取固化汇总...' : '暂无数据'}
) } @@ -383,13 +432,13 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) { - {!stats.available && ( + {!visibleStats.available && (
暂未读取到公网入口日志。请配置 PUBLIC_TRAFFIC_LOG_PATHS 环境变量,或把穿透服务器 Nginx access log 同步到应用服务器。
)} - {stats.available && ( + {visibleStats.available && ( <> {/* ===== 顶部汇总卡片 ===== */}
@@ -411,17 +460,17 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) { {/* 请求次数 */}
请求次数
-
{formatNumber(stats.summary.totalRequests)}
-
{formatNumber(stats.summary.uniqueIpCount)} 个客户端 IP
+
{formatNumber(visibleStats.summary.totalRequests)}
+
{formatNumber(visibleStats.summary.uniqueIpCount)} 个客户端 IP
{/* 日志时间范围 */}
日志范围
- {formatTime(stats.summary.firstSeen).slice(5, 17)} + {formatTime(visibleStats.summary.firstSeen).slice(5, 17)}
-
至 {formatTime(stats.summary.lastSeen).slice(5, 17)}
+
至 {formatTime(visibleStats.summary.lastSeen).slice(5, 17)}
@@ -522,8 +571,8 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {

按域名

- {stats.byDomain.length === 0 &&
无数据
} - {stats.byDomain.map((item) => { + {visibleStats.byDomain.length === 0 &&
无数据
} + {visibleStats.byDomain.map((item) => { const pct = responseBytes > 0 ? Math.round((item.responseBytes / responseBytes) * 100) : 0 return (
@@ -548,9 +597,9 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {

每日流量

- {stats.byDay.length === 0 &&
无数据
} + {visibleStats.byDay.length === 0 &&
无数据
}
- {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) { ) })}
- {stats.byDay.length > 0 && ( + {visibleStats.byDay.length > 0 && (
- {stats.byDay[0]?.date.slice(5)} - {stats.byDay[stats.byDay.length - 1]?.date.slice(5)} + {visibleStats.byDay[0]?.date.slice(5)} + {visibleStats.byDay[visibleStats.byDay.length - 1]?.date.slice(5)}
)}
@@ -582,15 +631,15 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
-

高消耗资源 Top {stats.topPaths.length}

+

高消耗资源 Top {visibleStats.topPaths.length}

点击展开查看原始路径

- {stats.topPaths.length === 0 && ( + {visibleStats.topPaths.length === 0 && (
无数据
)} - {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) { {/* ===== 底部统计信息 ===== */}
- 已扫描 {formatNumber(stats.source.scannedFiles)} 个日志文件 + {visibleStats.source.cache?.persisted ? '数据源:固化汇总表' : `已扫描 ${formatNumber(visibleStats.source.scannedFiles)} 个日志文件`} · - {formatNumber(stats.source.scannedLines)} 行,命中 {formatNumber(stats.source.matchedLines)} 行 - {stats.source.truncated && · 读取达上限,结果可能不完整} + {formatNumber(visibleStats.source.matchedLines)} 条有效请求 + {visibleStats.source.truncated && · 读取达上限,结果可能不完整}
- {!stats.source.requestBytesAvailable && ( + {!visibleStats.source.requestBytesAvailable && (
日志未含 request_length 字段,上传入站流量无法精确拆分。
)} - {stats.source.cache && ( + {visibleStats.source.cache && (
数据缓存: - {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 && ',正在后台刷新'}
)}
diff --git a/lib/public-traffic-stats.ts b/lib/public-traffic-stats.ts index 846275f..10c4fb8 100644 --- a/lib/public-traffic-stats.ts +++ b/lib/public-traffic-stats.ts @@ -188,6 +188,13 @@ interface PublicTrafficRollupRow { status_other: number } +interface PublicTrafficRollupAggregationState { + dayMap: Map + domainMap: Map + purposeMap: Map + topPathMap: Map +} + const MONTHS: Record = { 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