国家 / 地区分布改为流向图,域名国家持久化(debug11)

- 流向图:从本地(WAN 口公网 IP 所在国家,默认中国)飞向各国,线宽 / 气泡大小表示流量,前 5 名标名字和流量;窄屏标签放上方
- 右侧排行:名次、本地标记、条形占比,“无法定位”放最后并说明原因
- 地图数据:跨 180° 经线的环(俄罗斯远东、斐济)移到东侧,不再画出横线
- 新表 domain_geo:记录流量时存下域名连到的 IP 和国家,不再依赖会过期的 DNS 缓存
- README 截图与描述同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Su YijunandClaude Opus 5.5 committed 2026-10-23 03:26:10 +08:00
1 parent f5d3112450
commit e3d6bc9d47
14 files changed
+233 -46

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ debug11
- 家庭成员 / 人员必须属于一个分组 / 部门(后端校验);同一分组里不能重名;添加成员时没有分组可以就地新建。
- 成员列表每行加编辑按钮,详情页加编辑和删除(之前已有成员打不开编辑弹窗,也就删不掉)。
- 分组 / 部门下还有成员时不能删除,会提示先移走;以前没有分组的成员单独显示为“未分组”,方便补上。
- 国家 / 地区分布改为**流向图**:从本地(按 WAN 口公网 IP 判断,默认中国)飞向各国,线宽和气泡大小表示流量,流量最大的 5 个国家直接标名字和流量;右侧改为带名次的排行,“无法定位”放最后并说明原因。修正跨 180° 经线的国家在地图上画出的横线。
- 域名的国家在记录流量时就存下(新表 domain_geo),不再依赖会过期的 DNS 缓存;之前重启或隔天后大部分目标会显示为“无法定位”。
## [0.5.0] - 2026-10-23
+2 -2
View File
@@ -15,7 +15,7 @@
- **全网概览**:路由器状态、**网络健康指数**(逐项扣分和改进建议)、在线终端、**DHCP 地址池**、每条 **WAN 线路**的状态和速率;流量构成饼图(应用大类 → 小类);实时流量、历史流量、历史在线终端数;Top 应用与 Top 终端排行。
- **主机监控**:在线终端 / 全部终端 / DHCP 租约 / 命名管理四个标签。每台终端的类型(手机、电脑、电视、摄像头……自动识别)、IP、MAC、主机名、归属人、在线时长、连接数、当前速率、今日 / 本周 / 本月流量,可导出 CSV;可以按 MAC 或按 IP 给设备命名。详情页有应用排行与构成、按大类的趋势、访问过的域名、访问日志和上网时间线。
- **应用统计**:参照 Panabit / 维盟分三级(应用大类 → 小类 → 应用,逐级“查看详情”下钻)。大类和小类固定列出,每级显示上传 / 下载速度和数据及占比;应用构成用矩形树图一眼看两层。共 10 个大类、43 个小类、2800 多个内置应用(抖音、B 站、微信视频号、Steam、银行、券商、ToDesk、DeepSeek……,其中约 2500 个来自 v2fly 开源域名库)。识别不出具体应用的流量,会按 CDN 的 CNAME 归到阿里云 / 腾讯云 / Akamai 等厂商。
- **流量流向**:**实时连接**(此刻哪台终端连到哪个域名 / IP、什么应用、多快;走 sing-box 的连接显示策略组和出口节点)、**访问目标排行**(流量最大的远端域名和 IP,世界地图和国家 / 地区分布,可按主域名合并,看是哪些终端在访问)、**代理分流**(sing-box 各策略组、各节点的流量和主要应用)。
- **流量流向**:**实时连接**(此刻哪台终端连到哪个域名 / IP、什么应用、多快;走 sing-box 的连接显示策略组和出口节点)、**访问目标排行**(流量最大的远端域名和 IP;流向图从本地飞向各国,看流量去了哪些国家 / 地区;可按主域名合并,看是哪些终端在访问)、**代理分流**(sing-box 各策略组、各节点的流量和主要应用)。
- **家庭成员 / 人员**:把一个人的手机、平板、电脑归到一起,按人统计流量、在线时长、常用应用,以及最近 7 天的行为标签(附依据)。按使用场景:
- **家用**:关注孩子的上网健康,包括每日屏幕时间、游戏和短视频时长上限,熬夜,不良内容,学习时间玩娱乐应用。
- **企业**:关注效率与合规,包括工作时间娱乐占比、求职倾向、上班炒股,按部门统计。
@@ -250,7 +250,7 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5
- **Overview**: router status, a network health score with fixes, DHCP pool usage, WAN line status, online-host history, live rate, a traffic-mix pie (app group → category), and top apps and hosts.
- **Hosts**: every device with its detected type (phone, computer, TV, camera…), IP, MAC, hostname, owner, online state, live rate and daily / weekly / monthly totals. Name devices by MAC or by IP. Each device has an app breakdown, visited domains, an access log and a daily timeline.
- **Applications**: a three-level drill-down (app group → category → app) with fixed groups and categories, upload/download rates and totals with shares at each level, and a treemap of the mix. It covers 10 groups, 43 categories and 2800+ built-in apps (about 2500 converted from v2fly/domain-list-community). Traffic that can't be tied to an app is attributed to its CDN via CNAME.
- **Flows**: live connections (sing-box connections show their policy group and outbound node), top destinations on a world map with a country breakdown, and proxy routing (traffic and top apps per sing-box policy group and node).
- **Flows**: live connections (sing-box connections show their policy group and outbound node), top destinations with a flow map from your location to each country, and proxy routing (traffic and top apps per sing-box policy group and node).
- **People / Family**: group each person's devices and see their traffic, online time, top apps and behaviour tags for the last 7 days, with the evidence behind each tag.
- **Home**: daily screen-time, gaming and short-video limits, late nights, adult content, and leisure during homework time.
- **Business**: leisure share in working hours, job hunting and trading at work, with departments.
+25 -1
View File
@@ -16,7 +16,7 @@ from app.config import get_settings
from app import database, hostnames
from app.database import upsert_add
from app.dnsmap import ConnNames, DnsMap
from app.models import (AccessLog, AuditEvent, CustomRule, DnsCache, Host, HostDomainDay, OpLog, Setting, TrafficDay,
from app.models import (AccessLog, AuditEvent, CustomRule, DnsCache, DomainGeo, Host, HostDomainDay, OpLog, Setting, TrafficDay,
TrafficHour,
TrafficMinute)
@@ -62,6 +62,7 @@ class Engine:
self._dom: dict[tuple[str, str, str], int] = defaultdict(int)
self._seen: dict[str, tuple[str, str | None, int]] = {} # host_key -> (ip, mac, ts)
self._ipdest: dict[tuple[str, str, str], int] = defaultdict(int) # (host_key, 远端 IP, app) -> bytes
self._dom_ip: dict[str, str] = {} # 域名 -> 这一分钟里它连到的 IP(DomainGeo)
# 实时连接:(host_key, 远端 IP, 远端端口, 协议, 终端端口) ->
# [终端 IP, app, 域名, 域名来源, 累计上行, 累计下行, 开始, 最近, 本窗口上行, 本窗口下行, 上行速率, 下行速率]
self.sessions: OrderedDict[tuple, list] = OrderedDict()
@@ -360,6 +361,8 @@ class Engine:
b[2] += 1
if qname:
self._dom[(key, qname, app)] += nbytes
if len(self._dom_ip) < 50_000:
self._dom_ip[qname] = remote # 域名实际连到的 IP,落库时查国家
else:
self._ipdest[(key, remote, app)] += nbytes
self._track_session(key, host_ip, hport, remote, rport, proto, app, qname, src_kind, direction, nbytes, now)
@@ -462,6 +465,7 @@ class Engine:
visits, visit_upd = self.take_visits(now, final)
buf, self._buf = self._buf, defaultdict(lambda: [0, 0, 0])
dom, self._dom = self._dom, defaultdict(int)
dom_ip, self._dom_ip = self._dom_ip, {}
seen, self._seen = self._seen, {}
ipdest, self._ipdest = self._ipdest, defaultdict(int)
# 实时连接:本窗口的字节数换算成速率,然后清零;去掉 5 分钟没动静的
@@ -520,6 +524,9 @@ class Engine:
for ip, v in dirty_dns.items()]
for chunk in _chunks(drs, 500):
upsert_add(db, DnsCache.__table__, chunk, ["ip"], [], ["name", "cname", "expires"])
geo = _domain_geo(dom_ip, int(now))
for chunk in _chunks(geo, 500):
upsert_add(db, DomainGeo.__table__, chunk, ["domain"], [], ["ip", "cc", "updated"])
db.commit()
finished, self.finished_visits = getattr(self, "finished_visits", []), []
if finished:
@@ -592,6 +599,23 @@ class Engine:
return app in GENERIC_APPS
def _domain_geo(dom_ip: dict[str, str], now: int) -> list[dict]:
"""域名 -> (IP, 国家);只记公网 IP,查不到国家的不记"""
from app.asn import asndb
out = []
for name, ip in dom_ip.items():
try:
a = ipaddress.ip_address(ip)
except ValueError:
continue
if not a.is_global:
continue
hit = asndb.lookup(ip)
if hit and hit[1]:
out.append({"domain": name[:255], "ip": ip, "cc": hit[1][:2], "updated": now})
return out
def _is_ip(x: str) -> bool:
try:
ipaddress.ip_address(x)
+9
View File
@@ -82,6 +82,15 @@ class DnsCache(Base):
expires = Column(BigInteger, nullable=False)
class DomainGeo(Base):
"""域名的地理位置:记录流量时它连到的 IP 和国家(访问目标的国家 / 地区分布用,不随 DNS 缓存过期而丢失)"""
__tablename__ = "domain_geo"
domain = Column(String(255), primary_key=True)
ip = Column(String(45))
cc = Column(String(2))
updated = Column(BigInteger)
class CustomRule(Base):
__tablename__ = "custom_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
+28 -6
View File
@@ -2,13 +2,14 @@ import time
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import health, hostnames, oplog, stats, timeutil
from app.asn import asndb
from app.database import get_db
from app.engine import engine
from app.models import Host, HostName
from app.models import DomainGeo, Host, HostName
from app.ros_poller import poller
from app.security import current_user
@@ -268,21 +269,42 @@ def destinations(range: str = Query("today", pattern=RANGES), q: str = "", merge
start, end = _bounds(range)
rows = stats.destinations(db, start, end, q, merge, kind)
idx = engine.dns.name_index()
names = {n for r in rows if not r["ip"] for n in [r["dest"], *r["name_list"]]}
geo = {}
for chunk in _chunks500(sorted(names)): # 参数名 range 遮住了内置 range,分块放到模块级
geo.update(dict(db.execute(select(DomainGeo.domain, DomainGeo.cc).where(DomainGeo.domain.in_(chunk))).all()))
countries: dict[str, int] = {}
for r in rows:
r["asn"] = _asn(r["dest"]) if r["ip"] else None
if not r["ip"]:
# 域名:用 DNS 缓存里它最近解析到的 IP 查国家(合并主域名时看它的子域名)
ip = idx.get(r["dest"]) or next((idx[n] for n in r["name_list"] if n in idx), None)
hit = asndb.lookup(ip) if ip else None
r["country"] = hit[1] if hit else None
# 域名:记录流量时存下的国家(DomainGeo);没有时再用 DNS 缓存里最近解析到的 IP 查
cc = geo.get(r["dest"]) or next((geo[n] for n in r["name_list"] if n in geo), None)
if not cc:
ip = idx.get(r["dest"]) or next((idx[n] for n in r["name_list"] if n in idx), None)
hit = asndb.lookup(ip) if ip else None
cc = hit[1] if hit else None
r["country"] = cc
else:
r["country"] = r["asn"]["country"] if r["asn"] else None
r.pop("name_list", None)
cc = r["country"] or "-"
countries[cc] = countries.get(cc, 0) + r["bytes"]
return {"range_total": stats.total(db, start, end), "rows": rows,
"countries": [{"cc": k, "bytes": v} for k, v in sorted(countries.items(), key=lambda x: x[1], reverse=True)]}
"countries": [{"cc": k, "bytes": v} for k, v in sorted(countries.items(), key=lambda x: x[1], reverse=True)],
"home": _home_country()}
def _chunks500(xs: list) -> list[list]:
return [xs[i:i + 500] for i in range(0, len(xs), 500)]
def _home_country() -> str:
"""本地所在国家(流向图的起点):WAN 口公网 IP 的归属;拿不到时按中国"""
for w in poller.wans:
hit = asndb.lookup(w.get("ip") or "") if w.get("ip") else None
if hit and hit[1]:
return hit[1]
return "CN"
@router.get("/proxy/routes")
+26
View File
@@ -165,3 +165,29 @@ def test_oui_db_load(tmp_path, monkeypatch):
assert db.lookup("28:EE:52:11:22:33") == "TP-LINK TECHNOLOGIES CO." and db.lookup("BC-6A-D1-00-00-01").startswith("Xiaomi")
assert db.lookup("2A:EE:52:11:22:33") == "" # 随机 MAC 不查
assert devtype.classify("BC:6A:D1:00:00:01", None, vendor_full=db.lookup("BC:6A:D1:00:00:01"))[:2] == ("phone", "Xiaomi")
def test_domain_country_survives_dns_expiry(tmp_path):
"""debug11:域名的国家在记录流量时存下(DomainGeo),DNS 缓存过期或重启后仍能定位"""
import gzip
from app.asn import asndb
p = tmp_path / "ip2asn.tsv.gz"
with gzip.open(p, "wt") as f:
f.write("45.57.0.0\t45.57.127.255\t64500\tJP\tTEST-JP\n")
saved = (asndb._v4, asndb._v6, asndb.names, asndb.rows)
try:
asndb.load(str(p))
asndb._cache = {}
now = time.time()
engine.dns.add("cdn.geo-test.example", [("45.57.10.120", 600)], [])
flow("192.168.88.120", "45.57.10.120", 7301, 4000, "AA:00:00:00:0E:01", now)
engine.flush(now)
engine.dns._d.pop("45.57.10.120", None) # 模拟 DNS 缓存过期 / 重启
with TestClient(app) as c:
h = _h(c)
r = c.get("/api/destinations?range=today&q=geo-test", headers=h).json()
row = next(x for x in r["rows"] if x["dest"] == "cdn.geo-test.example")
assert row["country"] == "JP" and any(x["cc"] == "JP" for x in r["countries"]) and r["home"]
finally:
asndb._v4, asndb._v6, asndb.names, asndb.rows = saved
asndb._cache = {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 176 KiB

+1 -1
View File
@@ -1 +1 @@
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"FJ"},"geometry":{"type":"MultiPolygon","coordinates":[[[[178.12,-17.51],[178.37,-17.34],[178.72,-17.63],[178.55,-18.15],[177.93,-18.29],[177.38,-18.16],[177.29,-17.72],[177.67,-17.38],[178.12,-17.51]]],[[[-180,-16.07],[-179.79,-16.02],[-179.92,-16.5],[-180,-16.56],[179.36,-16.8],[178.73,-17.01],[178.6,-16.64],[179.1,-16.43],[179.41,-16.38],[180,-16.07],[-180,-16.07]]]]}},{"type":"Feature","properties":{"name":"TZ"},"geometry":{"type":"Polygon","coordinates":[[[33.9,-0.95],[34.07,-1.06],[37.7,-3.1],[37.77,-3.68],[39.2,-4.68],[38.74,-5.91],[38.8,-6.48],[39.44,-6.84],[39.47,-7.1],[39.2,-7.7],[39.25,-8.01],[39.19,-8.49],[39.54,-9.11],[39.95,-10.1],[40.32,-10.32],[39.52,-10.9],[38.43,-11.29],[37.83,-11.27],[37.47,-11.57],[36.78,-11.6],[36.51,-11.72],[35.31,-11.44],[34.56,-11.52],[34.28,-10.16],[33.94,-9.69],[33.74,-9.42],[32.76,-9.23],[32.19,-8.93],[31.56,-8.76],[31.16,-8.59],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.75,-4.45],[30.12,-4.09],[30.5,-3.57],[30.75,-3.36],[30.74,-3.03],[30.53,-2.81],[30.47,-2.41],[30.76,-2.29],[30.81,-1.7],[30.42,-1.14],[30.77,-1.01],[31.87,-1.03],[33.9,-0.95]]]}},{"type":"Feature","properties":{"name":"EH"},"geometry":{"type":"Polygon","coordinates":[[[-8.67,27.66],[-8.66,27.59],[-8.69,27.4],[-8.69,25.88],[-11.97,25.93],[-11.94,23.37],[-12.88,23.29],[-13.12,22.77],[-12.93,21.33],[-16.85,21.33],[-17.06,21],[-17.02,21.42],[-17,21.42],[-14.75,21.5],[-14.63,21.86],[-14.22,22.31],[-13.89,23.69],[-12.5,24.77],[-12.03,26.03],[-11.72,26.1],[-11.39,26.88],[-10.55,26.99],[-10.19,26.86],[-9.74,26.86],[-9.41,27.09],[-8.79,27.12],[-8.82,27.66],[-8.67,27.66]]]}},{"type":"Feature","properties":{"name":"CA"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-122.98,49],[-124.91,49.99],[-125.63,50.42],[-127.44,50.83],[-127.99,51.72],[-127.85,52.33],[-129.13,52.76],[-129.3,53.56],[-130.51,54.29],[-130.54,54.8],[-129.98,55.28],[-130.01,55.92],[-131.71,56.55],[-132.73,57.69],[-133.35,58.41],[-134.27,58.86],[-134.95,59.27],[-135.47,59.79],[-136.48,59.46],[-137.45,58.91],[-138.34,59.56],[-139.04,60],[-140.01,60.28],[-141,60.31],[-140.99,66],[-140.99,69.71],[-139.12,69.47],[-137.54,68.99],[-136.5,68.9],[-135.63,69.32],[-134.42,69.63],[-132.93,69.51],[-131.43,69.94],[-129.79,70.19],[-129.11,69.78],[-128.36,70.01],[-128.14,70.48],[-127.45,70.38],[-125.75,69.48],[-124.43,70.16],[-124.29,69.4],[-123.06,69.56],[-122.68,69.86],[-121.47,69.8],[-119.94,69.38],[-117.6,69.01],[-116.23,68.84],[-115.25,68.91],[-113.9,68.4],[-115.3,67.9],[-113.5,67.69],[-110.8,67.81],[-109.95,67.98],[-108.88,67.38],[-107.79,67.89],[-108.81,68.31],[-108.17,68.65],[-106.95,68.7],[-106.15,68.8],[-105.34,68.56],[-104.34,68.02],[-103.22,68.1],[-101.45,67.65],[-99.9,67.81],[-98.44,67.78],[-98.56,68.4],[-97.67,68.58],[-96.12,68.24],[-96.13,67.29],[-95.49,68.09],[-94.69,68.06],[-94.23,69.07],[-95.31,69.68],[-96.47,70.09],[-96.39,71.19],[-95.21,71.92],[-93.89,71.76],[-92.88,71.32],[-91.52,70.19],[-92.41,69.7],[-90.55,69.5],[-90.55,68.47],[-89.21,69.26],[-88.02,68.62],[-88.32,67.87],[-87.35,67.2],[-86.31,67.92],[-85.58,68.78],[-85.52,69.88],[-84.1,69.81],[-82.62,69.66],[-81.28,69.16],[-81.22,68.67],[-81.96,68.13],[-81.26,67.6],[-81.39,67.11],[-83.35,66.41],[-84.74,66.26],[-85.77,66.56],[-86.07,66.06],[-87.03,65.21],[-87.32,64.77],[-88.48,64.1],[-89.91,64.03],[-90.7,63.61],[-90.77,62.96],[-91.93,62.84],[-93.16,62.02],[-94.24,60.9],[-94.63,60.11],[-94.69,58.95],[-93.21,58.78],[-92.76,57.85],[-92.3,57.09],[-90.9,57.29],[-89.04,56.85],[-88.04,56.47],[-87.32,56],[-86.07,55.72],[-85.01,55.3],[-83.36,55.25],[-82.27,55.15],[-82.44,54.28],[-82.13,53.28],[-81.4,52.16],[-79.91,51.21],[-79.14,51.53],[-78.6,52.56],[-79.12,54.14],[-79.83,54.67],[-78.23,55.14],[-77.1,55.84],[-76.54,56.53],[-76.62,57.2],[-77.3,58.05],[-78.52,58.81],[-77.34,59.85],[-77.77,60.76],[-78.11,62.32],[-77.41,62.55],[-75.7,62.28],[-74.67,62.18],[-73.84,62.44],[-72.91,62.11],[-71.68,61.53],[-71.37,61.14],[-69.59,61.06],[-69.62,60.22],[-69.29,58.96],[-68.37,58.8],[-67.65,58.21],[-66.2,58.77],[-65.25,59.87],[-64.58,60.34],[-63.81,59.44],[-62.5,58.17],[-61.4,56.97],[-61.8,56.34],[-60.47,55.78],[-59.57,55.2],[-57.98,54.95],[-57.33,54.63],[-56.94,53.78],[-56.16,53.65],[-55.76,53.27],[-55.68,52.15],[-56.41,51.77],[-57.13,51.42],[-58.78,51.07],[-60.03,50.24],[-61.72,50.08],[-63.86,50.29],[-65.36,50.3],[-66.4,50.23],[-67.24,49.51],[-68.51,49.07],[-69.95,47.74],[-71.11,46.82],[-70.26,46.99],[-68.65,48.3],[-66.55,49.13],[-65.06,49.23],[-64.17,48.74],[-65.12,48.07],[-64.8,46.99],[-64.47,46.24],[-63.17,45.74],[-61.52,45.88],[-60.52,47.01],[-60.45,46.28],[-59.8,45.92],[-61.04,45.26],[-63.25,44.67],[-64.25,44.27],[-65.36,43.55],[-66.12,43.62],[-66.16,44.47],[-64.42,45.29],[-66.03,45.26],[-67.14,45.14],[-67.79,45.7],[-67.79,47.07],[-68.23,47.35],[-68.91,47.19],[-69.24,47.45],[-70,46.69],[-70.3,45.91],[-70.66,45.46],[-71.08,45.31],[-71.4,45.25],[-71.51,45.01],[-73.Line truncated
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"FJ"},"geometry":{"type":"MultiPolygon","coordinates":[[[[178.12,-17.51],[178.37,-17.34],[178.72,-17.63],[178.55,-18.15],[177.93,-18.29],[177.38,-18.16],[177.29,-17.72],[177.67,-17.38],[178.12,-17.51]]],[[[180,-16.07],[180.21,-16.02],[180.08,-16.5],[180,-16.56],[179.36,-16.8],[178.73,-17.01],[178.6,-16.64],[179.1,-16.43],[179.41,-16.38],[180,-16.07],[180,-16.07]]]]}},{"type":"Feature","properties":{"name":"TZ"},"geometry":{"type":"Polygon","coordinates":[[[33.9,-0.95],[34.07,-1.06],[37.7,-3.1],[37.77,-3.68],[39.2,-4.68],[38.74,-5.91],[38.8,-6.48],[39.44,-6.84],[39.47,-7.1],[39.2,-7.7],[39.25,-8.01],[39.19,-8.49],[39.54,-9.11],[39.95,-10.1],[40.32,-10.32],[39.52,-10.9],[38.43,-11.29],[37.83,-11.27],[37.47,-11.57],[36.78,-11.6],[36.51,-11.72],[35.31,-11.44],[34.56,-11.52],[34.28,-10.16],[33.94,-9.69],[33.74,-9.42],[32.76,-9.23],[32.19,-8.93],[31.56,-8.76],[31.16,-8.59],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.75,-4.45],[30.12,-4.09],[30.5,-3.57],[30.75,-3.36],[30.74,-3.03],[30.53,-2.81],[30.47,-2.41],[30.76,-2.29],[30.81,-1.7],[30.42,-1.14],[30.77,-1.01],[31.87,-1.03],[33.9,-0.95]]]}},{"type":"Feature","properties":{"name":"EH"},"geometry":{"type":"Polygon","coordinates":[[[-8.67,27.66],[-8.66,27.59],[-8.69,27.4],[-8.69,25.88],[-11.97,25.93],[-11.94,23.37],[-12.88,23.29],[-13.12,22.77],[-12.93,21.33],[-16.85,21.33],[-17.06,21],[-17.02,21.42],[-17,21.42],[-14.75,21.5],[-14.63,21.86],[-14.22,22.31],[-13.89,23.69],[-12.5,24.77],[-12.03,26.03],[-11.72,26.1],[-11.39,26.88],[-10.55,26.99],[-10.19,26.86],[-9.74,26.86],[-9.41,27.09],[-8.79,27.12],[-8.82,27.66],[-8.67,27.66]]]}},{"type":"Feature","properties":{"name":"CA"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-122.98,49],[-124.91,49.99],[-125.63,50.42],[-127.44,50.83],[-127.99,51.72],[-127.85,52.33],[-129.13,52.76],[-129.3,53.56],[-130.51,54.29],[-130.54,54.8],[-129.98,55.28],[-130.01,55.92],[-131.71,56.55],[-132.73,57.69],[-133.35,58.41],[-134.27,58.86],[-134.95,59.27],[-135.47,59.79],[-136.48,59.46],[-137.45,58.91],[-138.34,59.56],[-139.04,60],[-140.01,60.28],[-141,60.31],[-140.99,66],[-140.99,69.71],[-139.12,69.47],[-137.54,68.99],[-136.5,68.9],[-135.63,69.32],[-134.42,69.63],[-132.93,69.51],[-131.43,69.94],[-129.79,70.19],[-129.11,69.78],[-128.36,70.01],[-128.14,70.48],[-127.45,70.38],[-125.75,69.48],[-124.43,70.16],[-124.29,69.4],[-123.06,69.56],[-122.68,69.86],[-121.47,69.8],[-119.94,69.38],[-117.6,69.01],[-116.23,68.84],[-115.25,68.91],[-113.9,68.4],[-115.3,67.9],[-113.5,67.69],[-110.8,67.81],[-109.95,67.98],[-108.88,67.38],[-107.79,67.89],[-108.81,68.31],[-108.17,68.65],[-106.95,68.7],[-106.15,68.8],[-105.34,68.56],[-104.34,68.02],[-103.22,68.1],[-101.45,67.65],[-99.9,67.81],[-98.44,67.78],[-98.56,68.4],[-97.67,68.58],[-96.12,68.24],[-96.13,67.29],[-95.49,68.09],[-94.69,68.06],[-94.23,69.07],[-95.31,69.68],[-96.47,70.09],[-96.39,71.19],[-95.21,71.92],[-93.89,71.76],[-92.88,71.32],[-91.52,70.19],[-92.41,69.7],[-90.55,69.5],[-90.55,68.47],[-89.21,69.26],[-88.02,68.62],[-88.32,67.87],[-87.35,67.2],[-86.31,67.92],[-85.58,68.78],[-85.52,69.88],[-84.1,69.81],[-82.62,69.66],[-81.28,69.16],[-81.22,68.67],[-81.96,68.13],[-81.26,67.6],[-81.39,67.11],[-83.35,66.41],[-84.74,66.26],[-85.77,66.56],[-86.07,66.06],[-87.03,65.21],[-87.32,64.77],[-88.48,64.1],[-89.91,64.03],[-90.7,63.61],[-90.77,62.96],[-91.93,62.84],[-93.16,62.02],[-94.24,60.9],[-94.63,60.11],[-94.69,58.95],[-93.21,58.78],[-92.76,57.85],[-92.3,57.09],[-90.9,57.29],[-89.04,56.85],[-88.04,56.47],[-87.32,56],[-86.07,55.72],[-85.01,55.3],[-83.36,55.25],[-82.27,55.15],[-82.44,54.28],[-82.13,53.28],[-81.4,52.16],[-79.91,51.21],[-79.14,51.53],[-78.6,52.56],[-79.12,54.14],[-79.83,54.67],[-78.23,55.14],[-77.1,55.84],[-76.54,56.53],[-76.62,57.2],[-77.3,58.05],[-78.52,58.81],[-77.34,59.85],[-77.77,60.76],[-78.11,62.32],[-77.41,62.55],[-75.7,62.28],[-74.67,62.18],[-73.84,62.44],[-72.91,62.11],[-71.68,61.53],[-71.37,61.14],[-69.59,61.06],[-69.62,60.22],[-69.29,58.96],[-68.37,58.8],[-67.65,58.21],[-66.2,58.77],[-65.25,59.87],[-64.58,60.34],[-63.81,59.44],[-62.5,58.17],[-61.4,56.97],[-61.8,56.34],[-60.47,55.78],[-59.57,55.2],[-57.98,54.95],[-57.33,54.63],[-56.94,53.78],[-56.16,53.65],[-55.76,53.27],[-55.68,52.15],[-56.41,51.77],[-57.13,51.42],[-58.78,51.07],[-60.03,50.24],[-61.72,50.08],[-63.86,50.29],[-65.36,50.3],[-66.4,50.23],[-67.24,49.51],[-68.51,49.07],[-69.95,47.74],[-71.11,46.82],[-70.26,46.99],[-68.65,48.3],[-66.55,49.13],[-65.06,49.23],[-64.17,48.74],[-65.12,48.07],[-64.8,46.99],[-64.47,46.24],[-63.17,45.74],[-61.52,45.88],[-60.52,47.01],[-60.45,46.28],[-59.8,45.92],[-61.04,45.26],[-63.25,44.67],[-64.25,44.27],[-65.36,43.55],[-66.12,43.62],[-66.16,44.47],[-64.42,45.29],[-66.03,45.26],[-67.14,45.14],[-67.79,45.7],[-67.79,47.07],[-68.23,47.35],[-68.91,47.19],[-69.24,47.45],[-70,46.69],[-70.3,45.91],[-70.66,45.46],[-71.08,45.31],[-71.4,45.25],[-71.51,45.01],[-73.35,45Line truncated
+55 -18
View File
@@ -124,33 +124,70 @@ function hexMix(a, b, p) {
return '#' + x.map((v, i) => Math.round(v * p + y[i] * (1 - p)).toString(16).padStart(2, '0')).join('')
}
/** 世界地图:按国家的流量,单一色相由浅到深(对数刻度,流量差几个数量级也看得出层次) */
export function mapOption(rows, active) {
/** 流向图:从本地(home)飞向各国,线宽、气泡大小随流量(平方根刻度);流量最大的 5 个国家标名字和流量 */
export function flowMapOption(rows, home, centers, active, homeName, narrow = false) {
const sum = rows.reduce((a, x) => a + x.bytes, 0) || 1
const vals = rows.map(r => Math.log10(r.bytes + 1))
const panel = css('--panel'), accent = css('--accent')
const panel = css('--panel'), ink = css('--ink'), muted = css('--muted'), accent = css('--accent')
const brand = css('--brand-ink'), sunk = css('--sunk'), warn = css('--warn')
const land = hexMix(sunk, ink, 0.92)
const from = centers[home] || [104, 34.5]
const dests = rows.filter(r => r.cc !== home && centers[r.cc]).sort((a, b) => b.bytes - a.bytes)
const local = rows.find(r => r.cc === home)
const max = Math.max(1, ...dests.map(r => r.bytes))
const k = b => Math.sqrt(b / max)
const tip = r => `<b>${r.name}</b><div style="margin-top:4px"><b>${bytes(r.bytes)}</b> <span style="color:${muted}">${pct(r.bytes / sum)}</span></div>`
const point = (r, i) => ({ name: r.name, cc: r.cc, bytes: r.bytes, value: [...centers[r.cc], r.bytes], rank: i,
symbolSize: 7 + 20 * k(r.bytes), itemStyle: active === r.cc ? { color: warn, borderColor: ink, borderWidth: 1.5 } : undefined })
return {
textStyle: { fontFamily: FONT },
tooltip: tooltip({
trigger: 'item',
formatter: p => {
const r = rows.find(x => x.cc === p.name)
const n = countryName(p.name) || p.name
return r ? `<b>${n}</b><div style="margin-top:4px"><b>${bytes(r.bytes)}</b> <span style="color:${css('--muted')}">${pct(r.bytes / sum)}</span></div>` : n
const d = p.data || {}
if (d.cc && d.bytes != null) return tip(d)
if (p.componentType === 'geo') {
const r = rows.find(x => x.cc === p.name)
return r ? tip(r) : ''
}
return d.home ? `<b>${d.name}</b>` + (local ? tip({ ...local, name: '' }).replace('<b></b>', '') : '') : ''
},
}),
visualMap: {
show: false, min: vals.length ? Math.min(...vals) : 0, max: vals.length ? Math.max(...vals) : 1,
inRange: { color: [hexMix(accent, panel, 0.18), accent] },
geo: {
map: 'world', roam: false, silent: false, boundingCoords: [[-168, 78], [190, -56]], top: 8, bottom: 8, left: 8, right: 8,
itemStyle: { areaColor: land, borderColor: panel, borderWidth: 0.6 },
emphasis: { label: { show: false }, itemStyle: { areaColor: hexMix(accent, land, 0.35) } },
select: { disabled: true },
regions: [
{ name: home, itemStyle: { areaColor: hexMix(brand, land, 0.28) } },
...dests.map(r => ({ name: r.cc, itemStyle: { areaColor: hexMix(accent, land, 0.12 + 0.3 * k(r.bytes)) } })),
],
},
series: [{
type: 'map', map: 'world', roam: false, top: 6, bottom: 6, left: 6, right: 6, selectedMode: false,
itemStyle: { areaColor: css('--sunk'), borderColor: panel, borderWidth: 0.6 },
emphasis: { label: { show: false }, itemStyle: { areaColor: css('--warn') } },
label: { show: false },
data: rows.map((r, i) => ({ name: r.cc, value: vals[i],
itemStyle: active && active === r.cc ? { borderColor: css('--ink'), borderWidth: 1.5 } : undefined })),
}],
series: [
{ // 飞线
type: 'lines', coordinateSystem: 'geo', zlevel: 1, silent: true,
effect: { show: true, period: 5, trailLength: 0.2, symbol: 'arrow', symbolSize: 5, color: accent },
lineStyle: { color: accent, opacity: 0.45, curveness: 0.28 },
data: dests.map(r => ({ coords: [from, centers[r.cc]], lineStyle: { width: 0.8 + 3.5 * k(r.bytes) } })),
},
{ // 流量最大的 5 个:带涟漪、标名字
type: 'effectScatter', coordinateSystem: 'geo', zlevel: 2, rippleEffect: { brushType: 'stroke', scale: 2.6, period: 4 },
itemStyle: { color: accent, shadowBlur: 6, shadowColor: hexMix(accent, panel, 0.4) },
label: { show: true, position: narrow ? 'top' : 'right', color: ink, fontSize: narrow ? 11 : 12, fontWeight: 600, distance: 6,
formatter: p => (narrow ? p.data.name : `${p.data.name} {s|${bytes(p.data.bytes)}}`), rich: { s: { color: muted, fontWeight: 400, fontSize: 11 } },
textBorderColor: panel, textBorderWidth: 3 },
data: dests.slice(0, 5).map(point),
},
{ // 其余国家
type: 'scatter', coordinateSystem: 'geo', zlevel: 2,
itemStyle: { color: hexMix(accent, panel, 0.75) }, data: dests.slice(5).map(point),
},
{ // 本地
type: 'scatter', coordinateSystem: 'geo', zlevel: 3, symbol: 'pin', symbolSize: 30,
itemStyle: { color: brand }, label: { show: true, position: 'bottom', color: ink, fontSize: 12, fontWeight: 600,
textBorderColor: panel, textBorderWidth: 3, formatter: () => homeName },
data: [{ name: homeName, home: true, value: from }],
},
],
}
}
+2 -2
View File
@@ -5,12 +5,12 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts/core'
import { BarChart, GaugeChart, LineChart, MapChart, PieChart, TreemapChart } from 'echarts/charts'
import { BarChart, EffectScatterChart, GaugeChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TreemapChart } from 'echarts/charts'
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { ui } from '../state.js'
echarts.use([LineChart, PieChart, BarChart, GaugeChart, TreemapChart, MapChart, GridComponent, TooltipComponent, LegendComponent,
echarts.use([LineChart, PieChart, BarChart, GaugeChart, TreemapChart, MapChart, LinesChart, ScatterChart, EffectScatterChart, GridComponent, TooltipComponent, LegendComponent,
GeoComponent, VisualMapComponent, CanvasRenderer])
// maps:要用的地图 GeoJSON({名字: geojson}),在这里注册,保证和画图用的是同一个 echarts
+55 -7
View File
@@ -1,23 +1,66 @@
<template>
<!-- 访问目标的世界地图:颜色深浅 = 流量(对数刻度),点击国家只看该国的目标(debug10) -->
<!-- 访问目标的流向图:从本地飞向各国,线和气泡越粗越大流量越多;流量最大的几个国家直接标名字(debug11) -->
<div class="world">
<Chart v-if="geo" :option="opt" :maps="{ world: geo }" :height="height" @click="onClick" />
<Chart v-if="geo" :option="opt" :maps="{ world: geo }" :height="narrow ? '230px' : height" @click="onClick" />
<div v-else class="empty" :style="{ height }">{{ failed ? t('flow.mapFail') : t('common.loading') }}</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, shallowRef } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { mapOption } from '../charts.js'
import { flowMapOption } from '../charts.js'
import { countryName } from '../format.js'
import { ui } from '../state.js'
import Chart from './Chart.vue'
const props = defineProps({ countries: { type: Array, default: () => [] }, active: String, height: { type: String, default: '340px' } })
const props = defineProps({
countries: { type: Array, default: () => [] }, // [{cc, bytes}]
home: { type: String, default: 'CN' },
active: String,
height: { type: String, default: '340px' },
})
const emit = defineEmits(['pick'])
const { t, locale } = useI18n()
const geo = shallowRef(null) // GeoJSON 很大,不做深层响应
const failed = ref(false)
// 窄屏:地图矮一些,标签放到气泡上方、只写国家名,免得被右边裁掉
const mq = window.matchMedia('(max-width: 640px)')
const narrow = ref(mq.matches)
const onMq = e => (narrow.value = e.matches)
onMounted(() => mq.addEventListener('change', onMq))
onUnmounted(() => mq.removeEventListener('change', onMq))
// 形状特殊的国家手工给中心点(海外领土、跨日期变更线、狭长国家)
const FIX = { US: [-98, 39], RU: [96, 61], CN: [104, 34.5], FR: [2.4, 46.6], NO: [9.5, 61], CA: [-102, 57], GB: [-1.8, 52.8],
NL: [5.5, 52.2], DK: [9.4, 56], NZ: [173, -41.5], JP: [138.5, 36.5], ID: [117, -2], MY: [102, 4], CL: [-71, -33], AU: [134, -25],
IT: [12.5, 42.8], GR: [22, 39.3], PH: [122, 12], SE: [16, 62.5], FI: [26, 63.5], IN: [79, 22], VN: [106, 16], KR: [127.8, 36.3] }
function centroid(ring) {
let a = 0, x = 0, y = 0
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const f = ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
a += f
x += (ring[j][0] + ring[i][0]) * f
y += (ring[j][1] + ring[i][1]) * f
}
return a ? [x / (3 * a), y / (3 * a)] : ring[0]
}
const centers = computed(() => {
const out = {}
for (const f of (geo.value && geo.value.features) || []) {
const g = f.geometry
const polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates
let best = null, size = -1
for (const p of polys) {
const xs = p[0].map(c => c[0]), ys = p[0].map(c => c[1])
const s = (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys))
if (s > size) { size = s; best = p[0] }
}
out[f.properties.name] = FIX[f.properties.name] || centroid(best)
}
return out
})
onMounted(async () => {
try {
@@ -25,8 +68,13 @@ onMounted(async () => {
geo.value = await window.__rosdpiWorld
} catch (e) { failed.value = true }
})
const opt = computed(() => (ui.theme, locale.value, mapOption(props.countries.filter(c => c.cc && c.cc !== '-'), props.active)))
function onClick(p) { if (p.name) emit('pick', p.name) }
const opt = computed(() => (ui.theme, locale.value, flowMapOption(
props.countries.filter(c => c.cc && c.cc !== '-').map(c => ({ ...c, name: countryName(c.cc) || c.cc })),
props.home, centers.value, props.active, countryName(props.home) || props.home, narrow.value)))
function onClick(p) {
const cc = p.data && p.data.cc ? p.data.cc : p.componentType === 'geo' ? p.name : null
if (cc) emit('pick', cc)
}
</script>
<style scoped>
+1 -1
View File
@@ -87,7 +87,7 @@ export default {
note: 'Shares are within the current level. Data covers the selected range; rates come from the per-minute IPFIX reports, so refreshing faster than 10s adds nothing.' } },
flow: {
tab: { live: 'Live connections', dest: 'Top destinations', proxy: 'Proxy routing' },
country: 'Country', mapFail: 'The map failed to load', countries: 'Countries / regions', countriesHint: 'Summed over the destinations below; domains use the IP they resolved to. Click one to filter', unknownCountry: 'Unknown',
country: 'Country', mapFail: 'The map failed to load', home: 'Local', unknownNote: 'Unlocated: these domains were not resolved through DNS seen here (proxied, resolved remotely or via encrypted DNS), so there is no IP to locate.', countries: 'Countries / regions', countriesHint: 'Summed over the destinations below; domains use the IP they resolved to. Click one to filter', unknownCountry: 'Unlocated',
pxOff: 'sing-box is not connected, so proxy routing is unavailable.', pxGo: 'Configure its Clash API in Settings → Data sources',
pxGroups: 'Policy groups', pxGroup: 'Policy group', pxNode: 'Outbound node', pxNodes: '{n} nodes', pxApps: 'Top apps', pxConns: 'Connections',
pxEmpty: 'No traffic through sing-box yet today.',
+1 -1
View File
@@ -87,7 +87,7 @@ export default {
note: '占比是在当前这一级里的占比。数据按所选时间范围统计;速率按 RouterOS 每分钟上报的流量计算,所以最快 10 秒刷新一次。' } },
flow: {
tab: { live: '实时连接', dest: '访问目标排行', proxy: '代理分流' },
country: '国家/地区', mapFail: '地图加载失败', countries: '国家 / 地区分布', countriesHint: '按下面排行里的目标汇总;域名按它解析到的 IP 查。点一项只看该国家', unknownCountry: '未知',
country: '国家/地区', mapFail: '地图加载失败', home: '本地', unknownNote: '无法定位:这些域名没有经过本机的 DNS 解析(例如走代理、远端解析或用加密 DNS),查不到 IP,也就查不到国家。', countries: '国家 / 地区分布', countriesHint: '按下面排行里的目标汇总;域名按它解析到的 IP 查。点一项只看该国家', unknownCountry: '无法定位',
pxOff: '没有接入 sing-box,看不到代理分流。', pxGo: '去 系统设置 → 外部数据源 配置 Clash API',
pxGroups: '策略组', pxGroup: '策略组', pxNode: '出口节点', pxNodes: '{n} 个节点', pxApps: '主要应用', pxConns: '连接数',
pxEmpty: '今天还没有经过 sing-box 的流量。',
+26 -7
View File
@@ -74,13 +74,17 @@
<div class="sec">{{ t('flow.countries') }}<span class="faint small">{{ t('flow.countriesHint') }}</span>
<span class="sp" /><button v-if="country" class="btn sm" @click="country = ''">{{ t('apps.showAll') }}</button></div>
<div class="mapgrid">
<WorldMap :countries="dest.countries" :active="country" height="320px" @pick="cc => (country = country === cc ? '' : cc)" />
<WorldMap :countries="dest.countries" :home="dest.home" :active="country" height="340px" @pick="cc => (country = country === cc ? '' : cc)" />
<div class="clist">
<button v-for="c in countries" :key="c.cc" class="cc" :class="{ on: country === c.cc }" @click="country = country === c.cc ? '' : c.cc">
<span class="cn">{{ c.name }}</span><span class="num">{{ bytes(c.bytes) }}</span>
<button v-for="(c, i) in countries" :key="c.cc" class="cr" :class="{ on: country === c.cc, unk: c.cc === '-' }"
@click="country = country === c.cc ? '' : c.cc">
<span class="rk num">{{ c.cc === '-' ? '' : i + 1 }}</span>
<span class="cn">{{ c.name }}<span v-if="c.cc === dest.home" class="pill grey home">{{ t('flow.home') }}</span></span>
<span class="num v">{{ bytes(c.bytes) }}</span>
<span class="bar"><i :style="{ width: (c.bytes / maxCountry * 100) + '%' }" /></span>
<span class="faint small num">{{ pct(c.bytes / countryTotal) }}</span>
<span class="num faint p">{{ pct(c.bytes / countryTotal) }}</span>
</button>
<div class="faint small unk-note" v-if="countries.some(c => c.cc === '-')">{{ t('flow.unknownNote') }}</div>
</div>
</div>
</div>
@@ -323,9 +327,24 @@ tr.sub td { background: var(--sunk); white-space: normal; }
.route { color: var(--brand-ink); }
.nowrap { white-space: nowrap; }
.ctry { padding-top: 0; }
.mapgrid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 16px; margin-top: 10px; align-items: start; }
.clist { display: grid; gap: 6px; max-height: 320px; overflow-y: auto; }
@media (max-width: 900px) { .mapgrid { grid-template-columns: 1fr; } }
.mapgrid { display: grid; grid-template-columns: minmax(0, 2.2fr) minmax(240px, 1fr); gap: 18px; margin-top: 10px; align-items: start; }
.clist { display: grid; gap: 2px; max-height: 340px; overflow-y: auto; align-content: start; }
.cr { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto 46px; grid-template-rows: auto 4px; column-gap: 8px; row-gap: 4px;
align-items: center; background: none; border: 0; border-radius: 8px; padding: 7px 8px; font: inherit; font-size: 13px; color: var(--ink);
cursor: pointer; text-align: left; }
.cr:hover, .cr.on { background: var(--sunk); }
.cr.on .cn { color: var(--brand-ink); }
.cr .rk { grid-column: 1; grid-row: 1 / 3; color: var(--faint); font-size: 12px; }
.cr .cn { grid-column: 2; grid-row: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.cr .v { grid-column: 3; grid-row: 1; font-weight: 600; }
.cr .p { grid-column: 4; grid-row: 1; text-align: right; font-size: 12px; }
.cr .bar { grid-column: 2 / 5; grid-row: 2; height: 4px; margin: 0; width: auto; border-radius: 2px; background: var(--sunk); }
.cr .bar i { background: var(--accent); }
.cr.unk { color: var(--muted); margin-top: 6px; border-top: 1px dashed var(--line); border-radius: 0; }
.cr.unk .bar i { background: var(--line-strong); }
.home { height: 18px; font-size: 10px; }
.unk-note { padding: 2px 8px 0 38px; line-height: 1.5; }
@media (max-width: 900px) { .mapgrid { grid-template-columns: 1fr; } .clist { max-height: none; } }
.cc { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; align-items: center; text-align: left; background: var(--sunk);
border: 1px solid transparent; border-radius: 10px; padding: 8px 12px; cursor: pointer; color: var(--ink); font: inherit; font-size: 13px; }
.cc:hover, .cc.on { border-color: var(--brand-ink); }