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 ? (
) : (
| {rankMode === 'city' ? '城市' : '运营商'} |
数量 |
{rankMode === 'city'
? cityRows.slice(0, 12).map((item, idx) => (
|
{item.city}
{item.region ? · {item.region} : null}
|
{formatNum(item.count)} |
))
: asns.slice(0, 12).map((item) => (
|
{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' && (
)}
{tab === 'settings' && settings && (
采集设置
GeoIP(地理与运营商)
国家/省/市由 IP2Location DB3 BIN 解析;ASN 由 GeoLite2-ASN.mmdb 解析(Docker 镜像不内置,需放入数据目录):
-
IPv4 BIN:
{settings.ip2location_v4_path || 'data/IP2LOCATION-LITE-DB3.BIN'}
{' · '}{settings.ip2location_v4_available ? '已加载' : '未检测到'}
-
IPv6 BIN:
{settings.ip2location_v6_path || 'data/IP2LOCATION-LITE-DB3.IPV6.BIN'}
{' · '}{settings.ip2location_v6_available ? '已加载' : '未检测到'}
-
ASN:
{settings.geoip_asn_path || 'data/GeoLite2-ASN.mmdb'}
{' · '}{settings.geoip_asn_available ? '已加载' : '未检测到'}
-
Country 兜底:
{settings.geoip_country_path || 'data/GeoLite2-Country.mmdb'}
{' · '}{settings.geoip_country_available ? '已加载' : '未检测到(可选)'}
请求日志目录:{settings.access_log_dir || 'data/logs/access'}
(按日 JSONL 文件,删除过期文件即可释放磁盘)
CDN 国家头(如 CF-IPCountry)仅在本地库无国家码时补全。管理端展示完整客户端 IP。
)}
);
}