fix: 对齐监控概览双通道口径文案与诊断抽样
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -171,6 +171,10 @@ func main() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
|
||||
printGeoReconcileSamples(db, have, since)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
|
||||
cols := []string{"id", "created_at", "path", "is_bot"}
|
||||
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||
@@ -217,10 +221,103 @@ func main() {
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
|
||||
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
|
||||
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
|
||||
type sample struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
IP string
|
||||
Country string
|
||||
Region string
|
||||
RegionISO string `gorm:"column:region_iso"`
|
||||
City string
|
||||
ASN uint
|
||||
ASOrg string `gorm:"column:as_org"`
|
||||
Path string
|
||||
}
|
||||
selCols := []string{"id", "created_at", "path"}
|
||||
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||
if have[c] {
|
||||
selCols = append(selCols, c)
|
||||
}
|
||||
}
|
||||
sel := strings.Join(selCols, ", ")
|
||||
|
||||
printBlock := func(title string, n int64, rows []sample) {
|
||||
fmt.Printf("%s: %d\n", title, n)
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range rows {
|
||||
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
|
||||
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if have["city"] {
|
||||
var n int64
|
||||
_ = db.Table("page_views").
|
||||
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||
Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("英文城市名(可能未中文化)", n, rows)
|
||||
} else {
|
||||
fmt.Println("英文城市名: (city 列缺失)")
|
||||
}
|
||||
|
||||
if have["region"] || have["region_iso"] {
|
||||
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
|
||||
emptyParts := []string{}
|
||||
if have["region"] {
|
||||
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
|
||||
}
|
||||
if have["region_iso"] {
|
||||
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
|
||||
}
|
||||
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
|
||||
var n int64
|
||||
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where(where, since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("有国家但省为空", n, rows)
|
||||
} else {
|
||||
fmt.Println("有国家但省为空: (省列缺失)")
|
||||
}
|
||||
|
||||
if have["asn"] {
|
||||
var n int64
|
||||
_ = db.Table("page_views").
|
||||
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||
Count(&n).Error
|
||||
var rows []sample
|
||||
_ = db.Table("page_views").
|
||||
Select(sel).
|
||||
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
|
||||
Order("id DESC").
|
||||
Limit(5).
|
||||
Scan(&rows).Error
|
||||
printBlock("asn=0(无运营商)", n, rows)
|
||||
} else {
|
||||
fmt.Println("asn=0: (asn 列缺失)")
|
||||
}
|
||||
}
|
||||
|
||||
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
|
||||
fmt.Println("=== Geo 数据文件 ===")
|
||||
v4, v6, asn, country := suite.Paths()
|
||||
|
||||
@@ -37,6 +37,8 @@ CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat
|
||||
|
||||
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
|
||||
|
||||
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
|
||||
|
||||
## 写入路径(性能)
|
||||
|
||||
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
|
||||
@@ -70,6 +72,7 @@ CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat
|
||||
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||
```
|
||||
|
||||
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
|
||||
## 明确不做
|
||||
|
||||
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import china from '@svg-maps/china';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils';
|
||||
import { buildChinaRegionCountMap, heatFill } from './monitorMapUtils';
|
||||
|
||||
type Loc = { id: string; name: string; path: string };
|
||||
|
||||
@@ -15,10 +15,19 @@ type RegionStat = {
|
||||
type Props = {
|
||||
regions: RegionStat[];
|
||||
className?: string;
|
||||
/** 近 30 日是否已有国家级浏览量 */
|
||||
hasCountryHits?: boolean;
|
||||
/** 近 30 日是否已有省级浏览量 */
|
||||
hasRegionHits?: boolean;
|
||||
};
|
||||
|
||||
/** 中国省区地图:按 BIN 解析出的省/区填色 */
|
||||
export default function MonitorChinaMap({ regions, className }: Props) {
|
||||
export default function MonitorChinaMap({
|
||||
regions,
|
||||
className,
|
||||
hasCountryHits = false,
|
||||
hasRegionHits = false,
|
||||
}: Props) {
|
||||
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
|
||||
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
|
||||
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
|
||||
@@ -26,6 +35,16 @@ export default function MonitorChinaMap({ regions, className }: Props) {
|
||||
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
|
||||
const viewBox = (china as { viewBox: string }).viewBox;
|
||||
|
||||
let emptyTitle = '暂无省级访问数据';
|
||||
let emptyHint = '放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色';
|
||||
if (hasCountryHits && !hasRegionHits) {
|
||||
emptyTitle = '有国家浏览量,尚无省级数据';
|
||||
emptyHint = '需前台路由产生带省字段的浏览量;仅请求日志不会点亮中国地图。请确认已放置 IP2Location DB3 BIN。';
|
||||
} else if (!hasCountryHits) {
|
||||
emptyTitle = '暂无浏览量地理数据';
|
||||
emptyHint = '需前台路由产生浏览量后才会按省填色;仅请求日志不会点亮此处。';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('admin-monitor-svg-wrap', className)}>
|
||||
<svg
|
||||
@@ -63,8 +82,8 @@ export default function MonitorChinaMap({ regions, className }: Props) {
|
||||
)}
|
||||
{!hasData && (
|
||||
<div className="admin-monitor-map-empty-overlay">
|
||||
<p>暂无省级访问数据</p>
|
||||
<p>放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色</p>
|
||||
<p>{emptyTitle}</p>
|
||||
<p>{emptyHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -79,23 +79,29 @@ function GeoRankTable({
|
||||
cities,
|
||||
asns,
|
||||
mode,
|
||||
mapMode,
|
||||
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'
|
||||
? '放置 IP2Location DB3 BIN 后可见城市排行'
|
||||
: '放置 GeoLite2-ASN.mmdb 后可见运营商排行';
|
||||
? (hasCountryHits
|
||||
? '需前台路由产生带城市的浏览量;仅请求日志不会进入此排行。请确认已放置 IP2Location DB3 BIN。'
|
||||
: '需前台路由产生浏览量后才会有城市排行;仅请求日志不会点亮此处。')
|
||||
: (hasCountryHits
|
||||
? '需浏览量写入 ASN;请确认已放置 GeoLite2-ASN.mmdb。仅请求日志不会进入此排行。'
|
||||
: '需前台路由产生浏览量后才会有运营商排行;仅请求日志不会点亮此处。');
|
||||
|
||||
return (
|
||||
<div className="admin-monitor-rank">
|
||||
@@ -117,7 +123,7 @@ function GeoRankTable({
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-monitor-geo-empty">
|
||||
<p>暂无来源数据</p>
|
||||
<p>暂无浏览量来源数据</p>
|
||||
<p className="admin-monitor-muted">{emptyHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -282,18 +288,24 @@ export default function AdminMonitorPage() {
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (!overview) return [];
|
||||
const pvHint = '前台浏览';
|
||||
const accessHintShort = '服务端请求';
|
||||
const accessHintFull = '服务端请求(含 API,重启后重计)';
|
||||
return [
|
||||
{ label: '浏览量', value: formatNum(overview.pageviews) },
|
||||
{ label: '访客数', value: formatNum(overview.visitors) },
|
||||
{ label: '独立 IP', value: formatNum(overview.unique_ips) },
|
||||
{ label: '流量', value: formatBytes(overview.traffic) },
|
||||
{ label: '蜘蛛', value: formatNum(overview.bots) },
|
||||
{ label: '请求数', value: formatNum(overview.requests) },
|
||||
{ label: '4xx', value: formatNum(overview.status_4xx) },
|
||||
{ label: '5xx', value: formatNum(overview.status_5xx) },
|
||||
{ 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);
|
||||
@@ -352,7 +364,7 @@ export default function AdminMonitorPage() {
|
||||
网站监控
|
||||
</h1>
|
||||
<p className="admin-page-desc">
|
||||
浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api)
|
||||
浏览量/访客与访客地图来自前台路由 pageview;请求数、流量与请求日志来自服务端访问采集(含 /api,重启后请求类今日指标重计)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,8 +403,11 @@ export default function AdminMonitorPage() {
|
||||
</div>
|
||||
<div className="admin-monitor-today-grid">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="admin-monitor-today-item">
|
||||
<div className="admin-monitor-today-label">{m.label}</div>
|
||||
<div key={m.label} className="admin-monitor-today-item" title={m.title}>
|
||||
<div className="admin-monitor-today-label">
|
||||
{m.label}
|
||||
<span className="admin-monitor-today-hint">{m.hint}</span>
|
||||
</div>
|
||||
<div className="admin-monitor-today-value">{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -404,7 +419,13 @@ export default function AdminMonitorPage() {
|
||||
<div className="admin-card-head admin-monitor-map-head">
|
||||
<div className="admin-monitor-today-head" style={{ marginBottom: 0 }}>
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2 className="admin-monitor-section-title">访客地图(30 日)</h2>
|
||||
<div>
|
||||
<h2 className="admin-monitor-section-title">访客地图</h2>
|
||||
<p className="admin-monitor-map-sub">
|
||||
基于前台浏览 · 近 30 日
|
||||
{mapMode === 'china' ? ' · 按省/区填色' : ' · 按国家填色'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-monitor-seg" role="group" aria-label="地图范围">
|
||||
<button
|
||||
@@ -428,7 +449,11 @@ export default function AdminMonitorPage() {
|
||||
{mapMode === 'world' ? (
|
||||
<MonitorWorldMap items={geoCountries(geo)} />
|
||||
) : (
|
||||
<MonitorChinaMap regions={geo?.regions || []} />
|
||||
<MonitorChinaMap
|
||||
regions={geo?.regions || []}
|
||||
hasCountryHits={hasCountryHits}
|
||||
hasRegionHits={hasRegionHits}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-monitor-map-side">
|
||||
@@ -438,6 +463,7 @@ export default function AdminMonitorPage() {
|
||||
mode={mapMode}
|
||||
rankMode={rankMode}
|
||||
onRankMode={setRankMode}
|
||||
hasCountryHits={hasCountryHits}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,10 +580,10 @@ export default function AdminMonitorPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((row) => {
|
||||
const placeBits = [row.region, row.city, row.as_org].filter(Boolean);
|
||||
const geoBits = [
|
||||
row.country ? countryLabel(row.country) : '',
|
||||
row.city || '',
|
||||
row.as_org || '',
|
||||
...placeBits,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
@@ -568,9 +594,9 @@ export default function AdminMonitorPage() {
|
||||
<td className="admin-table-mono">
|
||||
{row.ip || '—'}
|
||||
{row.is_bot ? ' · bot' : ''}
|
||||
{(row.city || row.as_org) ? (
|
||||
{placeBits.length > 0 ? (
|
||||
<div className="admin-monitor-muted" style={{ fontSize: 12 }}>
|
||||
{[row.city, row.as_org].filter(Boolean).join(' · ')}
|
||||
{placeBits.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
|
||||
@@ -15356,6 +15356,18 @@ button.post-poll__option,
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.admin-monitor-today-hint {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.admin-monitor-today-value {
|
||||
font-size: 22px;
|
||||
@@ -15365,6 +15377,13 @@ button.post-poll__option,
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
}
|
||||
.admin-monitor-map-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-monitor-main {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user