国家 / 地区分布改为流向图,域名国家持久化(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:
1 parent
f5d3112450
commit
e3d6bc9d47
14 files changed
+233
-46
No files matched your search
@@ -7,6 +7,8 @@ debug11
|
||||
- 家庭成员 / 人员必须属于一个分组 / 部门(后端校验);同一分组里不能重名;添加成员时没有分组可以就地新建。
|
||||
- 成员列表每行加编辑按钮,详情页加编辑和删除(之前已有成员打不开编辑弹窗,也就删不掉)。
|
||||
- 分组 / 部门下还有成员时不能删除,会提示先移走;以前没有分组的成员单独显示为“未分组”,方便补上。
|
||||
- 国家 / 地区分布改为**流向图**:从本地(按 WAN 口公网 IP 判断,默认中国)飞向各国,线宽和气泡大小表示流量,流量最大的 5 个国家直接标名字和流量;右侧改为带名次的排行,“无法定位”放最后并说明原因。修正跨 180° 经线的国家在地图上画出的横线。
|
||||
- 域名的国家在记录流量时就存下(新表 domain_geo),不再依赖会过期的 DNS 缓存;之前重启或隔天后大部分目标会显示为“无法定位”。
|
||||
|
||||
## [0.5.0] - 2026-10-23
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 @@
|
||||
{"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
@@ -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 }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 的流量。',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user