import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Activity, AlertTriangle } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { notify } from '@/lib/notify'; import { cn } from '@/lib/utils'; import { api } from '../../api/client'; import type { MonitorASNItem, MonitorCityItem, MonitorConfig, MonitorGeoResult, MonitorLogItem, MonitorOverview, MonitorRealtime, MonitorStatItem, } from '../../api/types'; import { useAdminGuard } from '../../layouts/AdminLayout'; import { formatTime } from '../../utils/content'; import { invalidateForumLimitsCache } from '../../hooks/useForumLimits'; import MonitorWorldMap from '../../components/admin/MonitorWorldMap'; import MonitorChinaMap from '../../components/admin/MonitorChinaMap'; import { countryLabelZh } from '../../components/admin/monitorMapUtils'; type TabKey = 'overview' | 'stats' | 'logs' | 'settings'; type StatsDim = 'url' | 'referer' | 'browser' | 'os' | 'device' | 'status'; type StatsRange = '1d' | '7d' | '30d' | '90d'; type MapMode = 'world' | 'china'; type RankMode = 'city' | 'asn'; function countryLabel(code: string) { return countryLabelZh(code); } function formatBytes(n: number) { if (!n || n < 0) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = n; let i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`; } function formatNum(n: number) { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`; return String(n ?? 0); } function MiniSparkline({ series }: { series: { count: number }[] }) { const max = Math.max(1, ...series.map((p) => p.count)); const w = 240; const h = 48; const pts = series.map((p, i) => { const x = series.length <= 1 ? 0 : (i / (series.length - 1)) * w; const y = h - (p.count / max) * (h - 4) - 2; return `${x},${y}`; }).join(' '); return ( ); } const CHINA_CODES = new Set(['CN', 'HK', 'TW', 'MO']); function geoCountries(geo: MonitorGeoResult | null) { return geo?.countries || []; } function GeoRankTable({ cities, asns, mode, rankMode, onRankMode, hasCountryHits, }: { cities: MonitorCityItem[]; asns: MonitorASNItem[]; mode: MapMode; rankMode: RankMode; onRankMode: (m: RankMode) => void; /** 近 30 日是否已有国家级浏览量(用于区分「无 PV 地理」与缺库) */ hasCountryHits: boolean; }) { const cityRows = mode === 'china' ? cities.filter((i) => CHINA_CODES.has((i.country || '').toUpperCase())) : cities; const rows = rankMode === 'city' ? cityRows : asns; const emptyHint = rankMode === 'city' ? (hasCountryHits ? '需前台路由产生带城市的浏览量;仅请求日志不会进入此排行。请确认已放置 IP2Location DB3 BIN。' : '需前台路由产生浏览量后才会有城市排行;仅请求日志不会点亮此处。') : (hasCountryHits ? '需浏览量写入 ASN;请确认已放置 GeoLite2-ASN.mmdb。仅请求日志不会进入此排行。' : '需前台路由产生浏览量后才会有运营商排行;仅请求日志不会点亮此处。'); return (
{rows.length === 0 ? (

暂无浏览量来源数据

{emptyHint}

) : ( {rankMode === 'city' ? cityRows.slice(0, 12).map((item, idx) => ( )) : asns.slice(0, 12).map((item) => ( ))}
{rankMode === 'city' ? '城市' : '运营商'} 数量
{item.city} {item.region ? · {item.region} : null} {formatNum(item.count)}
{item.as_org || `AS${item.asn}`} · AS{item.asn} {formatNum(item.count)}
)}
); } function parseMonitorTab(raw: string | null): TabKey { if (raw === 'stats' || raw === 'logs' || raw === 'settings' || raw === 'overview') { return raw; } return 'overview'; } export default function AdminMonitorPage() { const { ready } = useAdminGuard(); const [searchParams, setSearchParams] = useSearchParams(); const [tab, setTab] = useState(() => parseMonitorTab(searchParams.get('tab'))); const [overview, setOverview] = useState(null); const [geo, setGeo] = useState(null); const [realtime, setRealtime] = useState(null); const [mapMode, setMapMode] = useState('world'); const [rankMode, setRankMode] = useState('city'); const [loading, setLoading] = useState(true); const [statsDim, setStatsDim] = useState('url'); const [statsRange, setStatsRange] = useState('30d'); const [statsItems, setStatsItems] = useState([]); const [statsLoading, setStatsLoading] = useState(false); const [logs, setLogs] = useState([]); const [logsTotal, setLogsTotal] = useState(0); const [logsPage, setLogsPage] = useState(1); const [logMethod, setLogMethod] = useState(''); const [logPath, setLogPath] = useState(''); const [logStatus, setLogStatus] = useState(''); const [logIP, setLogIP] = useState(''); const [logsLoading, setLogsLoading] = useState(false); const [settings, setSettings] = useState(null); const [excludeText, setExcludeText] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { const next = parseMonitorTab(searchParams.get('tab')); setTab((prev) => (prev === next ? prev : next)); }, [searchParams]); const selectTab = (key: TabKey) => { setTab(key); const next = new URLSearchParams(searchParams); if (key === 'overview') next.delete('tab'); else next.set('tab', key); setSearchParams(next, { replace: true }); }; const loadOverview = useCallback(async () => { const [ov, g, rt] = await Promise.all([ api.adminMonitorOverview(), api.adminMonitorGeo('30d'), api.adminMonitorRealtime(), ]); setOverview(ov); setGeo(g); setRealtime(rt); }, []); useEffect(() => { if (!ready) return; setLoading(true); loadOverview() .catch(() => notify.error('加载监控概览失败')) .finally(() => setLoading(false)); }, [ready, loadOverview]); // 概览页实时轮询 useEffect(() => { if (!ready || tab !== 'overview') return; const id = window.setInterval(() => { api.adminMonitorRealtime().then(setRealtime).catch(() => {}); api.adminMonitorOverview().then(setOverview).catch(() => {}); }, 5000); return () => window.clearInterval(id); }, [ready, tab]); useEffect(() => { if (!ready || tab !== 'stats') return; setStatsLoading(true); api.adminMonitorStats(statsDim, statsRange) .then((r) => setStatsItems(r.items || [])) .catch(() => notify.error('加载统计失败')) .finally(() => setStatsLoading(false)); }, [ready, tab, statsDim, statsRange]); const loadLogs = useCallback(async (page = 1) => { setLogsLoading(true); try { const r = await api.adminMonitorLogs({ page, size: 20, method: logMethod || undefined, path: logPath || undefined, status: logStatus || undefined, ip: logIP || undefined, }); setLogs(r.items || []); setLogsTotal(r.total || 0); setLogsPage(r.page || page); } catch { notify.error('加载请求日志失败'); } finally { setLogsLoading(false); } }, [logMethod, logPath, logStatus, logIP]); useEffect(() => { if (!ready || tab !== 'logs') return; loadLogs(1); }, [ready, tab, loadLogs]); useEffect(() => { if (!ready || tab !== 'settings') return; api.adminMonitorSettings() .then((cfg) => { setSettings(cfg); setExcludeText((cfg.exclude_rules || []).join('\n')); }) .catch(() => notify.error('加载设置失败')); }, [ready, tab]); const enabled = overview?.enabled ?? settings?.enabled ?? false; const metrics = useMemo(() => { if (!overview) return []; const pvHint = '前台浏览'; const accessHintShort = '服务端请求'; const accessHintFull = '服务端请求(含 API,重启后重计)'; return [ { label: '浏览量', hint: pvHint, title: pvHint, value: formatNum(overview.pageviews) }, { label: '访客数', hint: pvHint, title: pvHint, value: formatNum(overview.visitors) }, { label: '独立 IP', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.unique_ips) }, { label: '流量', hint: accessHintShort, title: accessHintFull, value: formatBytes(overview.traffic) }, { label: '蜘蛛', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.bots) }, { label: '请求数', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.requests) }, { label: '4xx', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.status_4xx) }, { label: '5xx', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.status_5xx) }, ]; }, [overview]); const hasCountryHits = (geo?.countries?.length || 0) > 0; const hasRegionHits = (geo?.regions?.length || 0) > 0; const saveSettings = async () => { if (!settings) return; setSaving(true); try { const rules = excludeText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); const r = await api.adminUpdateMonitorSettings({ ...settings, exclude_rules: rules, }); setSettings(r.monitor); setExcludeText((r.monitor.exclude_rules || []).join('\n')); invalidateForumLimitsCache(); notify.success(r.message); const ov = await api.adminMonitorOverview(); setOverview(ov); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '保存失败'); } finally { setSaving(false); } }; if (!ready || (loading && tab === 'overview')) { return
; } const tabs: { key: TabKey; label: string }[] = [ { key: 'overview', label: '概览' }, { key: 'stats', label: '访问统计' }, { key: 'logs', label: '请求日志' }, { key: 'settings', label: '设置' }, ]; const dims: { key: StatsDim; label: string }[] = [ { key: 'url', label: 'URL' }, { key: 'referer', label: '来源' }, { key: 'browser', label: '浏览器' }, { key: 'os', label: '系统' }, { key: 'device', label: '设备' }, { key: 'status', label: '状态码' }, ]; const ranges: { key: StatsRange; label: string }[] = [ { key: '1d', label: '今日' }, { key: '7d', label: '7 日' }, { key: '30d', label: '30 日' }, { key: '90d', label: '90 日' }, ]; return (

网站监控

浏览量/访客与访客地图来自前台路由 pageview;请求数、流量与请求日志来自服务端访问采集(含 /api,重启后请求类今日指标重计)

{tabs.map((t) => ( ))}
{!enabled && tab !== 'settings' && (
访问采集尚未开启,指标可能为空。
)} {tab === 'overview' && overview && ( <>

今日状态

{metrics.map((m) => (
{m.label} {m.hint}
{m.value}
))}

访客地图

基于前台浏览 · 近 30 日 {mapMode === 'china' ? ' · 按省/区填色' : ' · 按国家填色'}

{mapMode === 'world' ? ( ) : ( )}

实时请求数(1 分钟)

{formatNum(realtime?.requests_1m || 0)}

实时流量(1 分钟)

{formatBytes(realtime?.traffic_1m || 0)}

近 1 小时请求

)} {tab === 'stats' && (
{dims.map((d) => ( ))}
{ranges.map((r) => ( ))}
{statsLoading ? (
) : statsItems.length === 0 ? (

暂无统计数据

) : (
{statsItems.map((item, idx) => ( ))}
# 名称 数量
{idx + 1} {item.key} {formatNum(item.count)}
)}
)} {tab === 'logs' && (
setLogMethod(e.target.value)} placeholder="方法 GET" aria-label="方法" style={{ width: 100 }} /> setLogPath(e.target.value)} placeholder="路径包含…" aria-label="路径" /> setLogStatus(e.target.value)} placeholder="状态码" aria-label="状态码" style={{ width: 90 }} /> setLogIP(e.target.value)} placeholder="IP 包含…" aria-label="IP" style={{ width: 140 }} />
{logsLoading ? (
) : logs.length === 0 ? (

暂无请求日志

) : (
{logs.map((row) => { const placeBits = [row.region, row.city, row.as_org].filter(Boolean); const geoBits = [ row.country ? countryLabel(row.country) : '', ...placeBits, ].filter(Boolean); return ( ); })}
时间 方法 路径 状态 IP 耗时 地区
{formatTime(row.created_at)} {row.method} {row.path} {row.status} {row.ip || '—'} {row.is_bot ? ' · bot' : ''} {placeBits.length > 0 ? (
{placeBits.join(' · ')}
) : null}
{row.duration_ms}ms {row.country ? countryLabel(row.country) : '—'}
)} {logsTotal > 20 && (
第 {logsPage} 页 / 共 {logsTotal} 条
)}
)} {tab === 'settings' && settings && (
采集设置