feat: debug3 应用三级分类、图表重做、终端按 MAC/IP 命名(v0.1.4)
- 应用分类参照 Panabit 改为 应用大类 → 小类 → 应用:10 个大类、41 个小类、277 个应用; CNAME 指向 CDN 时归到 CDN 厂商;ICMP / GRE / ESP 按协议识别;已发布的应用和分类 ID 全部保留 - 图表重做:流量构成双层环图 + 排行表、按应用大类堆叠的趋势(可切换上下行)、 带名次和占比的 Top 排行、桑基图中间列改为应用大类;配色按大类固定并通过色盲校验 - 终端命名:按 MAC 或按 IP,新增命名管理,可提前给未上线设备命名,旧备注名自动迁移 - DNS 嗅探卡片说明作用并显示应答来源;补充内网自建 DNS(AdGuard/Pi-hole)的说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.4] - 2026-10-22
|
||||
|
||||
debug3
|
||||
|
||||
- 应用分类改成 Panabit 那样的三级:**应用大类 → 小类 → 应用**。10 个大类(影音娱乐、网络游戏、社交通讯、下载更新、办公学习、购物生活、资讯阅读、智能设备、网络基础、未识别),41 个小类(新增手机游戏、主机游戏、游戏加速、语音通话、电子邮件、应用商店、视频会议、开发工具、远程控制、在线教育、出行地图、银行、证券理财、阅读漫画、监控摄像、系统服务、CDN 云服务、代理隧道、安全软件等)。
|
||||
- 内置应用从 100 多个增加到 277 个:微信视频号、B 站直播、咪咕、央视频、各大银行和券商、ToDesk / 向日葵、DeepSeek / Kimi / 豆包 / 通义 等。
|
||||
- 没法识别具体应用的流量,如果 CNAME 指向阿里云、腾讯云、网宿、Akamai、Cloudflare 等 CDN,会归到对应的 CDN 厂商,不再是“其他”。ICMP、GRE / ESP(VPN)按 IP 协议识别。
|
||||
- 已有的应用 ID 和分类 ID 都保留,历史数据不受影响。
|
||||
- 图表全部重做:
|
||||
- 流量构成:双层环图(内环大类、外环小类)加带数值的排行表,点击可筛选应用。
|
||||
- 流量趋势:可切换“按应用大类”堆叠图和“上行 / 下行”图;标出峰值。
|
||||
- Top 应用 / Top 终端:带名次和占比。
|
||||
- 流量流向:中间一列改成应用大类,连线颜色表示大类。
|
||||
- 配色按应用大类固定,用 dataviz 校验脚本检查过色盲区分度,深浅色主题各一套。
|
||||
- 终端可以重命名,**按 MAC**(换 IP 名字不变)或**按 IP**(适合随机 MAC 的手机、拿不到 MAC 的终端)。主机监控页新增“命名管理”,可以给还没上线的设备提前命名;两种都设置时 MAC 优先。旧版本的备注名会自动迁移。
|
||||
- 系统设置的 DNS 嗅探卡片说明了它的作用,并显示应答来自哪台 DNS 服务器(内网 / 外网),方便确认内网自建 DNS 的情况是否嗅探到了。接入向导和文档补充了内网自建 DNS(AdGuard Home / Pi-hole 等)的说明。
|
||||
|
||||
## [0.1.3] - 2026-10-22
|
||||
|
||||
- 修复:配置 RouterOS API(`ROS_URL`)后,概览页报“服务器内部错误:AttributeError: 'Response' object has no attribute 'get'”,页面打不开。原因是读取 RouterOS 返回数据时把 HTTP 响应对象当成了 JSON。
|
||||
|
||||
@@ -8,6 +8,7 @@ Source & docs: https://github.com/suyijun8182/ros-dpi
|
||||
|
||||
## What's new
|
||||
|
||||
- **0.1.4**: Panabit-style three-level app library (10 groups, 41 categories, 277 apps; CDN attribution via CNAME); redesigned charts (traffic-mix donut, trend stacked by app group, ranked lists, Sankey by group); name hosts by MAC or IP; DNS sniffer card shows which DNS servers answers come from.
|
||||
- **0.1.3**: fix the 500 error on the overview page when the RouterOS API (`ROS_URL`) is configured; clearer RouterOS connection errors.
|
||||
- **0.1.2**: first-run setup wizard: enter your MySQL connection in the browser, like EasySub. The compose file no longer needs database settings. A README guide covers creating the database.
|
||||
- **0.1.1**: full `docker-compose.yml` on this page (no `.env` needed), first-run notes, database auto-created on external MySQL.
|
||||
@@ -16,7 +17,8 @@ Source & docs: https://github.com/suyijun8182/ros-dpi
|
||||
## Features
|
||||
|
||||
- IPFIX / NetFlow v9 collector (udp/2055), TZSP DNS sniffer (udp/37008), optional read-only RouterOS REST polling
|
||||
- 100+ built-in apps in 20+ categories; custom domain / CIDR / port rules
|
||||
- 277 built-in apps in 41 categories under 10 app groups (Panabit-style); custom domain / CIDR / port rules
|
||||
- Name hosts by MAC or by IP
|
||||
- Per-minute / hour / day aggregation in MySQL
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
|
||||
### 能做什么
|
||||
|
||||
- **全网概览**:今日 / 本周 / 本月流量,此刻的上下行速率,流量构成(按应用分类),Top 应用与 Top 终端,24 小时与 30 天趋势。
|
||||
- **主机监控**:所有终端的 IP、MAC、主机名、在线状态、当前速率、今日 / 本周 / 本月流量,可以给设备起备注名;详情页可看该终端的应用排行、分类占比和访问过的域名。
|
||||
- **应用统计**:100 多个内置应用(抖音、B 站、微信、Steam、Apple 更新、ChatGPT……)分 20 多个类别,可以查看每个应用被哪些终端使用。
|
||||
- **流量流向**:桑基图展示 终端 → 应用分类 → 应用 的流向。
|
||||
- **全网概览**:今日 / 本周 / 本月流量,此刻的上下行速率,流量构成环图(应用大类 → 小类),按应用大类堆叠的流量趋势,Top 应用与 Top 终端排行。
|
||||
- **主机监控**:所有终端的 IP、MAC、主机名、在线状态、当前速率、今日 / 本周 / 本月流量,可以按 MAC 或按 IP 给设备命名(也能提前给还没上线的设备命名);详情页可看该终端的应用排行、应用构成、按大类的趋势和访问过的域名。
|
||||
- **应用统计**:参照 Panabit 分三级(应用大类 → 小类 → 应用):10 个大类、41 个小类、270 多个内置应用(抖音、B 站、微信视频号、Steam、银行、券商、ToDesk、DeepSeek……)。识别不出具体应用的流量,会按 CDN 的 CNAME 归到阿里云 / 腾讯云 / Akamai 等厂商。可以查看每个应用被哪些终端使用。
|
||||
- **流量流向**:桑基图展示 终端 → 应用大类 → 应用 的流向。
|
||||
- **识别规则**:按域名后缀、IP 段、端口自定义识别规则;列出“未识别域名 Top”,一键归类。
|
||||
- 中英双语,深浅色主题,手机可用。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 工作原理
|
||||
@@ -37,7 +39,9 @@ RouterOS ◀─REST 只读(可选)── 主机名、IP↔MAC、WAN 口速
|
||||
- 应用识别靠 DNS:终端访问 `v26.douyinvod.com` 时,路由器把 DNS 应答镜像一份过来,之后发往该 IP 的流量就归到“抖音”。查不到域名时,依次按 IP 段、端口兜底(HTTPS / QUIC 等)。
|
||||
- 终端用 MAC 区分(来自 ROS 的 ARP / DHCP,或流记录中的源 MAC),IP 变化不影响统计。
|
||||
|
||||
**已知局限**:终端使用 DoH / DoT 加密 DNS,或者在路由器之外解析 DNS 时,这部分流量只能识别为 HTTPS / QUIC。可以在终端或路由器上禁用加密 DNS 来提高识别率。
|
||||
**已知局限**:终端使用 DoH / DoT 加密 DNS 时,这部分流量只能识别为 HTTPS / QUIC。可以在终端或路由器上禁用加密 DNS 来提高识别率。
|
||||
|
||||
**内网有自己的 DNS 服务器(AdGuard Home / Pi-hole 等)时**:DNS 嗅探照样能用。终端查询内网 DNS 走的是交换机,路由器看不到;但内网 DNS 向上游(如 223.5.5.5)查询时,应答要经过路由器,会被 `forward` 那条规则嗅探到,同样能得到“IP → 域名”的对应关系。前提是内网 DNS 的上游用普通 DNS(udp/53);上游设成 DoH / DoT 时路由器看不到域名,可以把上游改成普通 DNS,或者改成路由器地址。**系统设置 → DNS 嗅探** 里的“应答来源”会显示应答来自哪台 DNS 服务器。
|
||||
|
||||
### 部署
|
||||
|
||||
@@ -189,16 +193,16 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5
|
||||
|
||||
**ROS-DPI** shows per-device traffic and per-application usage (today / this week / this month) for networks behind a MikroTik RouterOS router, in the spirit of Panabit-style DPI dashboards.
|
||||
|
||||
- **Overview**: totals, live rate, traffic mix by category, top apps and hosts, trends.
|
||||
- **Hosts**: every device with IP, MAC, hostname, online state, live rate and daily / weekly / monthly totals; per-host app breakdown and visited domains.
|
||||
- **Applications**: 100+ built-in apps in 20+ categories.
|
||||
- **Flows**: a Sankey diagram from hosts through categories to apps.
|
||||
- **Overview**: totals, live rate, a two-ring traffic-mix donut (app group → category), trends stacked by app group, ranked top apps and hosts.
|
||||
- **Hosts**: every device with IP, MAC, hostname, online state, live rate and daily / weekly / monthly totals; name devices by MAC or by IP (even before they appear); per-host app breakdown and visited domains.
|
||||
- **Applications**: a Panabit-style three-level library: 10 app groups, 41 categories, 270+ built-in apps. Traffic that can't be tied to an app is attributed to its CDN (Alibaba Cloud, Tencent Cloud, Akamai, Cloudflare…) via CNAME.
|
||||
- **Flows**: a Sankey diagram from hosts through app groups to apps.
|
||||
- **Rules**: custom domain / CIDR / port rules, and a list of top unidentified domains to classify.
|
||||
- Chinese and English UI, light and dark themes, works on phones.
|
||||
|
||||
**How it works.** RouterOS exports IPFIX (`/ip traffic-flow`) for byte counts, and mirrors DNS answers via `sniff-tzsp` so each remote IP can be mapped back to the domain the client looked up. A rule library maps domains to apps. An optional read-only REST user provides hostnames, IP-to-MAC, WAN rate and LAN prefixes. Data is aggregated per minute, hour and day into MySQL.
|
||||
|
||||
**Limitations.** Clients using DoH/DoT (or resolving DNS elsewhere) show up as generic HTTPS/QUIC.
|
||||
**Limitations.** Clients using DoH/DoT show up as generic HTTPS/QUIC. A LAN DNS server (AdGuard Home, Pi-hole…) works fine as long as its upstream is plain DNS on udp/53: its upstream answers pass through the router and are sniffed by the `forward` rule.
|
||||
|
||||
**Deploy.** ROS-DPI stores data in **MySQL** (5.7 / 8.x) and assumes you already have one. Create a database and user for it:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.3"
|
||||
__version__ = "0.1.4"
|
||||
@@ -1,6 +1,7 @@
|
||||
"""把一条流(远端 IP + 端口 + 域名)识别为应用。
|
||||
|
||||
优先级:自定义规则 > 域名后缀(先查询名再 CNAME,最长后缀优先)> CIDR > 端口 > other。
|
||||
分三级:应用大类(group)→ 应用小类(category)→ 应用(app),与 Panabit 的应用分类方式一致。
|
||||
优先级:自定义规则 > 域名后缀(先查询名再 CNAME,最长后缀优先)> CIDR > 端口 > IP 协议号 > other。
|
||||
"""
|
||||
import ipaddress
|
||||
import os
|
||||
@@ -11,6 +12,8 @@ import yaml
|
||||
RULES_FILE = os.path.join(os.path.dirname(__file__), "apps.yaml")
|
||||
UNKNOWN_APP = "other"
|
||||
GENERIC_APPS = {"https", "quic", "http", UNKNOWN_APP} # 这些应用下的域名算"未识别"
|
||||
# 只能说明协议或 CDN 厂商、说明不了具体应用的分类,不计入"应用识别率"
|
||||
UNIDENTIFIED_CATEGORIES = {"unknown", "web", "network", "cdn"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,6 +36,7 @@ class Classifier:
|
||||
def __init__(self, rules_file: str = RULES_FILE):
|
||||
with open(rules_file, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
self.groups: dict[str, dict] = data["groups"]
|
||||
self.categories: dict[str, dict] = data["categories"]
|
||||
self._base_apps = data["apps"]
|
||||
self.load_custom([])
|
||||
@@ -43,9 +47,12 @@ class Classifier:
|
||||
self.domains: dict[str, str] = {}
|
||||
self.nets: list[tuple[ipaddress._BaseNetwork, str]] = []
|
||||
self.ports: list[tuple[int, int, int, str]] = []
|
||||
self.protos: dict[int, str] = {}
|
||||
for a in self._base_apps:
|
||||
self.apps[a["id"]] = App(a["id"], a["name"], a.get("name_en", a["name"]), a["category"])
|
||||
self._add(a["id"], a.get("domains", []), a.get("cidrs", []), a.get("ports", []), first_wins=True)
|
||||
for p in a.get("protos", []):
|
||||
self.protos.setdefault(int(p), a["id"])
|
||||
self.apps[UNKNOWN_APP] = App(UNKNOWN_APP, "其他", "Other", "unknown")
|
||||
|
||||
custom_nets, custom_ports = [], []
|
||||
@@ -84,6 +91,7 @@ class Classifier:
|
||||
if not name:
|
||||
return None
|
||||
parts = name.lower().strip(".").split(".")
|
||||
# 从完整域名开始逐级去掉左边一段;单段名(如 dns.google 的 google)不参与匹配
|
||||
for i in range(len(parts) - 1):
|
||||
hit = self.domains.get(".".join(parts[i:]))
|
||||
if hit:
|
||||
@@ -110,6 +118,8 @@ class Classifier:
|
||||
if p == proto and lo <= port <= hi:
|
||||
app = aid
|
||||
break
|
||||
if app is None:
|
||||
app = self.protos.get(proto)
|
||||
app = app or UNKNOWN_APP
|
||||
if len(self._cache) > 200_000:
|
||||
self._cache.clear()
|
||||
@@ -120,4 +130,7 @@ class Classifier:
|
||||
return self.apps.get(app_id) or App(app_id, app_id, app_id, "unknown")
|
||||
|
||||
def category_info(self, cat: str) -> dict:
|
||||
return self.categories.get(cat) or {"name": cat, "name_en": cat}
|
||||
return self.categories.get(cat) or {"group": "unknown", "name": cat, "name_en": cat}
|
||||
|
||||
def group_info(self, group: str) -> dict:
|
||||
return self.groups.get(group) or {"name": group, "name_en": group}
|
||||
@@ -1,41 +1,84 @@
|
||||
# 应用识别规则库
|
||||
# 应用识别规则库(参照 Panabit 的 应用大类 → 应用小类 → 应用 三级结构)
|
||||
# domains: 后缀匹配(douyin.com 同时匹配 www.douyin.com)。先匹配终端查询的域名,再匹配 CNAME。
|
||||
# cidrs: 目的 IP 段(没有 DNS 记录时使用)
|
||||
# ports: "tcp/443"、"udp/3478-3481" 形式,最后兜底
|
||||
# 欢迎通过 PR 补充;用户也可以在 设置 → 识别规则 里添加自定义规则(优先级最高)。
|
||||
# protos: IP 协议号(1=ICMP、47=GRE、50=ESP、58=ICMPv6)
|
||||
# 欢迎通过 PR 补充;用户也可以在 识别规则 页添加自定义规则(优先级最高)。
|
||||
#
|
||||
# 已发布过的应用 ID 和分类 ID 不要删除或改名:历史流量按应用 ID 存储。
|
||||
# 应用不再使用时,保留条目、清空 domains 即可。
|
||||
|
||||
groups:
|
||||
media: {name: 影音娱乐, name_en: Media}
|
||||
game: {name: 网络游戏, name_en: Gaming}
|
||||
social: {name: 社交通讯, name_en: Social & messaging}
|
||||
transfer: {name: 下载更新, name_en: Downloads & updates}
|
||||
work: {name: 办公学习, name_en: Work & study}
|
||||
life: {name: 购物生活, name_en: Shopping & life}
|
||||
info: {name: 资讯阅读, name_en: News & reading}
|
||||
iot: {name: 智能设备, name_en: Smart devices}
|
||||
base: {name: 网络基础, name_en: Infrastructure}
|
||||
unknown: {name: 未识别, name_en: Unknown}
|
||||
|
||||
categories:
|
||||
video: {name: 视频, name_en: Video}
|
||||
short_video: {name: 短视频, name_en: Short video}
|
||||
live: {name: 直播, name_en: Live}
|
||||
music: {name: 音乐音频, name_en: Audio}
|
||||
social: {name: 社交社区, name_en: Social}
|
||||
im: {name: 即时通讯, name_en: Messaging}
|
||||
game: {name: 游戏, name_en: Gaming}
|
||||
shopping: {name: 购物, name_en: Shopping}
|
||||
news: {name: 资讯阅读, name_en: News}
|
||||
search: {name: 搜索门户, name_en: Search & portal}
|
||||
cloud: {name: 网盘存储, name_en: Cloud storage}
|
||||
download: {name: 下载P2P, name_en: Download & P2P}
|
||||
update: {name: 系统更新, name_en: Software update}
|
||||
ai: {name: AI 工具, name_en: AI}
|
||||
work: {name: 办公开发, name_en: Work & dev}
|
||||
finance: {name: 金融支付, name_en: Finance}
|
||||
life: {name: 生活出行, name_en: Lifestyle}
|
||||
ads: {name: 广告统计, name_en: Ads & analytics}
|
||||
iot: {name: 智能家居, name_en: Smart home}
|
||||
network: {name: 网络协议, name_en: Network protocol}
|
||||
web: {name: 通用网页, name_en: Web}
|
||||
unknown: {name: 未识别, name_en: Unknown}
|
||||
video: {group: media, name: 在线视频, name_en: Video}
|
||||
short_video: {group: media, name: 短视频, name_en: Short video}
|
||||
live: {group: media, name: 直播, name_en: Live streaming}
|
||||
music: {group: media, name: 音乐音频, name_en: Music & audio}
|
||||
game: {group: game, name: 游戏平台, name_en: Game platforms}
|
||||
mobile_game: {group: game, name: 手机游戏, name_en: Mobile games}
|
||||
console_game: {group: game, name: 主机游戏, name_en: Console games}
|
||||
game_tool: {group: game, name: 游戏加速, name_en: Game boosters}
|
||||
im: {group: social, name: 即时通讯, name_en: Messaging}
|
||||
social: {group: social, name: 社交社区, name_en: Social}
|
||||
voip: {group: social, name: 语音通话, name_en: Voice & calls}
|
||||
mail: {group: social, name: 电子邮件, name_en: Mail}
|
||||
download: {group: transfer, name: 下载P2P, name_en: Download & P2P}
|
||||
cloud: {group: transfer, name: 网盘存储, name_en: Cloud storage}
|
||||
update: {group: transfer, name: 软件更新, name_en: Software update}
|
||||
app_store: {group: transfer, name: 应用商店, name_en: App stores}
|
||||
work: {group: work, name: 办公协作, name_en: Office & collaboration}
|
||||
meeting: {group: work, name: 视频会议, name_en: Meetings}
|
||||
dev: {group: work, name: 开发工具, name_en: Developer}
|
||||
remote: {group: work, name: 远程控制, name_en: Remote access}
|
||||
ai: {group: work, name: AI 工具, name_en: AI}
|
||||
education: {group: work, name: 在线教育, name_en: Education}
|
||||
shopping: {group: life, name: 网络购物, name_en: Shopping}
|
||||
life: {group: life, name: 生活服务, name_en: Lifestyle}
|
||||
travel: {group: life, name: 出行地图, name_en: Travel & maps}
|
||||
finance: {group: life, name: 支付, name_en: Payments}
|
||||
bank: {group: life, name: 银行, name_en: Banking}
|
||||
stock: {group: life, name: 证券理财, name_en: Investing}
|
||||
news: {group: info, name: 新闻资讯, name_en: News}
|
||||
search: {group: info, name: 搜索门户, name_en: Search & portal}
|
||||
reading: {group: info, name: 阅读漫画, name_en: Reading & comics}
|
||||
iot: {group: iot, name: 智能家居, name_en: Smart home}
|
||||
camera: {group: iot, name: 监控摄像, name_en: Cameras}
|
||||
network: {group: base, name: 网络协议, name_en: Network protocol}
|
||||
system: {group: base, name: 系统服务, name_en: OS services}
|
||||
cdn: {group: base, name: CDN 云服务, name_en: CDN & cloud}
|
||||
proxy: {group: base, name: 代理隧道, name_en: VPN & tunnels}
|
||||
security: {group: base, name: 安全软件, name_en: Security}
|
||||
ads: {group: base, name: 广告统计, name_en: Ads & analytics}
|
||||
web: {group: base, name: 通用网页, name_en: Web}
|
||||
unknown: {group: unknown, name: 未识别, name_en: Unknown}
|
||||
|
||||
apps:
|
||||
# ---------- 短视频 / 视频 ----------
|
||||
# ================= 影音娱乐 =================
|
||||
# ---------- 短视频 ----------
|
||||
- {id: douyin, name: 抖音, name_en: Douyin, category: short_video,
|
||||
domains: [douyin.com, douyinpic.com, douyinvod.com, douyincdn.com, douyinstatic.com, iesdouyin.com, amemv.com, snssdk.com, bytecdn.cn, byteimg.com, pstatp.com, zjcdn.com, bytedance.com, bytegoofy.com, ibytedtos.com, volces.com]}
|
||||
- {id: kuaishou, name: 快手, name_en: Kuaishou, category: short_video,
|
||||
domains: [kuaishou.com, gifshow.com, ksapisrv.com, kwaicdn.com, kwimgs.com, yximgs.com, kspkg.com, kuaishouzt.com, ndcimgs.com]}
|
||||
- {id: tiktok, name: TikTok, name_en: TikTok, category: short_video,
|
||||
domains: [tiktok.com, tiktokv.com, tiktokcdn.com, tiktokcdn-us.com, musical.ly, byteoversea.com, ibyteimg.com, ttwstatic.com]}
|
||||
- {id: wechat_channels, name: 微信视频号, name_en: WeChat Channels, category: short_video,
|
||||
domains: [finder.video.qq.com, channels.weixin.qq.com]}
|
||||
- {id: weishi, name: 微视, name_en: Weishi, category: short_video, domains: [weishi.qq.com]}
|
||||
- {id: xigua, name: 西瓜视频, name_en: Xigua, category: short_video,
|
||||
domains: [ixigua.com, ixiguavideo.com]}
|
||||
- {id: haokan, name: 好看视频, name_en: Haokan, category: short_video, domains: [haokan.baidu.com, haokanvideo.com]}
|
||||
# ---------- 在线视频 ----------
|
||||
- {id: bilibili, name: 哔哩哔哩, name_en: Bilibili, category: video,
|
||||
domains: [bilibili.com, bilivideo.com, bilivideo.cn, hdslb.com, biliapi.net, biliapi.com, bilicdn1.com, bilicdn2.com, b23.tv, acgvideo.com]}
|
||||
- {id: iqiyi, name: 爱奇艺, name_en: iQIYI, category: video,
|
||||
@@ -46,25 +89,32 @@ apps:
|
||||
domains: [youku.com, ykimg.com, tudou.com, soku.com, cibntv.net, youku.tv]}
|
||||
- {id: mgtv, name: 芒果TV, name_en: Mango TV, category: video,
|
||||
domains: [mgtv.com, hitv.com, hunantv.com]}
|
||||
- {id: xiaohongshu, name: 小红书, name_en: Xiaohongshu, category: social,
|
||||
domains: [xiaohongshu.com, xhscdn.com, xhslink.com, xhscdn.net]}
|
||||
- {id: sohu_video, name: 搜狐视频, name_en: Sohu Video, category: video, domains: [tv.sohu.com, film.sohu.com]}
|
||||
- {id: migu_video, name: 咪咕视频, name_en: Migu Video, category: video, domains: [miguvideo.com, cmvideo.cn]}
|
||||
- {id: cctv, name: 央视频/CCTV, name_en: CCTV, category: video, domains: [cctv.com, cntv.cn, cctvpic.com, yangshipin.cn]}
|
||||
- {id: youtube, name: YouTube, name_en: YouTube, category: video,
|
||||
domains: [youtube.com, googlevideo.com, ytimg.com, youtu.be, youtube-nocookie.com, yt3.ggpht.com, youtubei.googleapis.com]}
|
||||
- {id: netflix, name: Netflix, name_en: Netflix, category: video,
|
||||
domains: [netflix.com, nflxvideo.net, nflximg.net, nflxext.com, nflxso.net, nflximg.com]}
|
||||
- {id: disney, name: Disney+, name_en: Disney+, category: video,
|
||||
domains: [disneyplus.com, disney-plus.net, dssott.com, bamgrid.com, disneystreaming.com]}
|
||||
- {id: prime_video, name: Prime Video, name_en: Prime Video, category: video,
|
||||
domains: [primevideo.com, aiv-cdn.net, aiv-delivery.net, pv-cdn.net]}
|
||||
- {id: hbo, name: HBO Max, name_en: HBO Max, category: video, domains: [max.com, hbomax.com, hbo.com]}
|
||||
- {id: hulu, name: Hulu, name_en: Hulu, category: video, domains: [hulu.com, hulustream.com, huluim.com]}
|
||||
- {id: apple_tv, name: Apple TV+, name_en: Apple TV+, category: video,
|
||||
domains: [tv.apple.com, hls.itunes.apple.com, play-edge.itunes.apple.com, np-edge.itunes.apple.com]}
|
||||
# ---------- 直播 ----------
|
||||
- {id: twitch, name: Twitch, name_en: Twitch, category: live,
|
||||
domains: [twitch.tv, ttvnw.net, jtvnw.net, twitchcdn.net]}
|
||||
- {id: huya, name: 虎牙直播, name_en: Huya, category: live,
|
||||
domains: [huya.com, msstatic.com, huyaimg.com, hy-cdn.com]}
|
||||
- {id: douyu, name: 斗鱼直播, name_en: Douyu, category: live,
|
||||
domains: [douyu.com, douyucdn.cn, douyucdn2.cn, douyuscdn.com]}
|
||||
- {id: xigua, name: 西瓜视频, name_en: Xigua, category: video,
|
||||
domains: [ixigua.com, ixiguavideo.com]}
|
||||
|
||||
- {id: bili_live, name: B站直播, name_en: Bilibili Live, category: live, domains: [live.bilibili.com, api.live.bilibili.com]}
|
||||
- {id: yy, name: YY直播, name_en: YY Live, category: live, domains: [yy.com, yystatic.com, bigo.tv]}
|
||||
- {id: inke, name: 映客/花椒, name_en: Inke/Huajiao, category: live, domains: [inke.cn, inke.com, huajiao.com]}
|
||||
- {id: kick, name: Kick, name_en: Kick, category: live, domains: [kick.com]}
|
||||
# ---------- 音乐音频 ----------
|
||||
- {id: netease_music, name: 网易云音乐, name_en: NetEase Music, category: music,
|
||||
domains: [music.163.com, music.126.net, music.netease.com, iplay.163.com]}
|
||||
@@ -72,47 +122,18 @@ apps:
|
||||
domains: [y.qq.com, music.qq.com, stream.qqmusic.qq.com, qqmusic.qq.com, ws.stream.qqmusic.qq.com]}
|
||||
- {id: kugou, name: 酷狗/酷我, name_en: Kugou/Kuwo, category: music,
|
||||
domains: [kugou.com, kgimg.com, kuwo.cn]}
|
||||
- {id: migu_music, name: 咪咕音乐, name_en: Migu Music, category: music, domains: [music.migu.cn]}
|
||||
- {id: ximalaya, name: 喜马拉雅, name_en: Ximalaya, category: music,
|
||||
domains: [ximalaya.com, xmcdn.com]}
|
||||
- {id: lizhi, name: 荔枝/蜻蜓FM, name_en: Lizhi/Qingting FM, category: music, domains: [lizhi.fm, lizhifm.com, qingting.fm, qtfm.cn]}
|
||||
- {id: spotify, name: Spotify, name_en: Spotify, category: music,
|
||||
domains: [spotify.com, scdn.co, spotifycdn.com, spotify.design, pscdn.co]}
|
||||
- {id: apple_music, name: Apple Music, name_en: Apple Music, category: music,
|
||||
domains: [music.apple.com, aod.itunes.apple.com, audio-ssl.itunes.apple.com, mvod.itunes.apple.com]}
|
||||
domains: [music.apple.com, aod.itunes.apple.com, audio-ssl.itunes.apple.com, mvod.itunes.apple.com, podcasts.apple.com]}
|
||||
- {id: youtube_music, name: YouTube Music, name_en: YouTube Music, category: music, domains: [music.youtube.com]}
|
||||
- {id: soundcloud, name: SoundCloud, name_en: SoundCloud, category: music, domains: [soundcloud.com, sndcdn.com]}
|
||||
|
||||
# ---------- 即时通讯 / 社交 ----------
|
||||
- {id: wechat, name: 微信, name_en: WeChat, category: im,
|
||||
domains: [weixin.qq.com, wechat.com, servicewechat.com, weixinbridge.com, wx.qq.com, qpic.cn, qlogo.cn, wxs.qq.com, mmbiz.qpic.cn, tenpay.com, wxapp.tc.qq.com, finder.video.qq.com, channels.weixin.qq.com, szextshort.weixin.qq.com, szshort.weixin.qq.com, szminorshort.weixin.qq.com, extshort.weixin.qq.com, short.weixin.qq.com, dns.weixin.qq.com]}
|
||||
- {id: qq, name: QQ, name_en: QQ, category: im,
|
||||
domains: [qq.com, qzone.qq.com, im.qq.com, idqqimg.com, gtimg.cn, qqmail.com, tencent.com, myqcloud.com, qcloud.com, tencent-cloud.net, tencentcs.com]}
|
||||
- {id: dingtalk, name: 钉钉, name_en: DingTalk, category: work,
|
||||
domains: [dingtalk.com, dingtalkapps.com, alicdn-dingtalk.com, dingtalk.cn]}
|
||||
- {id: feishu, name: 飞书, name_en: Lark/Feishu, category: work,
|
||||
domains: [feishu.cn, larksuite.com, feishucdn.com, larkoffice.com, feishu.net]}
|
||||
- {id: wework, name: 企业微信, name_en: WeCom, category: work,
|
||||
domains: [work.weixin.qq.com, wework.qpic.cn, qyapi.weixin.qq.com]}
|
||||
- {id: weibo, name: 微博, name_en: Weibo, category: social,
|
||||
domains: [weibo.com, weibo.cn, sinaimg.cn, weibocdn.com, sina.com.cn, sina.cn]}
|
||||
- {id: zhihu, name: 知乎, name_en: Zhihu, category: social,
|
||||
domains: [zhihu.com, zhimg.com, zhihu.cn]}
|
||||
- {id: douban, name: 豆瓣, name_en: Douban, category: social,
|
||||
domains: [douban.com, doubanio.com]}
|
||||
- {id: tieba, name: 百度贴吧, name_en: Tieba, category: social,
|
||||
domains: [tieba.baidu.com, tiebapic.baidu.com]}
|
||||
- {id: telegram, name: Telegram, name_en: Telegram, category: im,
|
||||
domains: [telegram.org, t.me, telegram.me, tdesktop.com, telesco.pe, telegra.ph],
|
||||
cidrs: [91.108.4.0/22, 91.108.8.0/22, 91.108.12.0/22, 91.108.16.0/22, 91.108.56.0/22, 149.154.160.0/20, 95.161.64.0/20, "2001:b28:f23d::/48", "2001:b28:f23f::/48", "2001:67c:4e8::/48"]}
|
||||
- {id: whatsapp, name: WhatsApp, name_en: WhatsApp, category: im,
|
||||
domains: [whatsapp.com, whatsapp.net, wa.me]}
|
||||
- {id: discord, name: Discord, name_en: Discord, category: im,
|
||||
domains: [discord.com, discord.gg, discordapp.com, discordapp.net, discord.media]}
|
||||
- {id: facebook, name: Facebook/Instagram, name_en: Meta, category: social,
|
||||
domains: [facebook.com, fbcdn.net, instagram.com, cdninstagram.com, messenger.com, fb.com, threads.net]}
|
||||
- {id: twitter, name: X/Twitter, name_en: X/Twitter, category: social,
|
||||
domains: [twitter.com, x.com, twimg.com, t.co]}
|
||||
- {id: reddit, name: Reddit, name_en: Reddit, category: social,
|
||||
domains: [reddit.com, redd.it, redditmedia.com, redditstatic.com]}
|
||||
|
||||
# ---------- 游戏 ----------
|
||||
# ================= 网络游戏 =================
|
||||
- {id: steam, name: Steam, name_en: Steam, category: game,
|
||||
domains: [steampowered.com, steamcommunity.com, steamstatic.com, steamcontent.com, steamserver.net, steamgames.com, steamusercontent.com, valvesoftware.com, steamchina.com, akamai.steamstatic.com]}
|
||||
- {id: epic, name: Epic Games, name_en: Epic Games, category: game,
|
||||
@@ -121,62 +142,103 @@ apps:
|
||||
domains: [game.qq.com, gamesafe.qq.com, wegame.com, wegame.com.cn, gcloud.qq.com, pvp.qq.com, gp.qq.com, tgpa.qq.com, dlied1.qq.com, dlied5.qq.com, gamecenter.qq.com, tencentgames.com, levelinfinite.com]}
|
||||
- {id: netease_games, name: 网易游戏, name_en: NetEase Games, category: game,
|
||||
domains: [game.163.com, nie.netease.com, netease.gg, x19.update.netease.com, gdl.netease.com, update.netease.com]}
|
||||
- {id: mihoyo, name: 米哈游, name_en: HoYoverse, category: game,
|
||||
domains: [mihoyo.com, hoyoverse.com, yuanshen.com, genshinimpact.com, hoyolab.com, bhsr.com, starrails.com, mhyurl.cn]}
|
||||
- {id: xbox, name: Xbox/Microsoft游戏, name_en: Xbox, category: game,
|
||||
domains: [xbox.com, xboxlive.com, xboxservices.com, gamepass.com, assets1.xboxlive.com]}
|
||||
- {id: playstation, name: PlayStation, name_en: PlayStation, category: game,
|
||||
domains: [playstation.com, playstation.net, sonyentertainmentnetwork.com, np.dl.playstation.net, gs2.ww.prod.dl.playstation.net]}
|
||||
- {id: nintendo, name: Nintendo, name_en: Nintendo, category: game,
|
||||
domains: [nintendo.com, nintendo.net, nintendo.co.jp, nintendowifi.net]}
|
||||
- {id: blizzard, name: 暴雪战网, name_en: Battle.net, category: game,
|
||||
domains: [battle.net, blizzard.com, blizzard.cn, battlenet.com.cn, blzstatic.cn]}
|
||||
- {id: riot, name: Riot Games, name_en: Riot Games, category: game,
|
||||
domains: [riotgames.com, leagueoflegends.com, riotcdn.net, playvalorant.com]}
|
||||
- {id: ea, name: EA, name_en: EA, category: game,
|
||||
domains: [ea.com, origin.com, eaassets-a.akamaihd.net]}
|
||||
- {id: ubisoft, name: Ubisoft, name_en: Ubisoft, category: game, domains: [ubisoft.com, ubi.com, ubisoftconnect.com]}
|
||||
- {id: gog, name: GOG, name_en: GOG, category: game, domains: [gog.com, gog-statics.com]}
|
||||
- {id: rockstar, name: Rockstar, name_en: Rockstar, category: game, domains: [rockstargames.com]}
|
||||
- {id: activision, name: 动视, name_en: Activision, category: game, domains: [activision.com, callofduty.com]}
|
||||
- {id: pubg, name: PUBG/Krafton, name_en: PUBG/Krafton, category: game, domains: [pubg.com, playbattlegrounds.com, krafton.com]}
|
||||
- {id: minecraft, name: Minecraft, name_en: Minecraft, category: game, domains: [minecraft.net, mojang.com, minecraftservices.com]}
|
||||
- {id: roblox, name: Roblox, name_en: Roblox, category: game, domains: [roblox.com, rbxcdn.com, roblox.cn]}
|
||||
- {id: perfect_world, name: 完美世界, name_en: Perfect World, category: game, domains: [wanmei.com, pwrd.com, perfectworld.com]}
|
||||
- {id: kingsoft_games, name: 西山居/盛趣/巨人, name_en: Seasun/Shengqu/Giant, category: game, domains: [xoyo.com, seasun.com, sdo.com, ztgame.com]}
|
||||
- {id: game_4399, name: 4399, name_en: "4399", category: game, domains: [4399.com, img4399.com, 4399sj.com]}
|
||||
- {id: mihoyo, name: 米哈游, name_en: HoYoverse, category: mobile_game,
|
||||
domains: [mihoyo.com, hoyoverse.com, yuanshen.com, genshinimpact.com, hoyolab.com, bhsr.com, starrails.com, mhyurl.cn]}
|
||||
- {id: hypergryph, name: 鹰角网络, name_en: Hypergryph, category: mobile_game, domains: [hypergryph.com, hycdn.cn]}
|
||||
- {id: lilith, name: 莉莉丝, name_en: Lilith Games, category: mobile_game, domains: [lilith.com, lilithgame.com, lilithgames.com]}
|
||||
- {id: papergames, name: 叠纸游戏, name_en: Papergames, category: mobile_game, domains: [papegames.com, papegames.cn]}
|
||||
- {id: supercell, name: Supercell, name_en: Supercell, category: mobile_game, domains: [supercell.com, supercell.net]}
|
||||
- {id: taptap, name: TapTap, name_en: TapTap, category: mobile_game, domains: [taptap.com, taptap.cn, tapimg.com, taptap.io]}
|
||||
- {id: xbox, name: Xbox/Microsoft游戏, name_en: Xbox, category: console_game,
|
||||
domains: [xbox.com, xboxlive.com, xboxservices.com, gamepass.com, assets1.xboxlive.com]}
|
||||
- {id: playstation, name: PlayStation, name_en: PlayStation, category: console_game,
|
||||
domains: [playstation.com, playstation.net, sonyentertainmentnetwork.com, np.dl.playstation.net, gs2.ww.prod.dl.playstation.net]}
|
||||
- {id: nintendo, name: Nintendo, name_en: Nintendo, category: console_game,
|
||||
domains: [nintendo.com, nintendo.net, nintendo.co.jp, nintendowifi.net]}
|
||||
- {id: game_booster, name: 游戏加速器, name_en: Game boosters, category: game_tool,
|
||||
domains: [uu.163.com, xunyou.com, leigod.com, qiyou.cn, biubiu001.com]}
|
||||
|
||||
# ---------- 购物 / 生活 / 金融 ----------
|
||||
- {id: taobao, name: 淘宝/天猫, name_en: Taobao/Tmall, category: shopping,
|
||||
domains: [taobao.com, tmall.com, alicdn.com, tbcdn.cn, taobaocdn.com, mmstat.com, tanx.com, alibaba.com, aliexpress.com, 1688.com, alipayobjects.com]}
|
||||
- {id: jd, name: 京东, name_en: JD, category: shopping,
|
||||
domains: [jd.com, 360buyimg.com, jd.hk, jdpay.com, jcloud.com, jdcloud.com, 3.cn]}
|
||||
- {id: pinduoduo, name: 拼多多, name_en: Pinduoduo, category: shopping,
|
||||
domains: [pinduoduo.com, yangkeduo.com, pddpic.com, pddugc.com, temu.com, kwcdn.com]}
|
||||
- {id: meituan, name: 美团, name_en: Meituan, category: life,
|
||||
domains: [meituan.com, meituan.net, dianping.com, dpfile.com, maoyan.com, sankuai.com]}
|
||||
- {id: eleme, name: 饿了么, name_en: Ele.me, category: life,
|
||||
domains: [ele.me, elemecdn.com, eleme.cn]}
|
||||
- {id: didi, name: 滴滴出行, name_en: DiDi, category: life,
|
||||
domains: [didiglobal.com, diditaxi.com.cn, didistatic.com, xiaojukeji.com]}
|
||||
- {id: amap, name: 高德地图, name_en: Amap, category: life,
|
||||
domains: [amap.com, autonavi.com, gaode.com]}
|
||||
- {id: ctrip, name: 携程, name_en: Trip.com, category: life,
|
||||
domains: [ctrip.com, trip.com, c-ctrip.com, ctripcorp.com, tripcdn.com]}
|
||||
- {id: alipay, name: 支付宝, name_en: Alipay, category: finance,
|
||||
domains: [alipay.com, alipay.cn, alipay-eco.com, antgroup.com, alipaydns.com, mybank.cn]}
|
||||
- {id: amazon, name: Amazon, name_en: Amazon, category: shopping,
|
||||
domains: [amazon.com, amazon.cn, media-amazon.com, ssl-images-amazon.com, amazon.co.jp]}
|
||||
# ================= 社交通讯 =================
|
||||
- {id: wechat, name: 微信, name_en: WeChat, category: im,
|
||||
domains: [weixin.qq.com, wechat.com, servicewechat.com, weixinbridge.com, wx.qq.com, qpic.cn, qlogo.cn, wxs.qq.com, mmbiz.qpic.cn, tenpay.com, wxapp.tc.qq.com, szextshort.weixin.qq.com, szshort.weixin.qq.com, szminorshort.weixin.qq.com, extshort.weixin.qq.com, short.weixin.qq.com, dns.weixin.qq.com]}
|
||||
- {id: qq, name: QQ, name_en: QQ, category: im,
|
||||
domains: [qq.com, qzone.qq.com, im.qq.com, idqqimg.com, gtimg.cn, tencent.com]}
|
||||
- {id: telegram, name: Telegram, name_en: Telegram, category: im,
|
||||
domains: [telegram.org, t.me, telegram.me, tdesktop.com, telesco.pe, telegra.ph],
|
||||
cidrs: [91.108.4.0/22, 91.108.8.0/22, 91.108.12.0/22, 91.108.16.0/22, 91.108.56.0/22, 149.154.160.0/20, 95.161.64.0/20, "2001:b28:f23d::/48", "2001:b28:f23f::/48", "2001:67c:4e8::/48"]}
|
||||
- {id: whatsapp, name: WhatsApp, name_en: WhatsApp, category: im,
|
||||
domains: [whatsapp.com, whatsapp.net, wa.me]}
|
||||
- {id: discord, name: Discord, name_en: Discord, category: im,
|
||||
domains: [discord.com, discord.gg, discordapp.com, discordapp.net, discord.media]}
|
||||
- {id: line, name: LINE, name_en: LINE, category: im, domains: [line.me, line-apps.com, line-scdn.net, naver.jp]}
|
||||
- {id: signal, name: Signal, name_en: Signal, category: im, domains: [signal.org, whispersystems.org, signal.art]}
|
||||
- {id: kakao, name: KakaoTalk, name_en: KakaoTalk, category: im, domains: [kakao.com, kakaocdn.net]}
|
||||
- {id: momo, name: 陌陌/探探, name_en: Momo/Tantan, category: im, domains: [immomo.com, momocdn.com, tantanapp.com]}
|
||||
- {id: soul, name: Soul, name_en: Soul, category: im, domains: [soulapp.cn, soulapp.me]}
|
||||
- {id: weibo, name: 微博, name_en: Weibo, category: social,
|
||||
domains: [weibo.com, weibo.cn, sinaimg.cn, weibocdn.com]}
|
||||
- {id: xiaohongshu, name: 小红书, name_en: Xiaohongshu, category: social,
|
||||
domains: [xiaohongshu.com, xhscdn.com, xhslink.com, xhscdn.net]}
|
||||
- {id: zhihu, name: 知乎, name_en: Zhihu, category: social,
|
||||
domains: [zhihu.com, zhimg.com, zhihu.cn]}
|
||||
- {id: douban, name: 豆瓣, name_en: Douban, category: social,
|
||||
domains: [douban.com, doubanio.com]}
|
||||
- {id: tieba, name: 百度贴吧, name_en: Tieba, category: social,
|
||||
domains: [tieba.baidu.com, tiebapic.baidu.com]}
|
||||
- {id: hupu, name: 虎扑, name_en: Hupu, category: social, domains: [hupu.com, hoopchina.com.cn, hupucdn.com]}
|
||||
- {id: nga, name: NGA 玩家社区, name_en: NGA, category: social, domains: [nga.cn, ngabbs.com, 178.com, nga.178.com]}
|
||||
- {id: jike, name: 即刻/脉脉, name_en: Jike/Maimai, category: social, domains: [okjike.com, maimai.cn, taou.com]}
|
||||
- {id: facebook, name: Facebook/Instagram, name_en: Meta, category: social,
|
||||
domains: [facebook.com, fbcdn.net, instagram.com, cdninstagram.com, messenger.com, fb.com, threads.net]}
|
||||
- {id: twitter, name: X/Twitter, name_en: X/Twitter, category: social,
|
||||
domains: [twitter.com, x.com, twimg.com, t.co]}
|
||||
- {id: reddit, name: Reddit, name_en: Reddit, category: social,
|
||||
domains: [reddit.com, redd.it, redditmedia.com, redditstatic.com]}
|
||||
- {id: linkedin, name: LinkedIn, name_en: LinkedIn, category: social, domains: [linkedin.com, licdn.com]}
|
||||
- {id: pinterest, name: Pinterest, name_en: Pinterest, category: social, domains: [pinterest.com, pinimg.com]}
|
||||
- {id: snapchat, name: Snapchat, name_en: Snapchat, category: social, domains: [snapchat.com, sc-cdn.net, snap.com]}
|
||||
- {id: bluesky, name: Bluesky/Mastodon, name_en: Bluesky/Mastodon, category: social, domains: [bsky.app, bsky.social, mastodon.social, joinmastodon.org]}
|
||||
- {id: quora, name: Quora/Tumblr, name_en: Quora/Tumblr, category: social, domains: [quora.com, tumblr.com]}
|
||||
- {id: stun, name: 音视频通话(STUN/RTP), name_en: Real-time media, category: voip, ports: [udp/3478-3481, udp/19302-19309]}
|
||||
- {id: sip, name: SIP 电话, name_en: SIP, category: voip, ports: [udp/5060, tcp/5060, tcp/5061]}
|
||||
- {id: mail, name: 邮件协议, name_en: Mail protocols, category: mail, ports: [tcp/25, tcp/465, tcp/587, tcp/993, tcp/995, tcp/143, tcp/110]}
|
||||
- {id: qq_mail, name: QQ邮箱, name_en: QQ Mail, category: mail, domains: [mail.qq.com, exmail.qq.com, qqmail.com]}
|
||||
- {id: netease_mail, name: 网易邮箱, name_en: NetEase Mail, category: mail, domains: [mail.163.com, mail.126.com, ym.163.com, qiye.163.com, mail.yeah.net]}
|
||||
- {id: gmail, name: Gmail, name_en: Gmail, category: mail, domains: [mail.google.com, gmail.com, inbox.google.com]}
|
||||
- {id: outlook, name: Outlook, name_en: Outlook, category: mail, domains: [outlook.com, outlook.live.com, outlook.office365.com, outlook.office.com, hotmail.com]}
|
||||
- {id: proton, name: Proton, name_en: Proton, category: mail, domains: [proton.me, protonmail.com, protonmail.ch]}
|
||||
|
||||
# ---------- 搜索 / 门户 / 资讯 ----------
|
||||
- {id: baidu, name: 百度, name_en: Baidu, category: search,
|
||||
domains: [baidu.com, bdstatic.com, bdimg.com, bcebos.com, bdydns.com, baidubce.com, baiduyun.com, shifen.com]}
|
||||
- {id: google, name: Google, name_en: Google, category: search,
|
||||
domains: [google.com, googleapis.com, gstatic.com, google.com.hk, googleusercontent.com, ggpht.com, gvt1.com, gvt2.com, googlesyndication.com, google-analytics.com, googletagmanager.com, recaptcha.net]}
|
||||
- {id: bing, name: Bing, name_en: Bing, category: search,
|
||||
domains: [bing.com, bing.net]}
|
||||
- {id: netease, name: 网易, name_en: NetEase, category: news,
|
||||
domains: [163.com, 126.net, 127.net, netease.com, ydstatic.com]}
|
||||
- {id: toutiao, name: 今日头条, name_en: Toutiao, category: news,
|
||||
domains: [toutiao.com, toutiaocdn.com, toutiaoimg.cn]}
|
||||
- {id: wikipedia, name: Wikipedia, name_en: Wikipedia, category: news,
|
||||
domains: [wikipedia.org, wikimedia.org, wiki.wikipedia.org]}
|
||||
|
||||
# ---------- 网盘 / 下载 / 更新 ----------
|
||||
# ================= 下载更新 =================
|
||||
- {id: thunder, name: 迅雷, name_en: Xunlei, category: download,
|
||||
domains: [xunlei.com, sandai.net, n0808.com, xlcdn.com, xluser.com],
|
||||
ports: [tcp/6881-6889, udp/6881-6889]}
|
||||
- {id: bittorrent, name: BT/P2P, name_en: BitTorrent, category: download,
|
||||
domains: [tracker.opentrackr.org, bt.byr.cn, open.stealth.si]}
|
||||
- {id: baidu_pan, name: 百度网盘, name_en: Baidu Netdisk, category: cloud,
|
||||
domains: [pan.baidu.com, pcs.baidu.com, d.pcs.baidu.com, baidupcs.com, nd.baidu.com]}
|
||||
- {id: aliyun_drive, name: 阿里云盘/夸克, name_en: Aliyun Drive/Quark, category: cloud,
|
||||
domains: [aliyundrive.com, alipan.com, aliyundrive.net, quark.cn, quarkpan.com, uc.cn, ucweb.com]}
|
||||
- {id: pan_115, name: 115网盘, name_en: 115 Cloud, category: cloud, domains: [115.com, 115cdn.com, 115cdn.net]}
|
||||
- {id: tianyi_cloud, name: 天翼云盘/和彩云, name_en: Tianyi/Caiyun, category: cloud, domains: [cloud.189.cn, yun.139.com, caiyun.feixin.10086.cn]}
|
||||
- {id: pan_123, name: 123云盘/蓝奏云, name_en: 123Pan/Lanzou, category: cloud, domains: [123pan.com, 123pan.cn, lanzou.com, lanzoui.com, lanzoux.com]}
|
||||
- {id: jianguoyun, name: 坚果云, name_en: Nutstore, category: cloud, domains: [jianguoyun.com]}
|
||||
- {id: pikpak, name: PikPak, name_en: PikPak, category: cloud, domains: [mypikpak.com]}
|
||||
- {id: onedrive, name: OneDrive, name_en: OneDrive, category: cloud,
|
||||
domains: [onedrive.com, onedrive.live.com, 1drv.com, 1drv.ms, sharepoint.com, storage.live.com]}
|
||||
- {id: icloud, name: iCloud, name_en: iCloud, category: cloud,
|
||||
@@ -185,70 +247,234 @@ apps:
|
||||
domains: [drive.google.com, docs.google.com, drive.usercontent.google.com]}
|
||||
- {id: dropbox, name: Dropbox, name_en: Dropbox, category: cloud,
|
||||
domains: [dropbox.com, dropboxapi.com, dropboxusercontent.com, db.tt]}
|
||||
- {id: thunder, name: 迅雷, name_en: Xunlei, category: download,
|
||||
domains: [xunlei.com, sandai.net, n0808.com, xlcdn.com, xluser.com],
|
||||
ports: [tcp/6881-6889, udp/6881-6889]}
|
||||
- {id: bittorrent, name: BT/P2P, name_en: BitTorrent, category: download,
|
||||
domains: [tracker.opentrackr.org, bt.byr.cn, open.stealth.si]}
|
||||
- {id: apple_update, name: Apple 更新/商店, name_en: Apple updates/App Store, category: update,
|
||||
domains: [swcdn.apple.com, swdist.apple.com, updates.cdn-apple.com, mesu.apple.com, appldnld.apple.com, oscdn.apple.com, iosapps.itunes.apple.com, osxapps.itunes.apple.com, apps.apple.com, gdmf.apple.com]}
|
||||
- {id: apple, name: Apple 服务, name_en: Apple services, category: update,
|
||||
domains: [apple.com, mzstatic.com, itunes.apple.com, apple.com.cn, push.apple.com, aaplimg.com, apple-dns.net, apple.news]}
|
||||
- {id: mega, name: MEGA, name_en: MEGA, category: cloud, domains: [mega.nz, mega.co.nz, mega.io]}
|
||||
- {id: synology, name: 群晖 QuickConnect, name_en: Synology QuickConnect, category: cloud, domains: [quickconnect.to, quickconnect.cn, synology.com, synology.cn]}
|
||||
- {id: apple_update, name: Apple 更新, name_en: Apple updates, category: update,
|
||||
domains: [swcdn.apple.com, swdist.apple.com, updates.cdn-apple.com, mesu.apple.com, appldnld.apple.com, oscdn.apple.com, gdmf.apple.com]}
|
||||
- {id: windows_update, name: Windows 更新, name_en: Windows Update, category: update,
|
||||
domains: [windowsupdate.com, update.microsoft.com, delivery.mp.microsoft.com, dl.delivery.mp.microsoft.com, tlu.dl.delivery.mp.microsoft.com, download.windowsupdate.com, wustat.windows.com]}
|
||||
- {id: microsoft, name: Microsoft 服务, name_en: Microsoft services, category: work,
|
||||
domains: [microsoft.com, office.com, office.net, office365.com, live.com, msn.com, outlook.com, microsoftonline.com, msftconnecttest.com, windows.net, azureedge.net, msedge.net, skype.com, teams.microsoft.com, bing.com.cn]}
|
||||
- {id: android_update, name: 安卓/手机厂商, name_en: Android vendor services, category: update,
|
||||
domains: [xiaomi.com, xiaomi.net, mi.com, miui.com, mi-img.com, hicloud.com, huawei.com, dbankcdn.com, vivo.com.cn, vivoglobal.com, oppo.com, heytapmobi.com, coloros.com, heytapdownload.com, oneplus.com, honor.com, hihonor.com, meizu.com, flyme.cn]}
|
||||
- {id: app_market, name: 应用商店下载, name_en: App store downloads, category: update,
|
||||
domains: [play.googleapis.com, appstore.huawei.com, app.mi.com, apkpure.com, wandoujia.com, pp.cn, myapp.com, sj.qq.com]}
|
||||
- {id: chrome_update, name: Chrome/Google 更新, name_en: Chrome updates, category: update,
|
||||
domains: [dl.google.com, update.googleapis.com, gvt1.com, edgedl.me.gvt1.com]}
|
||||
- {id: firefox, name: Firefox/Mozilla, name_en: Firefox/Mozilla, category: update, domains: [mozilla.org, mozilla.net, firefox.com, mozilla.com]}
|
||||
- {id: driver_update, name: 显卡/硬件驱动, name_en: Drivers, category: update,
|
||||
domains: [nvidia.com, nvidia.cn, geforce.com, geforce.cn, amd.com, intel.com, intel.cn, lenovo.com, lenovo.com.cn, dell.com, hp.com]}
|
||||
- {id: app_store_apple, name: App Store, name_en: App Store, category: app_store,
|
||||
domains: [iosapps.itunes.apple.com, osxapps.itunes.apple.com, apps.apple.com, apps.mzstatic.com]}
|
||||
- {id: app_market, name: 安卓应用商店, name_en: Android app stores, category: app_store,
|
||||
domains: [play.googleapis.com, play.google.com, appstore.huawei.com, appgallery.huawei.com, app.mi.com, apkpure.com, wandoujia.com, pp.cn, myapp.com, sj.qq.com, appstore.vivo.com.cn, store.heytap.com]}
|
||||
|
||||
# ---------- AI / 开发 / 办公 ----------
|
||||
# ================= 办公学习 =================
|
||||
- {id: dingtalk, name: 钉钉, name_en: DingTalk, category: work,
|
||||
domains: [dingtalk.com, dingtalkapps.com, alicdn-dingtalk.com, dingtalk.cn]}
|
||||
- {id: feishu, name: 飞书, name_en: Lark/Feishu, category: work,
|
||||
domains: [feishu.cn, larksuite.com, feishucdn.com, larkoffice.com, feishu.net]}
|
||||
- {id: wework, name: 企业微信, name_en: WeCom, category: work,
|
||||
domains: [work.weixin.qq.com, wework.qpic.cn, qyapi.weixin.qq.com]}
|
||||
- {id: microsoft, name: Microsoft 365, name_en: Microsoft 365, category: work,
|
||||
domains: [microsoft.com, office.com, office.net, office365.com, live.com, msn.com, microsoftonline.com, msftconnecttest.com, windows.net, azureedge.net, msedge.net, skype.com, bing.com.cn]}
|
||||
- {id: wps, name: WPS, name_en: WPS, category: work,
|
||||
domains: [wps.cn, wps.com, kdocs.cn, kingsoft.com, ksord.com, wpscdn.cn]}
|
||||
- {id: tencent_docs, name: 腾讯文档, name_en: Tencent Docs, category: work, domains: [docs.qq.com]}
|
||||
- {id: notion, name: Notion, name_en: Notion, category: work, domains: [notion.so, notion.site, notion.com]}
|
||||
- {id: yuque, name: 语雀/石墨, name_en: Yuque/Shimo, category: work, domains: [yuque.com, shimo.im]}
|
||||
- {id: slack, name: Slack, name_en: Slack, category: work, domains: [slack.com, slack-edge.com, slack-msgs.com, slack-imgs.com]}
|
||||
- {id: atlassian, name: Atlassian, name_en: Atlassian, category: work, domains: [atlassian.net, atlassian.com, bitbucket.org, trello.com]}
|
||||
- {id: figma, name: Figma/Canva, name_en: Figma/Canva, category: work, domains: [figma.com, canva.com, canva.cn, mastergo.com, js.design]}
|
||||
- {id: adobe, name: Adobe, name_en: Adobe, category: work, domains: [adobe.com, adobe.io, adobelogin.com, adobecc.com, typekit.net]}
|
||||
- {id: evernote, name: 印象笔记/Evernote, name_en: Evernote, category: work, domains: [evernote.com, yinxiang.com]}
|
||||
- {id: tencent_meeting, name: 腾讯会议, name_en: Tencent Meeting, category: meeting,
|
||||
domains: [meeting.tencent.com, wemeet.qq.com, voovmeeting.com]}
|
||||
- {id: zoom, name: Zoom, name_en: Zoom, category: meeting,
|
||||
domains: [zoom.us, zoom.com, zoom.com.cn, zoomgov.com]}
|
||||
- {id: teams, name: Microsoft Teams, name_en: Microsoft Teams, category: meeting, domains: [teams.microsoft.com, teams.live.com, teams.cdn.office.net]}
|
||||
- {id: webex, name: Webex, name_en: Webex, category: meeting, domains: [webex.com, wbx2.com, webex.com.cn]}
|
||||
- {id: google_meet, name: Google Meet, name_en: Google Meet, category: meeting, domains: [meet.google.com]}
|
||||
- {id: github, name: GitHub, name_en: GitHub, category: dev,
|
||||
domains: [github.com, githubusercontent.com, githubassets.com, github.io, ghcr.io, github.dev, copilot-proxy.githubusercontent.com]}
|
||||
- {id: docker, name: Docker Hub, name_en: Docker Hub, category: dev,
|
||||
domains: [docker.com, docker.io, registry-1.docker.io, production.cloudflare.docker.com, dockerhub.com]}
|
||||
- {id: dev_mirrors, name: 开发镜像源, name_en: Package mirrors, category: dev,
|
||||
domains: [pypi.org, pythonhosted.org, npmjs.org, npmjs.com, npmmirror.com, yarnpkg.com, crates.io, golang.org, go.dev, maven.org, mirrors.aliyun.com, mirrors.tuna.tsinghua.edu.cn, mirrors.ustc.edu.cn, debian.org, ubuntu.com, archlinux.org, alpinelinux.org, fedoraproject.org, centos.org]}
|
||||
- {id: gitlab, name: GitLab/Gitee, name_en: GitLab/Gitee, category: dev, domains: [gitlab.com, gitee.com, gitee.io]}
|
||||
- {id: stackoverflow, name: Stack Overflow, name_en: Stack Overflow, category: dev, domains: [stackoverflow.com, sstatic.net, stackexchange.com]}
|
||||
- {id: csdn, name: CSDN/掘金/博客园, name_en: CSDN/Juejin/Cnblogs, category: dev, domains: [csdn.net, juejin.cn, cnblogs.com, oschina.net]}
|
||||
- {id: jetbrains, name: JetBrains, name_en: JetBrains, category: dev, domains: [jetbrains.com, jetbrains.com.cn]}
|
||||
- {id: vscode, name: VS Code, name_en: VS Code, category: dev, domains: [vscode.dev, visualstudio.com, vsassets.io, vscode-cdn.net, vscode-unpkg.net]}
|
||||
- {id: cursor, name: Cursor, name_en: Cursor, category: dev, domains: [cursor.sh, cursor.com, cursorapi.com]}
|
||||
- {id: ssh, name: SSH/远程桌面协议, name_en: SSH/RDP/VNC, category: remote, ports: [tcp/22, tcp/3389, udp/3389, tcp/5900]}
|
||||
- {id: todesk, name: ToDesk, name_en: ToDesk, category: remote, domains: [todesk.com, todesk.cn]}
|
||||
- {id: sunlogin, name: 向日葵, name_en: Sunlogin, category: remote, domains: [oray.com, oray.net, sunlogin.oray.com, orayimg.com]}
|
||||
- {id: teamviewer, name: TeamViewer, name_en: TeamViewer, category: remote, domains: [teamviewer.com, teamviewer.cn], ports: [tcp/5938, udp/5938]}
|
||||
- {id: anydesk, name: AnyDesk/RustDesk, name_en: AnyDesk/RustDesk, category: remote, domains: [anydesk.com, rustdesk.com], ports: [tcp/7070]}
|
||||
- {id: parsec, name: Parsec, name_en: Parsec, category: remote, domains: [parsec.app, parsecgaming.com]}
|
||||
- {id: chatgpt, name: ChatGPT, name_en: ChatGPT, category: ai,
|
||||
domains: [openai.com, chatgpt.com, oaistatic.com, oaiusercontent.com]}
|
||||
- {id: claude, name: Claude, name_en: Claude, category: ai,
|
||||
domains: [claude.ai, anthropic.com, claude.com, claudeusercontent.com]}
|
||||
- {id: gemini, name: Gemini, name_en: Gemini, category: ai,
|
||||
domains: [gemini.google.com, generativelanguage.googleapis.com, aistudio.google.com]}
|
||||
- {id: cn_ai, name: 国产大模型, name_en: Chinese AI, category: ai,
|
||||
domains: [deepseek.com, kimi.moonshot.cn, moonshot.cn, kimi.com, doubao.com, yiyan.baidu.com, tongyi.aliyun.com, qianwen.com, chatglm.cn, zhipuai.cn, yuanbao.tencent.com, hunyuan.tencent.com, minimaxi.com, hailuoai.com]}
|
||||
- {id: github, name: GitHub, name_en: GitHub, category: work,
|
||||
domains: [github.com, githubusercontent.com, githubassets.com, github.io, ghcr.io, github.dev, copilot-proxy.githubusercontent.com]}
|
||||
- {id: docker, name: Docker Hub, name_en: Docker Hub, category: work,
|
||||
domains: [docker.com, docker.io, registry-1.docker.io, production.cloudflare.docker.com, dockerhub.com]}
|
||||
- {id: dev_mirrors, name: 开发镜像源, name_en: Package mirrors, category: work,
|
||||
domains: [pypi.org, pythonhosted.org, npmjs.org, npmjs.com, npmmirror.com, yarnpkg.com, crates.io, golang.org, go.dev, maven.org, mirrors.aliyun.com, mirrors.tuna.tsinghua.edu.cn, mirrors.ustc.edu.cn, debian.org, ubuntu.com, archlinux.org, alpinelinux.org, fedoraproject.org, centos.org]}
|
||||
- {id: wps, name: WPS, name_en: WPS, category: work,
|
||||
domains: [wps.cn, wps.com, kdocs.cn, kingsoft.com, ksord.com, wpscdn.cn]}
|
||||
- {id: tencent_meeting, name: 腾讯会议, name_en: Tencent Meeting, category: work,
|
||||
domains: [meeting.tencent.com, wemeet.qq.com, voovmeeting.com]}
|
||||
- {id: zoom, name: Zoom, name_en: Zoom, category: work,
|
||||
domains: [zoom.us, zoom.com, zoom.com.cn, zoomgov.com]}
|
||||
- {id: cloudflare, name: Cloudflare, name_en: Cloudflare, category: web,
|
||||
domains: [cloudflare.com, cloudflare-dns.com, cloudflareinsights.com, cloudflare.net, workers.dev, pages.dev]}
|
||||
- {id: copilot, name: Microsoft Copilot, name_en: Microsoft Copilot, category: ai, domains: [copilot.microsoft.com]}
|
||||
- {id: grok, name: Grok, name_en: Grok, category: ai, domains: [grok.com, x.ai]}
|
||||
- {id: perplexity, name: Perplexity/Poe, name_en: Perplexity/Poe, category: ai, domains: [perplexity.ai, poe.com]}
|
||||
- {id: midjourney, name: Midjourney, name_en: Midjourney, category: ai, domains: [midjourney.com]}
|
||||
- {id: huggingface, name: Hugging Face, name_en: Hugging Face, category: ai, domains: [huggingface.co, hf.co, hf-mirror.com]}
|
||||
- {id: deepseek, name: DeepSeek, name_en: DeepSeek, category: ai, domains: [deepseek.com]}
|
||||
- {id: kimi, name: Kimi, name_en: Kimi, category: ai, domains: [kimi.moonshot.cn, moonshot.cn, kimi.com]}
|
||||
- {id: doubao, name: 豆包, name_en: Doubao, category: ai, domains: [doubao.com]}
|
||||
- {id: yuanbao, name: 腾讯元宝, name_en: Yuanbao, category: ai, domains: [yuanbao.tencent.com, hunyuan.tencent.com]}
|
||||
- {id: tongyi, name: 通义千问, name_en: Qwen, category: ai, domains: [tongyi.aliyun.com, qianwen.com, tongyi.com]}
|
||||
- {id: wenxin, name: 文心一言, name_en: ERNIE Bot, category: ai, domains: [yiyan.baidu.com]}
|
||||
- {id: zhipu, name: 智谱清言, name_en: Zhipu GLM, category: ai, domains: [chatglm.cn, zhipuai.cn, bigmodel.cn]}
|
||||
- {id: cn_ai, name: 其他国产大模型, name_en: Other Chinese AI, category: ai,
|
||||
domains: [minimaxi.com, hailuoai.com, xinghuo.xfyun.cn, tiangong.cn, baichuan-ai.com, stepfun.com]}
|
||||
- {id: chaoxing, name: 学习通/雨课堂, name_en: Chaoxing/Yuketang, category: education, domains: [chaoxing.com, yuketang.cn]}
|
||||
- {id: icourse, name: 中国大学MOOC, name_en: iCourse MOOC, category: education, domains: [icourse163.org]}
|
||||
- {id: k12, name: 作业帮/猿辅导/学而思, name_en: K12 tutoring, category: education, domains: [zuoyebang.com, zybang.com, yuanfudao.com, fenbi.com, xueersi.com, xueersi.cn]}
|
||||
- {id: youdao, name: 网易有道, name_en: Youdao, category: education, domains: [youdao.com, ydstatic.com]}
|
||||
- {id: cnki, name: 知网/学术, name_en: CNKI & papers, category: education, domains: [cnki.net, wanfangdata.com.cn, cqvip.com, arxiv.org, sciencedirect.com, springer.com, ieee.org]}
|
||||
- {id: coursera, name: Coursera/Duolingo, name_en: Coursera/Duolingo, category: education, domains: [coursera.org, duolingo.com, khanacademy.org, udemy.com]}
|
||||
|
||||
# ---------- 广告统计 ----------
|
||||
- {id: ads, name: 广告与统计, name_en: Ads & analytics, category: ads,
|
||||
domains: [doubleclick.net, googleadservices.com, adnxs.com, umeng.com, umengcloud.com, cnzz.com, growingio.com, talkingdata.com, gdt.qq.com, e.qq.com, pangolin-sdk-toutiao.com, pglstatp-toutiao.com, ads.tiktok.com, adkwai.com, app-measurement.com, crashlytics.com, sentry.io, bugly.qq.com, appsflyer.com, adjust.com, branch.io, mixpanel.com, segment.io, hotjar.com, miaozhen.com, admaster.com.cn, scorecardresearch.com]}
|
||||
# ================= 购物生活 =================
|
||||
- {id: taobao, name: 淘宝/天猫, name_en: Taobao/Tmall, category: shopping,
|
||||
domains: [taobao.com, tmall.com, alicdn.com, tbcdn.cn, taobaocdn.com, mmstat.com, tanx.com, alibaba.com, aliexpress.com, 1688.com, alipayobjects.com, goofish.com]}
|
||||
- {id: jd, name: 京东, name_en: JD, category: shopping,
|
||||
domains: [jd.com, 360buyimg.com, jd.hk, jdpay.com, jcloud.com, jdcloud.com, 3.cn]}
|
||||
- {id: pinduoduo, name: 拼多多, name_en: Pinduoduo, category: shopping,
|
||||
domains: [pinduoduo.com, yangkeduo.com, pddpic.com, pddugc.com, temu.com, kwcdn.com]}
|
||||
- {id: vipshop, name: 唯品会, name_en: Vipshop, category: shopping, domains: [vip.com, vipstatic.com, vipglobal.hk]}
|
||||
- {id: dewu, name: 得物, name_en: Dewu, category: shopping, domains: [dewu.com, poizon.com, dewucdn.com]}
|
||||
- {id: suning, name: 苏宁/当当, name_en: Suning/Dangdang, category: shopping, domains: [suning.com, suning.cn, dangdang.com, ddimg.cn]}
|
||||
- {id: smzdm, name: 什么值得买, name_en: SMZDM, category: shopping, domains: [smzdm.com, zdmimg.com]}
|
||||
- {id: amazon, name: Amazon, name_en: Amazon, category: shopping,
|
||||
domains: [amazon.com, amazon.cn, media-amazon.com, ssl-images-amazon.com, amazon.co.jp]}
|
||||
- {id: ebay, name: eBay/SHEIN, name_en: eBay/SHEIN, category: shopping, domains: [ebay.com, ebaystatic.com, ebayimg.com, shein.com, ltwebstatic.com]}
|
||||
- {id: meituan, name: 美团/大众点评, name_en: Meituan/Dianping, category: life,
|
||||
domains: [meituan.com, meituan.net, dianping.com, dpfile.com, maoyan.com, sankuai.com]}
|
||||
- {id: eleme, name: 饿了么, name_en: Ele.me, category: life,
|
||||
domains: [ele.me, elemecdn.com, eleme.cn]}
|
||||
- {id: city58, name: 58同城/赶集, name_en: 58.com, category: life, domains: [58.com, 58cdn.com.cn, ganji.com]}
|
||||
- {id: house, name: 贝壳/链家/安居客, name_en: Real estate, category: life, domains: [ke.com, lianjia.com, anjuke.com, ljcdn.com]}
|
||||
- {id: jobs, name: 招聘求职, name_en: Jobs, category: life, domains: [zhipin.com, zhaopin.com, 51job.com, liepin.com]}
|
||||
- {id: amap, name: 高德地图, name_en: Amap, category: travel,
|
||||
domains: [amap.com, autonavi.com, gaode.com]}
|
||||
- {id: baidu_map, name: 百度地图, name_en: Baidu Maps, category: travel, domains: [map.baidu.com, api.map.baidu.com, map.bdimg.com]}
|
||||
- {id: tencent_map, name: 腾讯地图, name_en: Tencent Maps, category: travel, domains: [map.qq.com, apis.map.qq.com]}
|
||||
- {id: google_maps, name: Google Maps, name_en: Google Maps, category: travel, domains: [maps.google.com, maps.googleapis.com, maps.gstatic.com]}
|
||||
- {id: didi, name: 滴滴出行, name_en: DiDi, category: travel,
|
||||
domains: [didiglobal.com, diditaxi.com.cn, didistatic.com, xiaojukeji.com]}
|
||||
- {id: ctrip, name: 携程, name_en: Trip.com, category: travel,
|
||||
domains: [ctrip.com, trip.com, c-ctrip.com, ctripcorp.com, tripcdn.com]}
|
||||
- {id: rail12306, name: 铁路12306, name_en: China Railway 12306, category: travel, domains: [12306.cn]}
|
||||
- {id: fliggy, name: 飞猪/去哪儿/同程, name_en: Fliggy/Qunar/LY, category: travel, domains: [fliggy.com, alitrip.com, qunar.com, qunarzz.com, ly.com, 17u.cn, umetrip.com]}
|
||||
- {id: hellobike, name: 哈啰/Uber, name_en: Hellobike/Uber, category: travel, domains: [hellobike.com, uber.com]}
|
||||
- {id: alipay, name: 支付宝, name_en: Alipay, category: finance,
|
||||
domains: [alipay.com, alipay.cn, alipay-eco.com, antgroup.com, alipaydns.com, mybank.cn]}
|
||||
- {id: unionpay, name: 云闪付/银联, name_en: UnionPay, category: finance, domains: [95516.com, unionpay.com, unionpayintl.com]}
|
||||
- {id: paypal, name: PayPal, name_en: PayPal, category: finance, domains: [paypal.com, paypalobjects.com, paypal.cn]}
|
||||
- {id: cmb, name: 招商银行, name_en: China Merchants Bank, category: bank, domains: [cmbchina.com, cmbimg.com, cmbchina.cn]}
|
||||
- {id: icbc, name: 工商银行, name_en: ICBC, category: bank, domains: [icbc.com.cn, icbc.com]}
|
||||
- {id: ccb, name: 建设银行, name_en: CCB, category: bank, domains: [ccb.com, ccb.cn]}
|
||||
- {id: abc_bank, name: 农业银行, name_en: ABC, category: bank, domains: [abchina.com, abchina.com.cn]}
|
||||
- {id: boc, name: 中国银行, name_en: Bank of China, category: bank, domains: [boc.cn, bankofchina.com]}
|
||||
- {id: bocom, name: 交通/邮储银行, name_en: BoCom/PSBC, category: bank, domains: [bankcomm.com, psbc.com]}
|
||||
- {id: other_bank, name: 其他银行, name_en: Other banks, category: bank,
|
||||
domains: [pingan.com, pingan.com.cn, spdb.com.cn, citicbank.com, cib.com.cn, cmbc.com.cn, webank.com, cebbank.com, hxb.com.cn, cgbchina.com.cn]}
|
||||
- {id: ths, name: 同花顺, name_en: 10jqka, category: stock, domains: [10jqka.com.cn, hexin.cn, 10jqka.com]}
|
||||
- {id: eastmoney, name: 东方财富/天天基金, name_en: East Money, category: stock, domains: [eastmoney.com, dfcfw.com, 1234567.com.cn]}
|
||||
- {id: xueqiu, name: 雪球, name_en: Xueqiu, category: stock, domains: [xueqiu.com, xqdoc.imedao.com, imedao.com]}
|
||||
- {id: futu, name: 富途/老虎, name_en: Futu/Tiger, category: stock, domains: [futunn.com, futuhk.com, futu5.com, futuholdings.com, itiger.com, tigerbrokers.com]}
|
||||
- {id: securities, name: 券商行情, name_en: Brokers, category: stock, domains: [htsc.com.cn, tdx.com.cn, gw.com.cn, gtja.com, citics.com, cmschina.com]}
|
||||
|
||||
# ---------- 智能家居 ----------
|
||||
# ================= 资讯阅读 =================
|
||||
- {id: toutiao, name: 今日头条, name_en: Toutiao, category: news,
|
||||
domains: [toutiao.com, toutiaocdn.com, toutiaoimg.cn]}
|
||||
- {id: tencent_news, name: 腾讯新闻, name_en: Tencent News, category: news, domains: [news.qq.com, inews.qq.com, new.qq.com]}
|
||||
- {id: netease, name: 网易, name_en: NetEase, category: news,
|
||||
domains: [163.com, 126.net, 127.net, netease.com]}
|
||||
- {id: sina, name: 新浪, name_en: Sina, category: news, domains: [sina.com.cn, sina.cn, sina.com]}
|
||||
- {id: sohu, name: 搜狐, name_en: Sohu, category: news, domains: [sohu.com, sohucs.com]}
|
||||
- {id: ifeng, name: 凤凰网, name_en: iFeng, category: news, domains: [ifeng.com, ifengimg.com]}
|
||||
- {id: cn_media, name: 官方媒体, name_en: State media, category: news, domains: [thepaper.cn, people.com.cn, xinhuanet.com, news.cn, chinanews.com.cn, chinadaily.com.cn]}
|
||||
- {id: tech_news, name: 科技资讯, name_en: Tech news, category: news, domains: [ithome.com, 36kr.com, huxiu.com, sspai.com, geekpark.net]}
|
||||
- {id: intl_news, name: 国际媒体, name_en: International news, category: news, domains: [bbc.com, bbc.co.uk, bbci.co.uk, nytimes.com, nyt.com, cnn.com, reuters.com, theguardian.com, bloomberg.com]}
|
||||
- {id: wikipedia, name: Wikipedia, name_en: Wikipedia, category: news,
|
||||
domains: [wikipedia.org, wikimedia.org, wiki.wikipedia.org]}
|
||||
- {id: baidu, name: 百度, name_en: Baidu, category: search,
|
||||
domains: [baidu.com, bdstatic.com, bdimg.com, bcebos.com, bdydns.com, baidubce.com, baiduyun.com, shifen.com]}
|
||||
- {id: google, name: Google, name_en: Google, category: search,
|
||||
domains: [google.com, googleapis.com, gstatic.com, google.com.hk, googleusercontent.com, ggpht.com, gvt2.com, recaptcha.net]}
|
||||
- {id: bing, name: Bing, name_en: Bing, category: search,
|
||||
domains: [bing.com, bing.net]}
|
||||
- {id: sogou, name: 搜狗/360搜索, name_en: Sogou/360 Search, category: search, domains: [sogou.com, sogoucdn.com, so.com]}
|
||||
- {id: duckduckgo, name: DuckDuckGo/Yandex, name_en: DuckDuckGo/Yandex, category: search, domains: [duckduckgo.com, yandex.ru, yandex.net, yandex.com]}
|
||||
- {id: weread, name: 微信读书, name_en: WeRead, category: reading, domains: [weread.qq.com]}
|
||||
- {id: yuewen, name: 起点/阅文, name_en: Qidian/Yuewen, category: reading, domains: [qidian.com, yuewen.com, qdmm.com, hongxiu.com]}
|
||||
- {id: fanqie, name: 番茄/七猫小说, name_en: Fanqie/Qimao, category: reading, domains: [fanqienovel.com, qimao.com, wtzw.com]}
|
||||
- {id: zhangyue, name: 掌阅/晋江, name_en: iReader/JJWXC, category: reading, domains: [zhangyue.com, ireader.com, jjwxc.net]}
|
||||
- {id: dedao, name: 得到/樊登, name_en: Dedao, category: reading, domains: [dedao.cn, igetget.com, dushu365.com]}
|
||||
- {id: comics, name: 漫画, name_en: Comics, category: reading, domains: [kuaikanmanhua.com, manga.bilibili.com, ac.qq.com, pixiv.net, pximg.net]}
|
||||
|
||||
# ================= 智能设备 =================
|
||||
- {id: mijia, name: 米家, name_en: Mi Home, category: iot,
|
||||
domains: [io.mi.com, iot.mi.com, miot-spec.org, mijia.tech, xiaomiyoupin.com, mi-iot.com]}
|
||||
- {id: tuya, name: 涂鸦智能, name_en: Tuya, category: iot,
|
||||
domains: [tuyacn.com, tuyaus.com, tuyaeu.com, tuya.com, tuyacloud.com]}
|
||||
- {id: homeassistant, name: Home Assistant, name_en: Home Assistant, category: iot,
|
||||
domains: [home-assistant.io, nabucasa.com, ui.nabu.casa]}
|
||||
- {id: ezviz, name: 萤石/摄像头, name_en: Ezviz/Cameras, category: iot,
|
||||
domains: [ezvizlife.com, ys7.com, hik-connect.com, hikvision.com, imoulife.com, dahuatech.com]}
|
||||
- {id: smart_speaker, name: 智能音箱, name_en: Smart speakers, category: iot,
|
||||
domains: [aligenie.com, xiaodu.baidu.com, dueros.baidu.com, amazonalexa.com, alexa.amazon.com]}
|
||||
- {id: appliances, name: 家电云(美的/海尔/格力), name_en: Appliance clouds, category: iot,
|
||||
domains: [midea.com, msmartlife.cn, smartmidea.net, haier.com, haier.net, gree.com, coolkit.cc, ewelink.cc]}
|
||||
- {id: intl_iot, name: Hue/SmartThings/Nest, name_en: Hue/SmartThings/Nest, category: iot,
|
||||
domains: [meethue.com, smartthings.com, nest.com, home.google.com]}
|
||||
- {id: mqtt, name: MQTT, name_en: MQTT, category: iot, ports: [tcp/1883, tcp/8883]}
|
||||
- {id: ezviz, name: 萤石/海康, name_en: Ezviz/Hikvision, category: camera,
|
||||
domains: [ezvizlife.com, ys7.com, hik-connect.com, hikvision.com]}
|
||||
- {id: imou, name: 乐橙/大华, name_en: Imou/Dahua, category: camera, domains: [imoulife.com, lechange.cn, dahuatech.com, easy4ip.com]}
|
||||
- {id: tplink_cloud, name: TP-Link 云, name_en: TP-Link cloud, category: camera, domains: [tplinkcloud.com, tplinkcloud.com.cn, tp-link.com, tp-link.com.cn]}
|
||||
- {id: rtsp, name: RTSP 视频流, name_en: RTSP, category: camera, ports: [tcp/554]}
|
||||
|
||||
# ---------- 网络协议(按端口兜底) ----------
|
||||
# ================= 网络基础 =================
|
||||
- {id: apple, name: Apple 服务, name_en: Apple services, category: system,
|
||||
domains: [apple.com, mzstatic.com, itunes.apple.com, apple.com.cn, push.apple.com, aaplimg.com, apple-dns.net, apple.news]}
|
||||
- {id: android_update, name: 安卓/手机厂商服务, name_en: Android vendor services, category: system,
|
||||
domains: [xiaomi.com, xiaomi.net, mi.com, miui.com, mi-img.com, hicloud.com, huawei.com, dbankcdn.com, vivo.com.cn, vivoglobal.com, oppo.com, heytapmobi.com, coloros.com, heytapdownload.com, oneplus.com, honor.com, hihonor.com, meizu.com, flyme.cn]}
|
||||
- {id: connectivity, name: 联网检测, name_en: Connectivity checks, category: system,
|
||||
domains: [connectivitycheck.gstatic.com, captive.apple.com, connect.rom.miui.com, connectivitycheck.platform.hicloud.com, www.msftncsi.com]}
|
||||
- {id: dns, name: DNS, name_en: DNS, category: network, ports: [udp/53, tcp/53, tcp/853, udp/853]}
|
||||
- {id: doh, name: 加密 DNS (DoH), name_en: Encrypted DNS (DoH), category: network,
|
||||
domains: [dns.google, cloudflare-dns.com, dns.alidns.com, doh.pub, dot.pub, dns.quad9.net, dns.adguard-dns.com, doh.360.cn, dns.nextdns.io]}
|
||||
- {id: ntp, name: NTP, name_en: NTP, category: network, ports: [udp/123]}
|
||||
- {id: vpn, name: VPN 隧道, name_en: VPN, category: network,
|
||||
ports: [udp/51820, udp/1194, tcp/1194, udp/500, udp/4500, tcp/1723, udp/1701]}
|
||||
- {id: ssh, name: SSH/远程, name_en: SSH/remote, category: network, ports: [tcp/22, tcp/3389, udp/3389, tcp/5900]}
|
||||
- {id: mail, name: 邮件, name_en: Mail, category: work, ports: [tcp/25, tcp/465, tcp/587, tcp/993, tcp/995, tcp/143, tcp/110]}
|
||||
- {id: stun, name: 音视频通话(STUN/RTP), name_en: Real-time media, category: im, ports: [udp/3478-3481, udp/19302-19309]}
|
||||
- {id: ftp, name: FTP, name_en: FTP, category: network, ports: [tcp/21, tcp/990]}
|
||||
- {id: icmp, name: ICMP (Ping), name_en: ICMP, category: network, protos: [1, 58]}
|
||||
- {id: vpn, name: VPN 隧道, name_en: VPN, category: proxy,
|
||||
ports: [udp/51820, udp/1194, tcp/1194, udp/500, udp/4500, tcp/1723, udp/1701], protos: [47, 50]}
|
||||
- {id: tailscale, name: Tailscale/ZeroTier, name_en: Tailscale/ZeroTier, category: proxy,
|
||||
domains: [tailscale.com, tailscale.io, ts.net, zerotier.com], ports: [udp/41641, udp/9993]}
|
||||
- {id: warp, name: Cloudflare WARP, name_en: Cloudflare WARP, category: proxy, domains: [cloudflareclient.com, warp.plus]}
|
||||
- {id: cloudflare, name: Cloudflare, name_en: Cloudflare, category: cdn,
|
||||
domains: [cloudflare.com, cloudflareinsights.com, cloudflare.net, workers.dev, pages.dev, cdnjs.cloudflare.com]}
|
||||
- {id: akamai, name: Akamai, name_en: Akamai, category: cdn,
|
||||
domains: [akamai.net, akamaiedge.net, akamaihd.net, akamaized.net, edgekey.net, edgesuite.net, akadns.net, akamaitechnologies.com]}
|
||||
- {id: aliyun, name: 阿里云/CDN, name_en: Alibaba Cloud, category: cdn,
|
||||
domains: [aliyun.com, aliyuncs.com, alikunlun.com, alikunlun.net, kunlunca.com, kunlunsl.com, kunlungr.com, kunlunle.com, alicloudccp.com, aliyundrive.cloud]}
|
||||
- {id: tencent_cloud, name: 腾讯云/CDN, name_en: Tencent Cloud, category: cdn,
|
||||
domains: [myqcloud.com, qcloud.com, tencent-cloud.net, tencentcs.com, tencentcloud.com, cdntip.com, tdnsv5.com, tdnsv6.com, dnsv1.com, qcloudcdn.com, qcloudcdn.cn]}
|
||||
- {id: wangsu, name: 网宿 CDN, name_en: Wangsu CDN, category: cdn,
|
||||
domains: [wscdns.com, wsglb0.com, wsdvs.com, lxdns.com, chinanetcenter.com, wswebcdn.com, cdn20.com]}
|
||||
- {id: huawei_cloud, name: 华为云/CDN, name_en: Huawei Cloud, category: cdn, domains: [huaweicloud.com, myhuaweicloud.com, hwcdn.net, cdnhwc1.com]}
|
||||
- {id: volcengine, name: 火山引擎/CDN, name_en: Volcengine, category: cdn, domains: [volcengine.com, volccdn.com, bytegslb.com]}
|
||||
- {id: baishan, name: 白山云/其他国内CDN, name_en: Baishan & other CN CDN, category: cdn, domains: [bsgslb.cn, trpcdn.net, bsclink.cn, ccgslb.com, ccgslb.net, chinacache.net, qiniucdn.com, qiniu.com, clouddn.com, upaiyun.com]}
|
||||
- {id: aws, name: AWS/CloudFront, name_en: AWS/CloudFront, category: cdn, domains: [amazonaws.com, cloudfront.net, awsstatic.com, aws.amazon.com]}
|
||||
- {id: fastly, name: Fastly/jsDelivr, name_en: Fastly/jsDelivr, category: cdn, domains: [fastly.net, fastlylb.net, fastly.com, jsdelivr.net]}
|
||||
- {id: azure, name: Azure, name_en: Azure, category: cdn, domains: [azurefd.net, azure.com, trafficmanager.net, azurewebsites.net]}
|
||||
- {id: security_360, name: 360 安全, name_en: 360 Security, category: security, domains: [360.cn, 360.com, qhimg.com, qihucdn.com, 360safe.com, qhres.com]}
|
||||
- {id: tencent_pcmgr, name: 腾讯电脑管家, name_en: Tencent PC Manager, category: security, domains: [guanjia.qq.com, pcmgr.qq.com]}
|
||||
- {id: huorong, name: 火绒, name_en: Huorong, category: security, domains: [huorong.cn]}
|
||||
- {id: antivirus, name: 卡巴斯基/ESET/Avast, name_en: Antivirus, category: security,
|
||||
domains: [kaspersky.com, kaspersky-labs.com, eset.com, avast.com, avcdn.net, mcafee.com, norton.com, bitdefender.com]}
|
||||
- {id: ads, name: 广告与统计, name_en: Ads & analytics, category: ads,
|
||||
domains: [doubleclick.net, googleadservices.com, googlesyndication.com, google-analytics.com, googletagmanager.com, adnxs.com, umeng.com, umengcloud.com, cnzz.com, growingio.com, talkingdata.com, gdt.qq.com, e.qq.com, pangolin-sdk-toutiao.com, pglstatp-toutiao.com, ads.tiktok.com, adkwai.com, app-measurement.com, crashlytics.com, sentry.io, bugly.qq.com, appsflyer.com, adjust.com, branch.io, mixpanel.com, segment.io, hotjar.com, miaozhen.com, admaster.com.cn, scorecardresearch.com]}
|
||||
- {id: https, name: HTTPS 网页, name_en: HTTPS, category: web, ports: [tcp/443, tcp/8443]}
|
||||
- {id: quic, name: QUIC 网页, name_en: QUIC, category: web, ports: [udp/443]}
|
||||
- {id: http, name: HTTP 网页, name_en: HTTP, category: web, ports: [tcp/80, tcp/8080]}
|
||||
@@ -10,7 +10,8 @@ QTYPE_AAAA = 28
|
||||
|
||||
@dataclass
|
||||
class DnsAnswer:
|
||||
client: str # 收到响应的终端(UDP 目的地址)
|
||||
client: str # 收到响应的一方(UDP 目的地址)
|
||||
server: str # 发出响应的 DNS 服务器(UDP 源地址)
|
||||
qname: str # 终端查询的名字
|
||||
ips: list[tuple[str, int]] = field(default_factory=list) # (ip, ttl)
|
||||
cnames: list[str] = field(default_factory=list)
|
||||
@@ -142,7 +143,7 @@ def parse_tzsp_dns(data: bytes) -> DnsAnswer | None:
|
||||
udp = ethernet_udp(frame)
|
||||
if udp is None:
|
||||
return None
|
||||
_src, dst, sport, _dport, payload = udp
|
||||
src, dst, sport, _dport, payload = udp
|
||||
if sport != 53:
|
||||
return None
|
||||
try:
|
||||
@@ -152,4 +153,4 @@ def parse_tzsp_dns(data: bytes) -> DnsAnswer | None:
|
||||
if parsed is None:
|
||||
return None
|
||||
qname, ips, cnames = parsed
|
||||
return DnsAnswer(client=dst, qname=qname, ips=ips, cnames=cnames)
|
||||
return DnsAnswer(client=dst, server=src, qname=qname, ips=ips, cnames=cnames)
|
||||
@@ -11,7 +11,7 @@ from app.classify import GENERIC_APPS, Classifier
|
||||
from app.collector.ipfix import FlowDecoder
|
||||
from app.collector.tzsp import parse_tzsp_dns
|
||||
from app.config import get_settings
|
||||
from app import database
|
||||
from app import database, hostnames
|
||||
from app.database import upsert_add
|
||||
from app.dnsmap import DnsMap
|
||||
from app.models import (CustomRule, DnsCache, Host, HostDomainDay, Setting, TrafficDay, TrafficHour,
|
||||
@@ -57,6 +57,7 @@ class Engine:
|
||||
self.total_rate = (0.0, 0.0)
|
||||
self.stats = {"flows": 0, "flows_dropped": 0, "dns_packets": 0, "dns_answers": 0,
|
||||
"bytes_seen": 0, "last_flow": 0, "last_dns": 0, "exporters": {}}
|
||||
self.dns_servers: dict[str, list[int]] = {} # 应答来源 DNS 服务器 -> [应答数, 最近时间]
|
||||
|
||||
# ---------- 配置 ----------
|
||||
def set_lan(self, text: str):
|
||||
@@ -108,9 +109,18 @@ class Engine:
|
||||
ans = parse_tzsp_dns(data)
|
||||
if ans is None:
|
||||
return
|
||||
now = int(time.time())
|
||||
self.stats["dns_answers"] += 1
|
||||
self.stats["last_dns"] = int(time.time())
|
||||
self.stats["last_dns"] = now
|
||||
self.dns.add(ans.qname, ans.ips, ans.cnames)
|
||||
# 记录应答来自哪台 DNS 服务器,方便在系统设置里确认嗅探到的是哪一段(终端↔DNS 还是 DNS↔上游)
|
||||
srv = self.dns_servers.get(ans.server)
|
||||
if srv is None:
|
||||
if len(self.dns_servers) >= 64:
|
||||
del self.dns_servers[min(self.dns_servers, key=lambda k: self.dns_servers[k][1])]
|
||||
srv = self.dns_servers[ans.server] = [0, 0]
|
||||
srv[0] += 1
|
||||
srv[1] = now
|
||||
|
||||
def host_key(self, ip: str, mac: str | None) -> tuple[str, str | None]:
|
||||
m = self.ip2mac.get(ip)
|
||||
@@ -234,6 +244,7 @@ class Engine:
|
||||
rows = db.execute(select(DnsCache.ip, DnsCache.name, DnsCache.cname, DnsCache.expires)
|
||||
.where(DnsCache.expires > now)).all()
|
||||
lan = db.get(Setting, "lan_cidrs")
|
||||
hostnames.migrate_aliases(db)
|
||||
self.dns.load(rows)
|
||||
if lan and lan.value:
|
||||
self.set_lan(lan.value)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""终端命名:用户按 MAC 或 IP 给终端起名。
|
||||
|
||||
- 按 MAC:设备换了 IP 名字也跟着走,适合大多数设备。
|
||||
- 按 IP:适合固定 IP、但 MAC 会变的设备(手机的随机 MAC),或拿不到 MAC 的终端。
|
||||
"""
|
||||
import ipaddress
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Host, HostName
|
||||
|
||||
_MAC_RE = re.compile(r"^[0-9A-F]{12}$")
|
||||
|
||||
|
||||
def norm_mac(s: str) -> str | None:
|
||||
"""AA-BB-CC-DD-EE-FF / aabb.ccdd.eeff / aabbccddeeff → AA:BB:CC:DD:EE:FF"""
|
||||
h = re.sub(r"[:\-.\s]", "", s or "").upper()
|
||||
if not _MAC_RE.match(h):
|
||||
return None
|
||||
return ":".join(h[i:i + 2] for i in range(0, 12, 2))
|
||||
|
||||
|
||||
def norm_ip(s: str) -> str | None:
|
||||
try:
|
||||
return str(ipaddress.ip_address((s or "").strip()))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def normalize(kind: str, value: str) -> str | None:
|
||||
return norm_mac(value) if kind == "mac" else norm_ip(value) if kind == "ip" else None
|
||||
|
||||
|
||||
def load(db) -> dict[tuple[str, str], str]:
|
||||
return {(n.kind, n.value): n.name for n in db.scalars(select(HostName))}
|
||||
|
||||
|
||||
def resolve(names: dict, mac: str | None, ip: str | None) -> tuple[str | None, str | None]:
|
||||
"""返回 (名字, 命中方式 mac|ip)。"""
|
||||
if mac and (n := names.get(("mac", mac))):
|
||||
return n, "mac"
|
||||
if ip and (n := names.get(("ip", ip))):
|
||||
return n, "ip"
|
||||
return None, None
|
||||
|
||||
|
||||
def put(db, kind: str, value: str, name: str | None):
|
||||
"""name 为空表示删除这条命名。value 需已规范化。"""
|
||||
row = db.get(HostName, (kind, value))
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
return
|
||||
if row is None:
|
||||
db.add(HostName(kind=kind, value=value, name=name, updated=int(time.time())))
|
||||
else:
|
||||
row.name, row.updated = name, int(time.time())
|
||||
|
||||
|
||||
def migrate_aliases(db):
|
||||
"""把旧版 hosts.alias 搬到 host_names(有 MAC 按 MAC,否则按 IP),只做一次。"""
|
||||
hosts = db.scalars(select(Host).where(Host.alias.is_not(None))).all()
|
||||
if not hosts:
|
||||
return
|
||||
names = load(db)
|
||||
for h in hosts:
|
||||
kind, value = ("mac", h.mac) if h.mac else ("ip", h.ip)
|
||||
if value and (kind, value) not in names and h.alias.strip():
|
||||
put(db, kind, value, h.alias)
|
||||
names[(kind, value)] = h.alias
|
||||
h.alias = None
|
||||
db.commit()
|
||||
@@ -58,6 +58,18 @@ class Host(Base):
|
||||
last_seen = Column(BigInteger)
|
||||
|
||||
|
||||
class HostName(Base):
|
||||
"""终端命名:按 MAC(跟着设备走)或按 IP(跟着地址走)。同时命中时 MAC 优先。
|
||||
|
||||
旧版本把备注名存在 hosts.alias(按终端 key),启动时会迁移到这里。
|
||||
"""
|
||||
__tablename__ = "host_names"
|
||||
kind = Column(String(8), primary_key=True) # mac | ip
|
||||
value = Column(String(45), primary_key=True) # 大写冒号格式的 MAC,或规范化后的 IP
|
||||
name = Column(String(128), nullable=False)
|
||||
updated = Column(BigInteger)
|
||||
|
||||
|
||||
class DnsCache(Base):
|
||||
__tablename__ = "dns_cache"
|
||||
ip = Column(String(45), primary_key=True)
|
||||
|
||||
@@ -27,6 +27,7 @@ _APPID_RE = re.compile(r"^[a-z0-9_]{1,48}$")
|
||||
def meta():
|
||||
c = engine.classifier
|
||||
return {
|
||||
"groups": [{"id": k, **v} for k, v in c.groups.items()],
|
||||
"categories": [{"id": k, **v} for k, v in c.categories.items()],
|
||||
"apps": sorted(({"id": a.id, "name": a.name, "name_en": a.name_en, "category": a.category,
|
||||
"custom": a.custom} for a in c.apps.values()), key=lambda a: a["id"]),
|
||||
@@ -184,7 +185,10 @@ def system():
|
||||
"flow_ok": bool(st["last_flow"] and now - st["last_flow"] < 180),
|
||||
},
|
||||
"dns": {"packets": st["dns_packets"], "answers": st["dns_answers"], "last": st["last_dns"],
|
||||
"entries": len(engine.dns), "ok": bool(st["last_dns"] and now - st["last_dns"] < 600)},
|
||||
"entries": len(engine.dns), "ok": bool(st["last_dns"] and now - st["last_dns"] < 600),
|
||||
"servers": sorted(({"ip": ip, "answers": n, "last": ts, "lan": engine.is_lan(ip)}
|
||||
for ip, (n, ts) in list(engine.dns_servers.items())),
|
||||
key=lambda x: x["answers"], reverse=True)[:8]},
|
||||
"router": poller.status(),
|
||||
"generic_apps": sorted(GENERIC_APPS),
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import stats, timeutil
|
||||
from app import hostnames, stats, timeutil
|
||||
from app.database import get_db
|
||||
from app.engine import engine
|
||||
from app.models import Host
|
||||
from app.models import Host, HostName
|
||||
from app.ros_poller import poller
|
||||
from app.security import current_user
|
||||
|
||||
@@ -28,9 +28,8 @@ def _trend_gran(rng: str) -> str:
|
||||
def overview(range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)):
|
||||
start, end = _bounds(range)
|
||||
apps = stats.top_apps(db, start, end)
|
||||
cats = stats.top_categories(apps)
|
||||
now = int(time.time())
|
||||
known = sum(a["total"] for a in apps if a["category"] not in ("unknown", "web", "network"))
|
||||
all_bytes = sum(a["total"] for a in apps) or 1
|
||||
return {
|
||||
"totals": stats.totals3(db),
|
||||
"range_total": stats.total(db, start, end),
|
||||
@@ -38,11 +37,14 @@ def overview(range: str = Query("today", pattern=RANGES), db: Session = Depends(
|
||||
"wan_up": poller.wan_rate[0], "wan_down": poller.wan_rate[1]},
|
||||
"online": stats.online_count(db),
|
||||
"hosts_total": db.query(Host).count(),
|
||||
"identified_ratio": known / all_bytes,
|
||||
"identified_ratio": stats.identified_ratio(apps),
|
||||
"apps_count": len(apps),
|
||||
"top_apps": apps[:10],
|
||||
"categories": stats.top_categories(apps),
|
||||
"categories": cats,
|
||||
"groups": stats.top_groups(cats),
|
||||
"top_hosts": stats.top_hosts(db, start, end, limit=10),
|
||||
"trend": stats.trend(db, _trend_gran(range), start, end),
|
||||
"trend_groups": stats.trend_groups(db, _trend_gran(range), start, end),
|
||||
"realtime": stats.trend(db, "minute", now - 3600, now + 1),
|
||||
"router": poller.status(),
|
||||
}
|
||||
@@ -71,14 +73,17 @@ def host_detail(key: str, range: str = Query("today", pattern=RANGES), db: Sessi
|
||||
if meta["first_seen"] is None and stats.total(db, *_bounds("month"), host=key)["total"] == 0:
|
||||
raise HTTPException(404, "终端不存在")
|
||||
apps = stats.top_apps(db, start, end, host=key)
|
||||
cats = stats.top_categories(apps)
|
||||
lease = poller.leases.get(meta["mac"] or "") or {}
|
||||
return {
|
||||
**meta,
|
||||
"totals": stats.totals3(db, host=key),
|
||||
"range_total": stats.total(db, start, end, host=key),
|
||||
"apps": apps,
|
||||
"categories": stats.top_categories(apps),
|
||||
"categories": cats,
|
||||
"groups": stats.top_groups(cats),
|
||||
"trend": stats.trend(db, _trend_gran(range), start, end, host=key),
|
||||
"trend_groups": stats.trend_groups(db, _trend_gran(range), start, end, host=key),
|
||||
"domains": stats.host_domains(db, key, start, end),
|
||||
"dhcp": {"server": lease.get("server"), "status": lease.get("status"),
|
||||
"expires_after": lease.get("expires-after")} if lease else None,
|
||||
@@ -87,23 +92,85 @@ def host_detail(key: str, range: str = Query("today", pattern=RANGES), db: Sessi
|
||||
|
||||
class HostPatch(BaseModel):
|
||||
alias: str | None = Field(None, max_length=128)
|
||||
by: str | None = Field(None, pattern="^(mac|ip)$") # 留空:有 MAC 按 MAC,否则按 IP
|
||||
|
||||
|
||||
@router.patch("/hosts/{key:path}")
|
||||
def host_patch(key: str, req: HostPatch, db: Session = Depends(get_db)):
|
||||
"""给终端改名。按 MAC 命名跟着设备走;按 IP 命名跟着地址走。"""
|
||||
h = db.get(Host, key)
|
||||
if h is None:
|
||||
raise HTTPException(404, "终端不存在")
|
||||
h.alias = (req.alias or "").strip() or None
|
||||
by = req.by or ("mac" if h.mac else "ip")
|
||||
value = h.mac if by == "mac" else h.ip
|
||||
if not value:
|
||||
raise HTTPException(400, "这台终端没有 MAC 地址,只能按 IP 命名" if by == "mac" else "这台终端没有 IP 地址")
|
||||
# 换了命名方式时,删掉另一种方式下这台终端的旧名字,避免两处都有
|
||||
other = ("ip", h.ip) if by == "mac" else ("mac", h.mac)
|
||||
if other[1]:
|
||||
hostnames.put(db, other[0], other[1], None)
|
||||
hostnames.put(db, by, value, req.alias)
|
||||
h.alias = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
return {"ok": True, **stats.host_meta(db, [key])[key]}
|
||||
|
||||
|
||||
# ---------- 终端命名表(按 MAC / IP) ----------
|
||||
class NameIn(BaseModel):
|
||||
kind: str = Field(pattern="^(mac|ip)$")
|
||||
value: str = Field(min_length=1, max_length=45)
|
||||
name: str = Field("", max_length=128)
|
||||
|
||||
|
||||
def _name_rows(db):
|
||||
hosts = db.query(Host).all()
|
||||
by_mac = {h.mac: h for h in hosts if h.mac}
|
||||
by_ip: dict[str, Host] = {}
|
||||
for h in sorted(hosts, key=lambda x: x.last_seen or 0):
|
||||
if h.ip:
|
||||
by_ip[h.ip] = h # 同一 IP 用过多台设备时取最近的
|
||||
out = []
|
||||
for n in db.query(HostName).order_by(HostName.kind, HostName.value):
|
||||
h = by_mac.get(n.value) if n.kind == "mac" else by_ip.get(n.value)
|
||||
out.append({"kind": n.kind, "value": n.value, "name": n.name, "updated": n.updated,
|
||||
"host": {"key": h.key, "ip": h.ip, "mac": h.mac, "hostname": h.hostname} if h else None})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/names")
|
||||
def list_names(db: Session = Depends(get_db)):
|
||||
return _name_rows(db)
|
||||
|
||||
|
||||
@router.put("/names")
|
||||
def put_name(req: NameIn, db: Session = Depends(get_db)):
|
||||
"""新增或修改一条命名;name 为空表示删除。可以给还没出现过的设备提前命名。"""
|
||||
value = hostnames.normalize(req.kind, req.value)
|
||||
if value is None:
|
||||
raise HTTPException(400, "MAC 地址格式不正确,例如 AA:BB:CC:DD:EE:FF" if req.kind == "mac"
|
||||
else "IP 地址格式不正确,例如 192.168.88.20")
|
||||
hostnames.put(db, req.kind, value, req.name)
|
||||
db.commit()
|
||||
return _name_rows(db)
|
||||
|
||||
|
||||
@router.delete("/names/{kind}/{value:path}")
|
||||
def delete_name(kind: str, value: str, db: Session = Depends(get_db)):
|
||||
v = hostnames.normalize(kind, value)
|
||||
if v is None:
|
||||
raise HTTPException(400, "格式不正确")
|
||||
hostnames.put(db, kind, v, None)
|
||||
db.commit()
|
||||
return _name_rows(db)
|
||||
|
||||
|
||||
@router.get("/apps")
|
||||
def apps(range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)):
|
||||
start, end = _bounds(range)
|
||||
rows = stats.top_apps(db, start, end)
|
||||
return {"apps": rows, "categories": stats.top_categories(rows), "range_total": stats.total(db, start, end)}
|
||||
cats = stats.top_categories(rows)
|
||||
return {"apps": rows, "categories": cats, "groups": stats.top_groups(cats),
|
||||
"range_total": stats.total(db, start, end)}
|
||||
|
||||
|
||||
@router.get("/apps/{app_id}/detail")
|
||||
|
||||
@@ -4,8 +4,8 @@ from collections import defaultdict
|
||||
|
||||
from sqlalchemy import distinct, func, select
|
||||
|
||||
from app import timeutil
|
||||
from app.classify import GENERIC_APPS
|
||||
from app import hostnames, timeutil
|
||||
from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES
|
||||
from app.engine import engine
|
||||
from app.models import Host, HostDomainDay, TrafficDay, TrafficHour, TrafficMinute
|
||||
|
||||
@@ -35,10 +35,13 @@ def totals3(db, host=None, app=None) -> dict:
|
||||
|
||||
|
||||
def app_meta(app_id: str) -> dict:
|
||||
a = engine.classifier.app_info(app_id)
|
||||
c = engine.classifier.category_info(a.category)
|
||||
cl = engine.classifier
|
||||
a = cl.app_info(app_id)
|
||||
c = cl.category_info(a.category)
|
||||
g = cl.group_info(c["group"])
|
||||
return {"id": a.id, "name": a.name, "name_en": a.name_en, "category": a.category,
|
||||
"category_name": c["name"], "category_name_en": c["name_en"]}
|
||||
"category_name": c["name"], "category_name_en": c["name_en"],
|
||||
"group": c["group"], "group_name": g["name"], "group_name_en": g["name_en"]}
|
||||
|
||||
|
||||
def top_apps(db, start, end, host=None, limit=None) -> list[dict]:
|
||||
@@ -59,8 +62,8 @@ def top_categories(apps: list[dict]) -> list[dict]:
|
||||
agg: dict[str, dict] = {}
|
||||
for a in apps:
|
||||
c = agg.setdefault(a["category"], {"id": a["category"], "name": a["category_name"],
|
||||
"name_en": a["category_name_en"], "up": 0, "down": 0, "total": 0,
|
||||
"apps": 0})
|
||||
"name_en": a["category_name_en"], "group": a["group"],
|
||||
"up": 0, "down": 0, "total": 0, "apps": 0})
|
||||
c["up"] += a["up"]
|
||||
c["down"] += a["down"]
|
||||
c["total"] += a["total"]
|
||||
@@ -68,20 +71,45 @@ def top_categories(apps: list[dict]) -> list[dict]:
|
||||
return sorted(agg.values(), key=lambda c: c["total"], reverse=True)
|
||||
|
||||
|
||||
def top_groups(cats: list[dict]) -> list[dict]:
|
||||
"""应用大类汇总,children 是该大类下的小类(已按流量排序)。"""
|
||||
agg: dict[str, dict] = {}
|
||||
for c in cats:
|
||||
gi = engine.classifier.group_info(c["group"])
|
||||
g = agg.setdefault(c["group"], {"id": c["group"], "name": gi["name"], "name_en": gi["name_en"],
|
||||
"up": 0, "down": 0, "total": 0, "apps": 0, "children": []})
|
||||
for k in ("up", "down", "total", "apps"):
|
||||
g[k] += c[k]
|
||||
g["children"].append(c)
|
||||
return sorted(agg.values(), key=lambda g: g["total"], reverse=True)
|
||||
|
||||
|
||||
def identified_ratio(apps: list[dict]) -> float:
|
||||
known = sum(a["total"] for a in apps if a["category"] not in UNIDENTIFIED_CATEGORIES)
|
||||
return known / (sum(a["total"] for a in apps) or 1)
|
||||
|
||||
|
||||
def host_meta(db, keys) -> dict[str, dict]:
|
||||
keys = list(keys)
|
||||
if not keys:
|
||||
return {}
|
||||
now = time.time()
|
||||
rows = db.scalars(select(Host).where(Host.key.in_(keys))).all()
|
||||
names = hostnames.load(db)
|
||||
out = {}
|
||||
for h in rows:
|
||||
out[h.key] = {"key": h.key, "mac": h.mac, "ip": h.ip, "hostname": h.hostname, "alias": h.alias,
|
||||
"name": h.alias or h.hostname or h.ip or h.key, "first_seen": h.first_seen,
|
||||
alias, by = hostnames.resolve(names, h.mac, h.ip)
|
||||
alias = alias or h.alias # 旧版备注名(迁移前)
|
||||
out[h.key] = {"key": h.key, "mac": h.mac, "ip": h.ip, "hostname": h.hostname, "alias": alias,
|
||||
"alias_by": by, "name": alias or h.hostname or h.ip or h.key, "first_seen": h.first_seen,
|
||||
"last_seen": h.last_seen, "online": bool(h.last_seen and now - h.last_seen < ONLINE_SECONDS)}
|
||||
for k in keys:
|
||||
out.setdefault(k, {"key": k, "mac": None, "ip": k.removeprefix("IP:"), "hostname": None, "alias": None,
|
||||
"name": k.removeprefix("IP:"), "first_seen": None, "last_seen": None, "online": False})
|
||||
if k in out:
|
||||
continue
|
||||
ip = k.removeprefix("IP:")
|
||||
alias, by = hostnames.resolve(names, None, ip)
|
||||
out[k] = {"key": k, "mac": None, "ip": ip, "hostname": None, "alias": alias, "alias_by": by,
|
||||
"name": alias or ip, "first_seen": None, "last_seen": None, "online": False}
|
||||
rate = engine.host_rate.get(k, (0, 0))
|
||||
out[k]["rate_up"], out[k]["rate_down"] = rate
|
||||
return out
|
||||
@@ -99,27 +127,58 @@ def top_hosts(db, start, end, app=None, limit=None) -> list[dict]:
|
||||
for h, u, d, f in rows]
|
||||
|
||||
|
||||
def trend(db, gran: str, start, end, host=None, app=None) -> list[dict]:
|
||||
"""gran: minute | hour | day。返回补齐空桶后的序列。"""
|
||||
t, step = {"minute": (TrafficMinute, 60), "hour": (TrafficHour, 3600), "day": (TrafficDay, 86400)}[gran]
|
||||
q = _where(select(t.ts, *_sum_cols(t)), t, start, end, host, app).group_by(t.ts).order_by(t.ts)
|
||||
data = {int(ts): (int(u), int(d)) for ts, u, d, _f in db.execute(q)}
|
||||
_GRAN = {"minute": (TrafficMinute, 60), "hour": (TrafficHour, 3600), "day": (TrafficDay, 86400)}
|
||||
|
||||
|
||||
def _buckets(gran: str, start, end) -> list[int]:
|
||||
out = []
|
||||
if gran == "day":
|
||||
cur = timeutil.day_bucket(start)
|
||||
while cur < end:
|
||||
u, d = data.get(cur, (0, 0))
|
||||
out.append({"ts": cur, "up": u, "down": d})
|
||||
out.append(cur)
|
||||
cur = timeutil.day_bucket(cur + 86400 + 3600) # +1h 兼容夏令时
|
||||
else:
|
||||
step = _GRAN[gran][1]
|
||||
cur = start // step * step
|
||||
while cur < end:
|
||||
u, d = data.get(cur, (0, 0))
|
||||
out.append({"ts": cur, "up": u, "down": d})
|
||||
out.append(cur)
|
||||
cur += step
|
||||
return out
|
||||
|
||||
|
||||
def trend(db, gran: str, start, end, host=None, app=None) -> list[dict]:
|
||||
"""gran: minute | hour | day。返回补齐空桶后的序列。"""
|
||||
t = _GRAN[gran][0]
|
||||
q = _where(select(t.ts, *_sum_cols(t)), t, start, end, host, app).group_by(t.ts).order_by(t.ts)
|
||||
data = {int(ts): (int(u), int(d)) for ts, u, d, _f in db.execute(q)}
|
||||
return [{"ts": ts, "up": data.get(ts, (0, 0))[0], "down": data.get(ts, (0, 0))[1]}
|
||||
for ts in _buckets(gran, start, end)]
|
||||
|
||||
|
||||
def trend_groups(db, gran: str, start, end, host=None, app=None, max_series=7) -> dict:
|
||||
"""按应用大类拆分的趋势(堆叠面积图)。流量最大的 max_series 个大类单独成线,其余合并为 other。"""
|
||||
t, step = _GRAN[gran]
|
||||
q = _where(select(t.ts, t.app, func.sum(t.up) + func.sum(t.down)), t, start, end, host, app) .group_by(t.ts, t.app)
|
||||
per: dict[str, dict[int, int]] = defaultdict(lambda: defaultdict(int))
|
||||
tot: dict[str, int] = defaultdict(int)
|
||||
for ts, a, b in db.execute(q):
|
||||
g = app_meta(a)["group"]
|
||||
per[g][int(ts)] += int(b)
|
||||
tot[g] += int(b)
|
||||
buckets = _buckets(gran, start, end)
|
||||
order = sorted(tot, key=tot.get, reverse=True)
|
||||
keep, rest = order[:max_series], order[max_series:]
|
||||
series = []
|
||||
for g in keep:
|
||||
gi = engine.classifier.group_info(g)
|
||||
series.append({"id": g, "name": gi["name"], "name_en": gi["name_en"], "total": tot[g],
|
||||
"values": [per[g].get(ts, 0) for ts in buckets]})
|
||||
if rest:
|
||||
series.append({"id": "__other", "name": None, "name_en": None, "total": sum(tot[g] for g in rest),
|
||||
"values": [sum(per[g].get(ts, 0) for g in rest) for ts in buckets]})
|
||||
return {"gran": gran, "step": step, "ts": buckets, "series": series}
|
||||
|
||||
|
||||
def all_hosts(db) -> list[dict]:
|
||||
"""主机监控列表:每台终端的 今日/本周/本月 流量。"""
|
||||
per: dict[str, dict] = defaultdict(dict)
|
||||
@@ -170,7 +229,7 @@ def online_count(db) -> int:
|
||||
|
||||
|
||||
def flow_graph(db, start, end, max_hosts=12, max_apps=16, host=None) -> dict:
|
||||
"""流向图(桑基图)数据:终端 → 应用分类 → 应用。超出上限的终端/应用合并为"其他"。"""
|
||||
"""流向图(桑基图)数据:终端 → 应用大类 → 应用。超出上限的终端/应用合并为"其他"。"""
|
||||
t = TrafficDay
|
||||
q = _where(select(t.host, t.app, *_sum_cols(t)), t, start, end, host).group_by(t.host, t.app)
|
||||
rows = [(h, a, int(u), int(d)) for h, a, u, d, _f in db.execute(q)]
|
||||
@@ -183,13 +242,13 @@ def flow_graph(db, start, end, max_hosts=12, max_apps=16, host=None) -> dict:
|
||||
top_a = set(sorted(app_tot, key=app_tot.get, reverse=True)[:max_apps])
|
||||
meta = host_meta(db, top_h)
|
||||
|
||||
hc: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||
ca: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||
hg: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||
ga: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||
for h, a, u, d in rows:
|
||||
hk = h if h in top_h else "__other_hosts"
|
||||
cat = app_meta(a)["category"]
|
||||
ak = a if a in top_a else f"__other_apps:{cat}"
|
||||
for acc, key in ((hc, (hk, cat)), (ca, (cat, ak))):
|
||||
grp = app_meta(a)["group"]
|
||||
ak = a if a in top_a else f"__other_apps:{grp}"
|
||||
for acc, key in ((hg, (hk, grp)), (ga, (grp, ak))):
|
||||
acc[key][0] += u
|
||||
acc[key][1] += d
|
||||
|
||||
@@ -200,19 +259,20 @@ def flow_graph(db, start, end, max_hosts=12, max_apps=16, host=None) -> dict:
|
||||
return nid
|
||||
|
||||
links = []
|
||||
for (hk, cat), (u, d) in hc.items():
|
||||
for (hk, grp), (u, d) in hg.items():
|
||||
hm = meta.get(hk)
|
||||
src = node(f"h:{hk}", "host", key=None if hk == "__other_hosts" else hk,
|
||||
name=hm["name"] if hm else None, online=hm["online"] if hm else False)
|
||||
cm = engine.classifier.category_info(cat)
|
||||
dst = node(f"c:{cat}", "category", category=cat, name=cm["name"], name_en=cm["name_en"])
|
||||
gi = engine.classifier.group_info(grp)
|
||||
dst = node(f"g:{grp}", "group", group=grp, name=gi["name"], name_en=gi["name_en"])
|
||||
links.append({"source": src, "target": dst, "up": u, "down": d, "value": u + d})
|
||||
for (cat, ak), (u, d) in ca.items():
|
||||
for (grp, ak), (u, d) in ga.items():
|
||||
if ak.startswith("__other_apps:"):
|
||||
dst = node(f"a:{ak}", "app", app=None, category=cat)
|
||||
dst = node(f"a:{ak}", "app", app=None, group=grp, category=None)
|
||||
else:
|
||||
am = app_meta(ak)
|
||||
dst = node(f"a:{ak}", "app", app=ak, category=cat, name=am["name"], name_en=am["name_en"])
|
||||
links.append({"source": f"c:{cat}", "target": dst, "up": u, "down": d, "value": u + d})
|
||||
dst = node(f"a:{ak}", "app", app=ak, group=grp, category=am["category"], name=am["name"],
|
||||
name_en=am["name_en"])
|
||||
links.append({"source": f"g:{grp}", "target": dst, "up": u, "down": d, "value": u + d})
|
||||
links = [x for x in links if x["value"] > 0]
|
||||
return {"nodes": list(nodes.values()), "links": links, "total": sum(host_tot.values())}
|
||||
@@ -24,12 +24,30 @@ def test_api_flow():
|
||||
assert ov["totals"]["today"]["total"] >= 777
|
||||
assert any(x["key"] == "AA:00:00:00:00:50" for x in ov["top_hosts"])
|
||||
assert len(ov["realtime"]) >= 60
|
||||
assert ov["groups"][0]["children"][0]["id"] == "web"
|
||||
tg = ov["trend_groups"]
|
||||
assert len(tg["ts"]) == len(ov["trend"]) and all(len(x["values"]) == len(tg["ts"]) for x in tg["series"])
|
||||
|
||||
hosts = c.get("/api/hosts", headers=h).json()
|
||||
assert any(x["key"] == "AA:00:00:00:00:50" for x in hosts)
|
||||
assert c.patch("/api/hosts/AA:00:00:00:00:50", json={"alias": "书房电脑"}, headers=h).json()["ok"]
|
||||
d = c.get("/api/hosts/AA:00:00:00:00:50/detail?range=month", headers=h).json()
|
||||
assert d["name"] == "书房电脑" and d["apps"][0]["id"] == "https"
|
||||
assert d["name"] == "书房电脑" and d["alias_by"] == "mac" and d["apps"][0]["id"] == "https"
|
||||
assert d["groups"][0]["id"] == "base" and d["trend_groups"]["series"][0]["id"] == "base"
|
||||
# 改成按 IP 命名:MAC 下的旧名字被删掉
|
||||
r = c.patch("/api/hosts/AA:00:00:00:00:50", json={"alias": "固定IP", "by": "ip"}, headers=h).json()
|
||||
assert r["name"] == "固定IP" and r["alias_by"] == "ip"
|
||||
names = c.get("/api/names", headers=h).json()
|
||||
assert [(n["kind"], n["value"]) for n in names] == [("ip", "192.168.88.50")]
|
||||
assert names[0]["host"]["key"] == "AA:00:00:00:00:50"
|
||||
# 命名表:MAC 优先于 IP;格式自动规范化;可以给没出现过的设备提前命名
|
||||
c.put("/api/names", json={"kind": "mac", "value": "aa-00-00-00-00-50", "name": "按MAC"}, headers=h)
|
||||
c.put("/api/names", json={"kind": "mac", "value": "aabb.ccdd.eeff", "name": "未来设备"}, headers=h)
|
||||
assert c.put("/api/names", json={"kind": "ip", "value": "300.1.1.1", "name": "x"}, headers=h).status_code == 400
|
||||
assert c.get("/api/hosts/AA:00:00:00:00:50/detail", headers=h).json()["name"] == "按MAC"
|
||||
names = c.delete("/api/names/mac/AA:00:00:00:00:50", headers=h).json()
|
||||
assert {n["value"] for n in names} == {"192.168.88.50", "AA:BB:CC:DD:EE:FF"}
|
||||
assert next(x for x in c.get("/api/hosts", headers=h).json() if x["key"] == "AA:00:00:00:00:50")["name"] == "固定IP"
|
||||
assert c.get("/api/hosts/IP:1.1.1.1/detail", headers=h).status_code == 404
|
||||
|
||||
assert c.get("/api/apps/https/detail", headers=h).json()["hosts"][0]["key"] == "AA:00:00:00:00:50"
|
||||
@@ -45,7 +63,8 @@ def test_api_flow():
|
||||
assert s["lan_cidrs"] == "192.168.88.0/24,10.0.0.0/8"
|
||||
assert c.put("/api/settings", json={"lan_cidrs": "nope"}, headers=h).status_code == 400
|
||||
assert c.get("/api/system", headers=h).json()["collector"]["flows"] >= 1
|
||||
assert len(c.get("/api/meta", headers=h).json()["apps"]) > 50
|
||||
meta = c.get("/api/meta", headers=h).json()
|
||||
assert len(meta["apps"]) > 250 and {g["id"] for g in meta["groups"]} >= {"media", "game", "unknown"}
|
||||
|
||||
|
||||
def test_flow_graph():
|
||||
@@ -62,5 +81,5 @@ def test_flow_graph():
|
||||
assert "h:__other_hosts" in ids or len([n for n in g["nodes"] if n["kind"] == "host"]) == 1
|
||||
# 守恒:终端→分类 的总量 == 分类→应用 的总量 == total
|
||||
hc = sum(l["value"] for l in g["links"] if l["source"].startswith("h:"))
|
||||
ca = sum(l["value"] for l in g["links"] if l["source"].startswith("c:"))
|
||||
ca = sum(l["value"] for l in g["links"] if l["source"].startswith("g:"))
|
||||
assert hc == ca == g["total"]
|
||||
@@ -22,6 +22,37 @@ def test_classifier_longest_suffix_and_cname():
|
||||
assert c.classify("91.108.4.5", 6, 443, None, None) == "telegram"
|
||||
assert c.classify("9.9.9.9", 17, 443, None, None) == "quic"
|
||||
assert c.classify("9.9.9.9", 6, 12345, None, None) == "other"
|
||||
assert c.classify("9.9.9.9", 1, 0, None, None) == "icmp"
|
||||
assert c.classify("9.9.9.9", 50, 0, None, None) == "vpn"
|
||||
# 未识别的站点经 CDN 的 CNAME 至少能归到 CDN 厂商
|
||||
assert c.classify("1.1.1.1", 6, 443, "img.some-site.cn", "x.kunlunsl.com") == "aliyun"
|
||||
assert c.classify("1.1.1.1", 6, 443, "finder.video.qq.com", None) == "wechat_channels"
|
||||
|
||||
|
||||
def test_rules_library_consistent():
|
||||
"""规则库自洽:分类都有大类、应用都有分类,已发布的应用/分类 ID 没被删掉。"""
|
||||
c = Classifier()
|
||||
assert all(v["group"] in c.groups for v in c.categories.values())
|
||||
assert all(a.category in c.categories for a in c.apps.values())
|
||||
released = {"douyin", "bilibili", "wechat", "qq", "steam", "apple_update", "apple", "microsoft", "cn_ai",
|
||||
"android_update", "app_market", "cloudflare", "ads", "ezviz", "dns", "vpn", "ssh", "mail", "stun",
|
||||
"https", "quic", "http"}
|
||||
assert released <= set(c.apps)
|
||||
assert {"video", "short_video", "live", "music", "social", "im", "game", "shopping", "news", "search",
|
||||
"cloud", "download", "update", "ai", "work", "finance", "life", "ads", "iot", "network", "web",
|
||||
"unknown"} <= set(c.categories)
|
||||
|
||||
|
||||
def test_frontend_colors_cover_categories():
|
||||
"""前端按大类配色(format.js 的 CAT_GROUP),新增分类时要同步。"""
|
||||
import os
|
||||
import re
|
||||
js = open(os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "src", "format.js"), encoding="utf-8").read()
|
||||
block = js[js.index("CAT_GROUP"):]
|
||||
block = block[:block.index("}")]
|
||||
mapping = dict(re.findall(r"(\w+):\s*'(\w+)'", block))
|
||||
c = Classifier()
|
||||
assert mapping == {k: v["group"] for k, v in c.categories.items()}
|
||||
|
||||
|
||||
def test_custom_rule_overrides():
|
||||
@@ -41,6 +72,7 @@ def _engine():
|
||||
def test_direction_dns_and_mac_learning():
|
||||
e = _engine()
|
||||
e.on_tzsp(tzsp_dns("192.168.88.10", "www.bilibili.com", ["5.5.5.5"]))
|
||||
assert list(e.dns_servers) and e.dns_servers[next(iter(e.dns_servers))][0] == 1
|
||||
now = time.time()
|
||||
up = e.add_flow(dict(src="192.168.88.10", dst="5.5.5.5", sport=1, dport=443, proto=6, bytes=100,
|
||||
src_mac="AA:00:00:00:00:01"), now)
|
||||
|
||||
@@ -23,9 +23,19 @@
|
||||
```
|
||||
|
||||
- `output`:路由器自己作为 DNS 服务器时,发给终端的应答。
|
||||
- `forward`:终端直连外部 DNS(如 223.5.5.5)时的应答。
|
||||
- `forward`:终端直连外部 DNS(如 223.5.5.5)时的应答;内网自建 DNS 服务器向上游查询时的应答也走这里。
|
||||
- 只镜像 DNS 应答,数据量很小。
|
||||
|
||||
**作用**:流量记录里只有 IP 和端口。DNS 嗅探记下“这个 IP 是哪个域名解析出来的”,ROS-DPI 才能把发往该 IP 的流量认成抖音、微信等具体应用。它不影响流量字节数统计;没有它时,流量只能按 IP 段、端口识别为 HTTPS / QUIC 等通用协议。
|
||||
|
||||
**内网有自己的 DNS 服务器**(例如 AdGuard Home / Pi-hole 在 192.168.88.248,DHCP 下发的 DNS 是它):
|
||||
|
||||
- 终端 → 内网 DNS 在同一网段,走交换机,路由器看不到,这一段嗅探不到。这没关系:ROS-DPI 的“IP → 域名”表是全网共用的,不需要知道是哪台终端查的。
|
||||
- 内网 DNS → 上游 DNS(如 223.5.5.5)的应答要经过路由器,会被上面的 `forward` 规则嗅探到,效果和终端直接查询一样。
|
||||
- 前提是内网 DNS 的**上游用普通 DNS(udp/53)**。上游设成 DoH / DoT(AdGuard Home 默认的 `https://dns10.quad9.net/dns-query` 之类)时,路由器只看到加密流量,DNS 应答数会一直是 0。解决方法二选一:把上游改成普通 DNS(如 `223.5.5.5`、`119.29.29.29`);或者把上游改成路由器地址,并在路由器上开启 `/ip dns set allow-remote-requests=yes`,这样应答由 `output` 规则嗅探。
|
||||
- 内网 DNS 有缓存,缓存命中时不会再向上游查询。ROS-DPI 会把每条对应关系保留 6 到 48 小时,一般够用。
|
||||
- 可以在 **系统设置 → DNS 嗅探 → 应答来源** 确认:看到 223.5.5.5 等外网地址(标为“外网”)说明上游应答已经嗅探到了。
|
||||
|
||||
提高识别率:
|
||||
|
||||
- 在 DHCP 里把 DNS 下发为路由器地址,并开启 `/ip dns set allow-remote-requests=yes`。
|
||||
|
||||
|
After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 204 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ros-dpi-frontend",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// ECharts 配置生成。颜色从 CSS 变量读取,切换主题/语言后重新生成即可。
|
||||
import { appName, bytes, catColor, rate } from './format.js'
|
||||
// 规范(dataviz):细线 2px、发丝网格、2px 底色间隙分隔色块、颜色跟随实体(应用大类)而不是排名、
|
||||
// 只给关键点(峰值)直接标注,其余交给图例、提示框和旁边的数值表。
|
||||
import { appName, bytes, catHex, catName, groupHex, groupName, pct, rate } from './format.js'
|
||||
import { i18n } from './i18n/index.js'
|
||||
|
||||
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim()
|
||||
const t = (k, p) => i18n.global.t(k, p)
|
||||
const FONT = '"Barlow Semi Condensed", "Barlow", "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
const STEP = { minute: 60, hour: 3600, day: 86400 }
|
||||
|
||||
function tooltip(extra = {}) {
|
||||
return {
|
||||
backgroundColor: css('--panel'), borderColor: css('--line-strong'), borderWidth: 1, padding: [8, 12],
|
||||
textStyle: { color: css('--ink'), fontSize: 13, fontFamily: FONT }, extraCssText: 'box-shadow:none;border-radius:6px', ...extra,
|
||||
textStyle: { color: css('--ink'), fontSize: 13, fontFamily: FONT }, extraCssText: 'box-shadow:0 4px 16px rgba(0,0,0,.08);border-radius:6px', ...extra,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,27 +23,11 @@ function fmtTime(ts, gran) {
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 上下行趋势:下行(蓝)在上、上行(橙)以负值画在零线下方,像一对双绞线。 */
|
||||
export function trendOption(points, gran = 'hour', asRate = false) {
|
||||
const step = { minute: 60, hour: 3600, day: 86400 }[gran]
|
||||
const val = v => (asRate ? v / step : v)
|
||||
const fmt = v => (asRate ? rate(Math.abs(v)) : bytes(Math.abs(v)))
|
||||
const down = css('--down'), up = css('--up')
|
||||
const series = (name, key, color, sign) => ({
|
||||
name, type: gran === 'day' ? 'bar' : 'line', barMaxWidth: 14, barGap: '-100%',
|
||||
smooth: 0.25, symbol: 'none', lineStyle: { width: 1.5, color },
|
||||
areaStyle: gran === 'day' ? undefined : { color, opacity: 0.22 },
|
||||
itemStyle: { color, borderRadius: sign > 0 ? [2, 2, 0, 0] : [0, 0, 2, 2] },
|
||||
data: points.map(p => sign * val(p[key])),
|
||||
})
|
||||
function axes(labels, gran, fmt) {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
grid: { left: 4, right: 8, top: 30, bottom: 2, containLabel: true },
|
||||
legend: { top: 0, left: 0, icon: 'rect', itemWidth: 12, itemHeight: 3, textStyle: { color: css('--muted'), fontFamily: FONT, fontSize: 13 } },
|
||||
tooltip: tooltip({ trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } }, valueFormatter: fmt }),
|
||||
xAxis: {
|
||||
type: 'category', boundaryGap: gran === 'day', data: points.map(p => fmtTime(p.ts, gran)),
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
type: 'category', boundaryGap: gran === 'day', data: labels,
|
||||
axisLine: { lineStyle: { color: css('--line-strong') } }, axisTick: { show: false },
|
||||
axisLabel: { color: css('--faint'), hideOverlap: true, fontFamily: FONT, fontSize: 12 },
|
||||
},
|
||||
yAxis: {
|
||||
@@ -48,44 +35,134 @@ export function trendOption(points, gran = 'hour', asRate = false) {
|
||||
splitLine: { lineStyle: { color: css('--line') } },
|
||||
axisLabel: { color: css('--faint'), fontFamily: FONT, fontSize: 12, formatter: v => (v === 0 ? '0' : fmt(v).replace('.0 ', ' ')) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const legend = () => ({ top: 0, left: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 10, itemGap: 14,
|
||||
textStyle: { color: css('--muted'), fontFamily: FONT, fontSize: 13 } })
|
||||
|
||||
/** 上下行趋势:下行(蓝)在上、上行(橙)以负值画在零线下方,像一对双绞线。峰值直接标注。 */
|
||||
export function trendOption(points, gran = 'hour', asRate = false) {
|
||||
const val = v => (asRate ? v / STEP[gran] : v)
|
||||
const fmt = v => (asRate ? rate(Math.abs(v)) : bytes(Math.abs(v)))
|
||||
const down = css('--down'), up = css('--up')
|
||||
const series = (name, key, color, sign) => ({
|
||||
name, type: gran === 'day' ? 'bar' : 'line', barMaxWidth: 14, barGap: '-100%',
|
||||
smooth: 0.25, symbol: 'none', lineStyle: { width: 2, color },
|
||||
areaStyle: gran === 'day' ? undefined : { color, opacity: 0.16 },
|
||||
itemStyle: { color, borderRadius: sign > 0 ? [4, 4, 0, 0] : [0, 0, 4, 4] },
|
||||
data: points.map(p => sign * val(p[key])),
|
||||
// 只标下行峰值:一个数字讲清"最忙的时候有多忙"
|
||||
markPoint: sign > 0 && points.some(p => p[key] > 0) ? {
|
||||
symbol: 'circle', symbolSize: 7, itemStyle: { color, borderColor: css('--panel'), borderWidth: 2 },
|
||||
label: { position: 'top', distance: 6, color: css('--muted'), fontFamily: FONT, fontSize: 12, formatter: p => t('chart.peak', { v: fmt(p.value) }) },
|
||||
data: [{ type: 'max' }],
|
||||
} : undefined,
|
||||
})
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
grid: { left: 4, right: 12, top: 34, bottom: 2, containLabel: true },
|
||||
legend: legend(),
|
||||
tooltip: tooltip({ trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } }, valueFormatter: fmt }),
|
||||
...axes(points.map(p => fmtTime(p.ts, gran)), gran, fmt),
|
||||
series: [series(t('common.down'), 'down', down, 1), series(t('common.up'), 'up', up, -1)],
|
||||
}
|
||||
}
|
||||
|
||||
/** 流向桑基图:终端 → 分类 → 应用 */
|
||||
/** 按应用大类堆叠的趋势(Panabit 的"应用流量趋势")。最多 7 个大类 + 其他,颜色固定跟随大类。 */
|
||||
export function stackedOption(tg, asRate = false) {
|
||||
const step = tg.step || STEP[tg.gran]
|
||||
const fmt = v => (asRate ? rate(v) : bytes(v))
|
||||
const panel = css('--panel')
|
||||
const series = tg.series.map(s => {
|
||||
const color = s.id === '__other' ? css('--line-strong') : groupHex(s.id)
|
||||
return {
|
||||
name: groupName(s), id: s.id, type: tg.gran === 'day' ? 'bar' : 'line', stack: 'all',
|
||||
smooth: 0.2, symbol: 'none', barMaxWidth: 18,
|
||||
lineStyle: { width: 1, color: panel }, areaStyle: { color, opacity: 0.88 },
|
||||
// 堆叠柱之间留 2px 底色间隙,代替描边
|
||||
itemStyle: { color, borderColor: panel, borderWidth: tg.gran === 'day' ? 1 : 0 },
|
||||
emphasis: { focus: 'series' },
|
||||
data: s.values.map(v => (asRate ? v / step : v)),
|
||||
}
|
||||
})
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
grid: { left: 4, right: 12, top: 58, bottom: 2, containLabel: true },
|
||||
legend: { ...legend(), type: 'scroll' },
|
||||
tooltip: tooltip({
|
||||
trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } },
|
||||
formatter: ps => {
|
||||
const sum = ps.reduce((a, p) => a + (p.value || 0), 0)
|
||||
const rows = ps.filter(p => p.value > 0).sort((a, b) => b.value - a.value)
|
||||
.map(p => `<div style="display:flex;gap:14px;justify-content:space-between"><span>${p.marker}${p.seriesName}</span><b>${fmt(p.value)}</b></div>`)
|
||||
return `<div style="min-width:180px">${ps[0]?.axisValue ?? ''} <b>${fmt(sum)}</b>${rows.join('')}</div>`
|
||||
},
|
||||
}),
|
||||
...axes(tg.ts.map(ts => fmtTime(ts, tg.gran)), tg.gran, fmt),
|
||||
series,
|
||||
}
|
||||
}
|
||||
|
||||
/** 构成环图:内环 = 应用大类,外环 = 小类(同色深浅)。数值和名称在旁边的图例表里,图上不放文字。 */
|
||||
export function donutOption(groups) {
|
||||
const panel = css('--panel')
|
||||
const sum = groups.reduce((a, g) => a + g.total, 0) || 1
|
||||
const ring = { type: 'pie', startAngle: 90, avoidLabelOverlap: false, label: { show: false }, labelLine: { show: false },
|
||||
itemStyle: { borderColor: panel, borderWidth: 2, borderRadius: 3 }, emphasis: { scale: true, scaleSize: 4 } }
|
||||
const gs = groups.filter(g => g.total > 0)
|
||||
return {
|
||||
tooltip: tooltip({
|
||||
trigger: 'item',
|
||||
formatter: p => `${p.marker}${p.name}<br/><b>${bytes(p.value)}</b> <span style="color:${css('--muted')}">${pct(p.value / sum)}</span>`,
|
||||
}),
|
||||
series: [
|
||||
{ ...ring, name: 'group', radius: ['42%', '63%'],
|
||||
data: gs.map(g => ({ name: groupName(g), value: g.total, id: g.id, kind: 'group', itemStyle: { color: groupHex(g.id) } })) },
|
||||
{ ...ring, name: 'category', radius: ['67%', '84%'],
|
||||
data: gs.flatMap(g => g.children.filter(c => c.total > 0).map(c => ({
|
||||
name: `${groupName(g)} · ${catName(c)}`, value: c.total, id: c.id, kind: 'category', itemStyle: { color: catHex(c.id) },
|
||||
}))) },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 流向桑基图:终端 → 应用大类 → 应用 */
|
||||
export function sankeyOption(graph) {
|
||||
const zh = i18n.global.locale.value === 'zh'
|
||||
const ink = css('--ink'), muted = css('--muted')
|
||||
const label = n => {
|
||||
if (n.kind === 'host') return n.key ? n.name : t('flow.otherHosts')
|
||||
if (n.kind === 'category') return zh ? n.name : n.name_en
|
||||
if (n.kind === 'group') return groupName(n)
|
||||
if (!n.app) {
|
||||
const cat = graph.nodes.find(x => x.id === `c:${n.category}`)
|
||||
return t('flow.otherApps', { cat: cat ? (zh ? cat.name : cat.name_en) : '' })
|
||||
const g = graph.nodes.find(x => x.id === `g:${n.group}`)
|
||||
return t('flow.otherApps', { cat: g ? groupName(g) : '' })
|
||||
}
|
||||
return appName(n)
|
||||
}
|
||||
const minLabel = (graph.total || 0) * 0.012
|
||||
const idx = new Map(graph.nodes.map((n, i) => [n.id, i]))
|
||||
const color = n => (n.kind === 'host' ? (n.online ? css('--pair-green') : css('--line-strong')) : catColor(n.category))
|
||||
const color = n => (n.kind === 'host' ? (n.online ? css('--pair-green') : css('--line-strong'))
|
||||
: n.kind === 'app' && n.category ? catHex(n.category) : groupHex(n.group))
|
||||
const share = v => pct(v / (graph.total || 1), 0)
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: tooltip({
|
||||
trigger: 'item',
|
||||
formatter: p => (p.dataType === 'edge'
|
||||
? `${label(p.data.s)} → ${label(p.data.tg)}<br/><b>${bytes(p.data.value)}</b> <span style="color:${css('--down')}">↓${bytes(p.data.down)}</span> <span style="color:${css('--up')}">↑${bytes(p.data.up)}</span>`
|
||||
: `${p.name}<br/><b>${bytes(p.value)}</b>`),
|
||||
: `${p.name}<br/><b>${bytes(p.value)}</b> <span style="color:${muted}">${share(p.value)}</span>`),
|
||||
}),
|
||||
series: [{
|
||||
type: 'sankey', left: 4, right: 150, top: 8, bottom: 8, nodeWidth: 10, nodeGap: 9, draggable: false,
|
||||
type: 'sankey', left: 4, right: 170, top: 8, bottom: 8, nodeWidth: 12, nodeGap: 10, draggable: false,
|
||||
layoutIterations: 48, emphasis: { focus: 'adjacency' },
|
||||
// 占比太小的节点挤在一起时标签会重叠,只在悬停时通过提示框查看
|
||||
label: {
|
||||
color: ink, fontFamily: FONT, fontSize: 13, rich: { v: { color: muted, fontSize: 12 } },
|
||||
formatter: p => (p.value < minLabel ? '' : `${p.name} {v|${bytes(p.value)}}`),
|
||||
formatter: p => (p.value < minLabel ? '' : `${p.name} {v|${bytes(p.value)} · ${share(p.value)}}`),
|
||||
},
|
||||
lineStyle: { color: 'gradient', opacity: 0.32, curveness: 0.5 },
|
||||
itemStyle: { borderWidth: 0 },
|
||||
// 连线颜色取下游节点(大类/应用),一眼看出流量去了哪类应用
|
||||
lineStyle: { color: 'target', opacity: 0.28, curveness: 0.5 },
|
||||
itemStyle: { borderWidth: 0, borderRadius: 2 },
|
||||
data: graph.nodes.map(n => ({ name: label(n), id: n.id, raw: n, itemStyle: { color: color(n) } })),
|
||||
// 用下标连线:终端备注名可能和应用/分类重名
|
||||
links: graph.links.map(l => {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<template>
|
||||
<ol class="bars">
|
||||
<li v-if="!items.length" class="empty">{{ t('common.empty') }}</li>
|
||||
<li v-for="it in items" :key="it.key">
|
||||
<li v-for="(it, i) in items" :key="it.key">
|
||||
<component :is="it.to ? 'router-link' : 'div'" :to="it.to" class="row">
|
||||
<span class="rk num" :class="{ top: i < 3 }">{{ i + 1 }}</span>
|
||||
<span class="lab">
|
||||
<span v-if="it.color" class="chip" :style="{ background: it.color }" />
|
||||
<span v-else class="dot" :class="{ on: it.online }" />
|
||||
<span class="name">{{ it.label }}</span>
|
||||
<span class="sub faint" v-if="it.sub">{{ it.sub }}</span>
|
||||
</span>
|
||||
<span class="val num">{{ bytes(it.value) }}</span>
|
||||
<span class="val"><b class="num">{{ bytes(it.value) }}</b><span v-if="total" class="pc num">{{ pct(it.value / total) }}</span></span>
|
||||
<!-- 长度 = 相对最大项;颜色按上下行拆成蓝/橙两段 -->
|
||||
<span class="track" :style="{ width: Math.max(it.value / max * 100, 0.8) + '%' }">
|
||||
<i class="d" :style="{ flexGrow: it.down ?? it.value }" />
|
||||
<i class="u" :style="{ flexGrow: it.up ?? 0 }" />
|
||||
<span class="rail">
|
||||
<span class="track" :style="{ width: Math.max(it.value / max * 100, 0.8) + '%' }">
|
||||
<i class="d" :style="{ flexGrow: it.down ?? it.value }" />
|
||||
<i class="u" :style="{ flexGrow: it.up ?? 0 }" />
|
||||
</span>
|
||||
</span>
|
||||
</component>
|
||||
</li>
|
||||
@@ -23,9 +26,9 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { bytes } from '../format.js'
|
||||
import { bytes, pct } from '../format.js'
|
||||
|
||||
const props = defineProps({ items: { type: Array, default: () => [] } })
|
||||
const props = defineProps({ items: { type: Array, default: () => [] }, total: Number })
|
||||
const { t } = useI18n()
|
||||
const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
|
||||
</script>
|
||||
@@ -33,17 +36,23 @@ const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
|
||||
<style scoped>
|
||||
.bars { list-style: none; margin: 0; padding: 0; }
|
||||
.row {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; row-gap: 6px;
|
||||
padding: 8px 6px 10px; margin: 0 -6px; border-radius: var(--r-sm);
|
||||
display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; column-gap: 10px; row-gap: 5px;
|
||||
padding: 7px 6px 9px; margin: 0 -6px; border-radius: var(--r-sm);
|
||||
}
|
||||
a.row:hover { background: var(--sunk); }
|
||||
a.row:hover .name { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.rk { grid-row: 1 / 3; align-self: start; padding-top: 1px; color: var(--faint); font-size: 14px; font-weight: 600; text-align: center; }
|
||||
.rk.top { color: var(--ink); }
|
||||
.lab { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
||||
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.sub { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.val { font-size: 16px; font-weight: 600; }
|
||||
.track { grid-column: 1 / -1; display: flex; height: 4px; border-radius: 2px; overflow: hidden; }
|
||||
.val { display: flex; align-items: baseline; gap: 10px; }
|
||||
.val b { font-size: 16px; font-weight: 600; }
|
||||
.pc { color: var(--muted); font-size: 13px; min-width: 44px; text-align: right; }
|
||||
.rail { grid-column: 2 / -1; height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--line) 55%, transparent); }
|
||||
.track { display: flex; height: 100%; border-radius: 3px; overflow: hidden; }
|
||||
.track i { display: block; height: 100%; }
|
||||
.track .d { background: var(--down); }
|
||||
.track .u { background: var(--up); }
|
||||
@media (max-width: 480px) { .sub { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<!-- 流量构成:左侧双层环图(大类 / 小类),右侧是带数值的排行表,环图只负责"一眼看比例" -->
|
||||
<div class="comp" :class="{ compact }">
|
||||
<div class="ring">
|
||||
<Chart v-if="gs.length" :option="opt" :height="compact ? '200px' : '236px'" @click="onChart" />
|
||||
<div v-else class="ring-empty" />
|
||||
<div class="center">
|
||||
<div class="c-v num">{{ bytes(sum) }}</div>
|
||||
<div class="c-l">{{ centerLabel || t('overview.mix') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="lg" v-if="gs.length">
|
||||
<tbody>
|
||||
<template v-for="(g, i) in gs" :key="g.id">
|
||||
<tr class="g" :class="{ link: clickable, on: active === g.id }" @click="clickable && $emit('pick', { group: g.id })">
|
||||
<td class="nm"><span class="sw" :style="{ background: groupColor(g.id) }" />{{ groupName(g) }}</td>
|
||||
<td class="r num">{{ bytes(g.total) }}</td>
|
||||
<td class="r num pc">{{ pct(g.total / sum) }}</td>
|
||||
<td class="bar"><i :style="{ width: (g.total / max * 100) + '%', background: groupColor(g.id) }" /></td>
|
||||
</tr>
|
||||
<!-- 小类明细:宽面板里展开前 4 个大类,其余点开再看,避免表格过长 -->
|
||||
<tr class="sub" v-if="compact ? active === g.id : (i < 4 || active === g.id)">
|
||||
<td colspan="4">
|
||||
<component :is="clickable ? 'button' : 'span'" v-for="c in g.children.filter(x => x.total > 0).slice(0, compact ? 8 : 5)" :key="c.id"
|
||||
class="cat" :class="{ on: activeCat === c.id }" @click.stop="clickable && $emit('pick', { group: g.id, cat: c.id })">
|
||||
<span class="chip" :style="{ background: catColor(c.id) }" />{{ catName(c) }} <span class="faint num">{{ pct(c.total / sum, 0) }}</span>
|
||||
</component>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { donutOption } from '../charts.js'
|
||||
import { bytes, catColor, catName, groupColor, groupName, pct } from '../format.js'
|
||||
import { ui } from '../state.js'
|
||||
import Chart from './Chart.vue'
|
||||
|
||||
const props = defineProps({
|
||||
groups: { type: Array, default: () => [] },
|
||||
clickable: Boolean,
|
||||
compact: Boolean, // 窄面板里用:小类只在选中大类时展开
|
||||
active: String, // 当前筛选的大类
|
||||
activeCat: String,
|
||||
centerLabel: String,
|
||||
})
|
||||
const emit = defineEmits(['pick'])
|
||||
const { t, locale } = useI18n()
|
||||
const gs = computed(() => props.groups.filter(g => g.total > 0))
|
||||
const sum = computed(() => gs.value.reduce((s, g) => s + g.total, 0) || 0)
|
||||
const max = computed(() => Math.max(1, ...gs.value.map(g => g.total)))
|
||||
const opt = computed(() => (ui.theme, locale.value, donutOption(gs.value)))
|
||||
|
||||
function onChart(p) {
|
||||
if (!props.clickable || !p.data) return
|
||||
if (p.data.kind === 'group') emit('pick', { group: p.data.id })
|
||||
else {
|
||||
const g = gs.value.find(x => x.children.some(c => c.id === p.data.id))
|
||||
emit('pick', { group: g && g.id, cat: p.data.id })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comp { display: grid; grid-template-columns: 236px minmax(0, 1fr); gap: 12px 28px; align-items: center; }
|
||||
.comp.compact { grid-template-columns: 200px minmax(0, 1fr); gap: 8px 18px; }
|
||||
.ring { position: relative; }
|
||||
.ring-empty { height: 200px; border-radius: 50%; border: 18px solid var(--line); margin: 18px; }
|
||||
/* 环心:总量。pointer-events 关掉,不挡住环图的悬停 */
|
||||
.center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; text-align: center; }
|
||||
.c-v { font-size: 20px; font-weight: 600; line-height: 1.1; font-variant-numeric: normal; }
|
||||
.compact .c-v { font-size: 17px; }
|
||||
.c-l { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
.lg { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.lg td { padding: 3px 6px; white-space: nowrap; }
|
||||
.lg tr.g td { padding-top: 5px; }
|
||||
.lg tr.g.link { cursor: pointer; }
|
||||
.lg tr.g.link:hover td { background: var(--sunk); }
|
||||
.lg tr.g.on td { background: var(--sunk); }
|
||||
.lg tr.g td:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); }
|
||||
.lg tr.g td:last-child { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
|
||||
.nm { font-weight: 500; display: flex; align-items: center; gap: 8px; }
|
||||
.sw { width: 10px; height: 10px; border-radius: 3px; flex: none; }
|
||||
.r { text-align: right; }
|
||||
.pc { color: var(--muted); width: 56px; }
|
||||
.bar { width: 26%; min-width: 60px; }
|
||||
.bar i { display: block; height: 6px; border-radius: 3px; min-width: 2px; }
|
||||
.sub td { padding: 0 6px 3px 24px; white-space: normal; line-height: 1.4; }
|
||||
.cat { display: inline-flex; align-items: center; gap: 5px; margin: 0 14px 0 0; font-size: 12.5px; color: var(--muted); background: none; border: 0; padding: 0; font-family: inherit; }
|
||||
button.cat { cursor: pointer; }
|
||||
button.cat:hover, .cat.on { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cat .chip { height: 10px; width: 4px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.comp, .comp.compact { grid-template-columns: 1fr; }
|
||||
.ring { max-width: 260px; margin: 0 auto; width: 100%; }
|
||||
.bar { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<!-- 流量构成条:按分类占比横向切分,像一束线缆的截面 -->
|
||||
<div class="spec">
|
||||
<div class="bar" role="img" :aria-label="aria">
|
||||
<component :is="clickable ? 'button' : 'span'" v-for="c in segs" :key="c.id" class="seg-i"
|
||||
:style="{ flexGrow: c.total, background: catColor(c.id) }"
|
||||
:title="`${catName(c)} ${bytes(c.total)} ${pct(c.total / sum)}`"
|
||||
@click="clickable && $emit('pick', c.id)" />
|
||||
<span v-if="!segs.length" class="seg-i none" />
|
||||
</div>
|
||||
<ul class="legend" v-if="legend && segs.length">
|
||||
<li v-for="c in segs.slice(0, legendMax)" :key="c.id">
|
||||
<component :is="clickable ? 'button' : 'span'" class="lg" @click="clickable && $emit('pick', c.id)">
|
||||
<span class="chip" :style="{ background: catColor(c.id) }" />
|
||||
<span>{{ catName(c) }}</span>
|
||||
<span class="num faint">{{ pct(c.total / sum, 0) }}</span>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { bytes, catColor, catName, pct } from '../format.js'
|
||||
|
||||
const props = defineProps({
|
||||
cats: { type: Array, default: () => [] },
|
||||
legend: { type: Boolean, default: true },
|
||||
legendMax: { type: Number, default: 12 },
|
||||
clickable: Boolean,
|
||||
height: { type: Number, default: 14 },
|
||||
})
|
||||
defineEmits(['pick'])
|
||||
const segs = computed(() => props.cats.filter(c => c.total > 0))
|
||||
const sum = computed(() => segs.value.reduce((s, c) => s + c.total, 0) || 1)
|
||||
const aria = computed(() => segs.value.slice(0, 6).map(c => `${catName(c)} ${pct(c.total / sum.value, 0)}`).join(', '))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar { display: flex; gap: 2px; height: v-bind('props.height + "px"'); }
|
||||
.seg-i { min-width: 3px; height: 100%; border: 0; padding: 0; border-radius: 2px; display: block; }
|
||||
button.seg-i { cursor: pointer; }
|
||||
button.seg-i:hover { filter: brightness(1.12); }
|
||||
.seg-i.none { flex: 1; background: var(--line); }
|
||||
.legend { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
||||
.lg { display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; background: none; border: 0; padding: 0; color: var(--ink); }
|
||||
button.lg { cursor: pointer; }
|
||||
button.lg:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-h">
|
||||
<h2>{{ title }}</h2><span v-if="sub" class="faint small">{{ sub }}</span>
|
||||
<span class="sp" />
|
||||
<div class="seg" v-if="groups && groups.series.length" role="tablist">
|
||||
<button v-for="m in ['groups', 'pair']" :key="m" :class="{ on: mode === m }" role="tab" :aria-selected="mode === m"
|
||||
@click="ui.trendMode = m">{{ t('chart.mode.' + m) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-b"><Chart :option="opt" :height="height" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { stackedOption, trendOption } from '../charts.js'
|
||||
import { ui } from '../state.js'
|
||||
import Chart from './Chart.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: String, sub: String,
|
||||
points: { type: Array, default: () => [] }, // [{ts, up, down}]
|
||||
groups: Object, // trend_groups:{ts, series, gran}
|
||||
gran: { type: String, default: 'hour' },
|
||||
height: { type: String, default: '280px' },
|
||||
})
|
||||
const { t, locale } = useI18n()
|
||||
const mode = computed(() => (props.groups && props.groups.series.length ? ui.trendMode : 'pair'))
|
||||
const opt = computed(() => (ui.theme, locale.value,
|
||||
mode.value === 'groups' ? stackedOption(props.groups) : trendOption(props.points, props.gran)))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.seg button { font-size: 13px; padding: 4px 8px 6px; }
|
||||
</style>
|
||||
@@ -48,12 +48,53 @@ export function catName(a) {
|
||||
return i18n.global.locale.value === 'zh' ? a.name : (a.name_en || a.name)
|
||||
}
|
||||
|
||||
// 分类固定配色,所有图表保持一致
|
||||
export const CAT_COLORS = {
|
||||
video: '#d9485f', short_video: '#e2638f', live: '#b65fc8', music: '#8a63d2', social: '#6d7be0',
|
||||
im: '#2f9a57', game: '#e07a2e', shopping: '#d4a72c', news: '#7fa83a', search: '#2a9d8f', cloud: '#3c8fd0',
|
||||
download: '#c0533a', update: '#7b8794', ai: '#1fa7b8', work: '#2c6bc9', finance: '#3aa57a', life: '#c98a3c',
|
||||
ads: '#8b5e3c', iot: '#4bb3a0', network: '#a3aeb0', web: '#5f78b8', unknown: '#b5bec0',
|
||||
export function groupName(g) {
|
||||
if (!g) return '-'
|
||||
if (g.id === '__other') return i18n.global.t('common.otherGroups')
|
||||
if ('group_name' in g) return i18n.global.locale.value === 'zh' ? g.group_name : g.group_name_en
|
||||
return i18n.global.locale.value === 'zh' ? g.name : (g.name_en || g.name)
|
||||
}
|
||||
|
||||
export const catColor = c => CAT_COLORS[c] || '#64748b'
|
||||
/*
|
||||
* 配色按"应用大类":8 个有意义的大类各占一个固定色相(style.css 的 --g-*,已用 dataviz 校验脚本验证色盲区分度),
|
||||
* 网络基础与未识别用中性灰。大类下的小类用同一色相的深浅区分,名字始终和色块一起出现。
|
||||
* 顺序 = 小类在大类内的深浅次序;新增分类时必须同步(后端测试会检查与 apps.yaml 一致)。
|
||||
*/
|
||||
export const CAT_GROUP = {
|
||||
video: 'media', short_video: 'media', live: 'media', music: 'media',
|
||||
game: 'game', mobile_game: 'game', console_game: 'game', game_tool: 'game',
|
||||
im: 'social', social: 'social', voip: 'social', mail: 'social',
|
||||
download: 'transfer', cloud: 'transfer', update: 'transfer', app_store: 'transfer',
|
||||
work: 'work', meeting: 'work', dev: 'work', remote: 'work', ai: 'work', education: 'work',
|
||||
shopping: 'life', life: 'life', travel: 'life', finance: 'life', bank: 'life', stock: 'life',
|
||||
news: 'info', search: 'info', reading: 'info',
|
||||
iot: 'iot', camera: 'iot',
|
||||
network: 'base', system: 'base', cdn: 'base', proxy: 'base', security: 'base', ads: 'base', web: 'base',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
export const GROUPS = ['media', 'game', 'social', 'transfer', 'work', 'life', 'info', 'iot', 'base', 'unknown']
|
||||
|
||||
// 小类深浅:与面板底色混合的比例
|
||||
const SHADES = [100, 76, 58, 44, 34, 26, 20]
|
||||
const shadeOf = cat => {
|
||||
const g = CAT_GROUP[cat]
|
||||
return g ? SHADES[Object.keys(CAT_GROUP).filter(k => CAT_GROUP[k] === g).indexOf(cat)] ?? 20 : 100
|
||||
}
|
||||
|
||||
/** 模板里用:返回 CSS 颜色表达式,随深浅主题自动切换 */
|
||||
export const groupColor = g => (GROUPS.includes(g) ? `var(--g-${g})` : 'var(--line-strong)')
|
||||
export function catColor(cat) {
|
||||
const base = groupColor(CAT_GROUP[cat] || 'unknown')
|
||||
const p = shadeOf(cat)
|
||||
return p >= 100 ? base : `color-mix(in srgb, ${base} ${p}%, var(--panel))`
|
||||
}
|
||||
|
||||
// ECharts 需要真实色值:从 CSS 变量读出后自己混色
|
||||
const cssVar = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim()
|
||||
const hex2rgb = h => { const n = parseInt(h.replace('#', ''), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255] }
|
||||
function mix(a, b, p) {
|
||||
const x = hex2rgb(a), y = hex2rgb(b)
|
||||
return '#' + x.map((v, i) => Math.round(v * p + y[i] * (1 - p)).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
export const groupHex = g => cssVar(GROUPS.includes(g) ? `--g-${g}` : '--line-strong')
|
||||
export const catHex = cat => mix(groupHex(CAT_GROUP[cat] || 'unknown'), cssVar('--panel'), shadeOf(cat) / 100)
|
||||
@@ -7,7 +7,10 @@ export default {
|
||||
loading: 'Loading…', empty: 'No data', save: 'Save', saved: 'Saved', cancel: 'Cancel', delete: 'Delete',
|
||||
edit: 'Edit', add: 'Add', search: 'Search', refresh: 'Refresh', back: 'Back', online: 'Online', offline: 'Offline',
|
||||
name: 'Name', category: 'Category', share: 'Share', never: 'Never', confirmDelete: 'Delete this?', unknown: 'Unknown',
|
||||
theme: 'Toggle light/dark', lang: '中文', allApps: 'All applications', allHosts: 'All hosts',
|
||||
theme: 'Toggle light/dark', lang: '中文', allApps: 'All applications', allHosts: 'All hosts', otherGroups: 'Other groups',
|
||||
},
|
||||
chart: {
|
||||
peak: 'Peak {v}', mode: { groups: 'By app group', pair: 'Up / down' },
|
||||
},
|
||||
setup: {
|
||||
title: 'Connect to MySQL', intro: 'Statistics are stored in your MySQL. Enter the database and account you prepared for ROS-DPI.',
|
||||
@@ -30,17 +33,20 @@ export default {
|
||||
noData: 'No traffic yet. Once RouterOS sends Traffic Flow to this host, numbers appear here.',
|
||||
openGuide: 'Open setup guide',
|
||||
heroToday: 'Through the router today', heroRange: 'Traffic, {range}', live: 'Right now', mix: 'Traffic mix',
|
||||
weekShort: 'Week', monthShort: 'Month', viaWan: 'from WAN port {name}', viaFlow: 'from collected flows',
|
||||
weekShort: 'Week', monthShort: 'Month', mixHint: 'App groups → categories, {n} apps', realtimeHint: 'Per-minute average rate', viaWan: 'from WAN port {name}', viaFlow: 'from collected flows',
|
||||
},
|
||||
hosts: {
|
||||
title: 'Hosts', count: '{n} hosts, {m} online', search: 'Search name / IP / MAC', topApp: 'Top app today',
|
||||
lastSeen: 'Last seen', ip: 'IP address', mac: 'MAC address', hostname: 'Hostname', alias: 'Alias',
|
||||
aliasPlaceholder: 'Give this device a name', onlineOnly: 'Online only', firstSeen: 'First seen', domains: 'Domains',
|
||||
aliasPlaceholder: 'Give this device a name', rename: 'Rename',
|
||||
names: 'Names', namesHint: 'Name hosts by MAC or IP, including devices not seen yet. When both match, MAC wins.',
|
||||
nameBy: 'Match by', by: { mac: 'By MAC', ip: 'By IP' }, address: 'Address', matched: 'Current host', notSeen: 'Not seen yet',
|
||||
byHint: { mac: 'The name follows the device, even if its IP changes. Right for most devices.', ip: 'The name follows the address: for fixed-IP devices whose MAC changes (randomized MAC), or hosts without a MAC.' }, onlineOnly: 'Online only', firstSeen: 'First seen', domains: 'Domains',
|
||||
domain: 'Domain', dhcp: 'DHCP', notFound: 'Host not found',
|
||||
},
|
||||
apps: { title: 'Applications', showAll: 'Show all categories', usedBy: 'Used by', app: 'Application', byCategory: 'By category', byApp: 'By application' },
|
||||
apps: { title: 'Applications', showAll: 'Show all', count: '{n} apps', byGroup: 'App mix', byGroupHint: 'Click a group or category to filter the apps below', usedBy: 'Used by', app: 'Application', byCategory: 'By category', byApp: 'By application' },
|
||||
flow: {
|
||||
title: 'Flows', lead: 'From hosts, through app categories, to individual apps. Thicker lines carry more traffic.',
|
||||
title: 'Flows', lead: 'From hosts, through app groups, to individual apps. Thicker lines carry more traffic; color shows the app group.',
|
||||
hosts: 'Hosts shown', apps: 'Apps shown', otherHosts: 'Other hosts', otherApps: 'Other {cat}',
|
||||
hint: 'Click a host or app to open it', direction: 'Direction', both: 'Up + down',
|
||||
},
|
||||
@@ -58,7 +64,8 @@ export default {
|
||||
wanDetected: 'Detected: {name}', status: 'Status', collector: 'Flow collector (IPFIX)', dns: 'DNS sniffer (TZSP)',
|
||||
ros: 'RouterOS API', ok: 'OK', waiting: 'Waiting for data', disabled: 'Not configured', error: 'Error', test: 'Test',
|
||||
flows: 'Flows processed', dropped: 'Dropped (LAN-to-LAN / router)', exporters: 'Exporters', dnsAnswers: 'DNS answers',
|
||||
dnsEntries: 'IP→domain entries', last: 'Last', guide: 'Setup guide', guideIntro: 'Run these on the RouterOS terminal (replace {ip} with the IP of the host running this container):',
|
||||
dnsEntries: 'IP→domain entries', dnsServers: 'Answers from', dnsLan: 'LAN', dnsUpstream: 'Internet',
|
||||
dnsWhat: 'Why: flow records only carry IPs. DNS sniffing remembers which domain each IP was resolved from, so apps like Douyin or WeChat can be recognized. Byte counts do not depend on it. Any DNS server works as long as its answers pass through the router.', last: 'Last', guide: 'Setup guide', guideIntro: 'Run these on the RouterOS terminal (replace {ip} with the IP of the host running this container):',
|
||||
guideNote: 'Byte counts come from Traffic Flow; app identification relies on DNS sniffing. Clients using DoH/DoT reduce accuracy.',
|
||||
copy: 'Copy', copied: 'Copied', password: 'Change password', oldPw: 'Current password', newPw: 'New password', retention: 'Retention',
|
||||
retentionText: 'Minute detail {minute} d, hourly {hour} d, daily {day} d, domains {domain} d (set via environment variables)',
|
||||
|
||||
@@ -7,7 +7,10 @@ export default {
|
||||
loading: '加载中…', empty: '暂无数据', save: '保存', saved: '已保存', cancel: '取消', delete: '删除',
|
||||
edit: '编辑', add: '添加', search: '搜索', refresh: '刷新', back: '返回', online: '在线', offline: '离线',
|
||||
name: '名称', category: '分类', share: '占比', never: '从未', confirmDelete: '确定删除?', unknown: '未知',
|
||||
theme: '切换深浅色', lang: 'English', allApps: '全部应用', allHosts: '全部终端',
|
||||
theme: '切换深浅色', lang: 'English', allApps: '全部应用', allHosts: '全部终端', otherGroups: '其他大类',
|
||||
},
|
||||
chart: {
|
||||
peak: '峰值 {v}', mode: { groups: '按应用大类', pair: '上行 / 下行' },
|
||||
},
|
||||
setup: {
|
||||
title: '连接 MySQL 数据库', intro: '统计数据保存在你的 MySQL 中。请填写为 ROS-DPI 准备的数据库和账号。',
|
||||
@@ -30,17 +33,20 @@ export default {
|
||||
noData: '还没有收到流量数据。在 RouterOS 上开启 Traffic Flow 并发送到本机后,这里会开始显示。',
|
||||
openGuide: '打开接入向导',
|
||||
heroToday: '今天经过路由器的流量', heroRange: '{range}流量', live: '此刻', mix: '流量构成',
|
||||
weekShort: '本周', monthShort: '本月', viaWan: '取自 WAN 口 {name}', viaFlow: '取自流量采集',
|
||||
weekShort: '本周', monthShort: '本月', mixHint: '应用大类 → 小类,共 {n} 个应用', realtimeHint: '每分钟平均速率', viaWan: '取自 WAN 口 {name}', viaFlow: '取自流量采集',
|
||||
},
|
||||
hosts: {
|
||||
title: '主机监控', count: '共 {n} 台终端,在线 {m} 台', search: '搜索名称 / IP / MAC', topApp: '今日主要应用',
|
||||
lastSeen: '最后活跃', ip: 'IP 地址', mac: 'MAC 地址', hostname: '主机名', alias: '备注名',
|
||||
aliasPlaceholder: '给这台设备起个名字', onlineOnly: '只看在线', firstSeen: '首次出现', domains: '访问域名',
|
||||
aliasPlaceholder: '给这台设备起个名字', rename: '重命名',
|
||||
names: '命名管理', namesHint: '按 MAC 或 IP 给终端起名,可以给还没上线的设备提前命名。两种都设置时 MAC 优先。',
|
||||
nameBy: '命名方式', by: { mac: '按 MAC', ip: '按 IP' }, address: '地址', matched: '当前对应终端', notSeen: '还没出现过',
|
||||
byHint: { mac: '名字跟着设备走:换了 IP 也不变。适合大多数设备。', ip: '名字跟着地址走:适合固定 IP、但 MAC 会变(手机随机 MAC)或拿不到 MAC 的终端。' }, onlineOnly: '只看在线', firstSeen: '首次出现', domains: '访问域名',
|
||||
domain: '域名', dhcp: 'DHCP', notFound: '找不到该终端',
|
||||
},
|
||||
apps: { title: '应用统计', showAll: '显示全部分类', usedBy: '使用终端', app: '应用', byCategory: '按分类', byApp: '按应用' },
|
||||
apps: { title: '应用统计', showAll: '显示全部', count: '{n} 个应用', byGroup: '应用构成', byGroupHint: '点击大类或小类筛选下方应用', usedBy: '使用终端', app: '应用', byCategory: '按分类', byApp: '按应用' },
|
||||
flow: {
|
||||
title: '流量流向', lead: '从终端,经过应用分类,流到具体应用。线越粗流量越大。',
|
||||
title: '流量流向', lead: '从终端,经过应用大类,流到具体应用。线越粗流量越大,颜色表示应用大类。',
|
||||
hosts: '显示终端数', apps: '显示应用数', otherHosts: '其余终端', otherApps: '其余{cat}',
|
||||
hint: '点击终端或应用可查看详情', direction: '方向', both: '上下行合计',
|
||||
},
|
||||
@@ -58,7 +64,8 @@ export default {
|
||||
wanDetected: '当前识别为:{name}', status: '运行状态', collector: '流量采集 (IPFIX)', dns: 'DNS 嗅探 (TZSP)',
|
||||
ros: 'RouterOS API', ok: '正常', waiting: '等待数据', disabled: '未配置', error: '异常', test: '测试连接',
|
||||
flows: '已处理流', dropped: '丢弃(内网/路由器自身)', exporters: '发送端', dnsAnswers: 'DNS 应答',
|
||||
dnsEntries: 'IP→域名 映射', last: '最近', guide: '接入向导', guideIntro: '在 RouterOS 终端执行以下命令(把 {ip} 换成运行本容器的主机 IP):',
|
||||
dnsEntries: 'IP→域名 映射', dnsServers: '应答来源', dnsLan: '内网', dnsUpstream: '外网',
|
||||
dnsWhat: '作用:流量记录里只有 IP,DNS 嗅探把“IP 是哪个域名解析出来的”记下来,才能认出抖音、微信等具体应用;不影响流量统计本身。只要应答经过路由器就能嗅探到,DNS 服务器是谁都可以。', last: '最近', guide: '接入向导', guideIntro: '在 RouterOS 终端执行以下命令(把 {ip} 换成运行本容器的主机 IP):',
|
||||
guideNote: '流量统计依赖 Traffic Flow;应用识别依赖 DNS 嗅探。终端使用 DoH/DoT 加密 DNS 时识别率会下降。',
|
||||
copy: '复制', copied: '已复制', password: '修改密码', oldPw: '原密码', newPw: '新密码', retention: '数据保留',
|
||||
retentionText: '分钟明细 {minute} 天,小时 {hour} 天,每日 {day} 天,域名 {domain} 天(通过环境变量修改)',
|
||||
|
||||
@@ -11,6 +11,7 @@ const systemTheme = () => (media && media.matches ? 'dark' : 'light')
|
||||
export const ui = reactive({
|
||||
range: read('rosdpi.range', 'today'),
|
||||
theme: document.documentElement.dataset.theme || systemTheme(),
|
||||
trendMode: read('rosdpi.trendMode', 'groups'), // 趋势图:groups 按应用大类堆叠 | pair 上下行
|
||||
})
|
||||
|
||||
media && media.addEventListener && media.addEventListener('change', () => {
|
||||
@@ -18,6 +19,7 @@ media && media.addEventListener && media.addEventListener('change', () => {
|
||||
})
|
||||
|
||||
watch(() => ui.range, v => { try { localStorage.setItem('rosdpi.range', v) } catch (e) { /* ignore */ } })
|
||||
watch(() => ui.trendMode, v => { try { localStorage.setItem('rosdpi.trendMode', v) } catch (e) { /* ignore */ } })
|
||||
|
||||
export function toggleTheme() {
|
||||
ui.theme = ui.theme === 'dark' ? 'light' : 'dark'
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
--accent: var(--pair-blue);
|
||||
--focus: #2c6bc9;
|
||||
|
||||
/* 应用大类配色(dataviz validate_palette.js 校验:白底 CVD 相邻 ΔE ≥ 9.1;三色对比度 < 3:1,故图表一律带图例与数值表) */
|
||||
--g-media: #2a78d6;
|
||||
--g-game: #eb6834;
|
||||
--g-social: #1baf7a;
|
||||
--g-transfer: #eda100;
|
||||
--g-work: #e87ba4;
|
||||
--g-life: #008300;
|
||||
--g-info: #6250d6;
|
||||
--g-iot: #e34948;
|
||||
--g-base: #8d9a9d;
|
||||
--g-unknown: #c6ced0;
|
||||
|
||||
--r-sm: 5px;
|
||||
--r: 8px;
|
||||
|
||||
@@ -51,6 +63,16 @@
|
||||
--pair-brown: #b88660;
|
||||
--alert: #ef6158;
|
||||
--focus: #7aaaf0;
|
||||
--g-media: #3987e5;
|
||||
--g-game: #d95926;
|
||||
--g-social: #199e70;
|
||||
--g-transfer: #c98500;
|
||||
--g-work: #d55181;
|
||||
--g-life: #008300;
|
||||
--g-info: #9085e9;
|
||||
--g-iot: #e66767;
|
||||
--g-base: #6b797c;
|
||||
--g-unknown: #3e494c;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +92,16 @@
|
||||
--pair-brown: #b88660;
|
||||
--alert: #ef6158;
|
||||
--focus: #7aaaf0;
|
||||
--g-media: #3987e5;
|
||||
--g-game: #d95926;
|
||||
--g-social: #199e70;
|
||||
--g-transfer: #c98500;
|
||||
--g-work: #d55181;
|
||||
--g-life: #008300;
|
||||
--g-info: #9085e9;
|
||||
--g-iot: #e66767;
|
||||
--g-base: #6b797c;
|
||||
--g-unknown: #3e494c;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="page-h">
|
||||
<router-link to="/apps" class="btn sm">← {{ t('common.back') }}</router-link>
|
||||
<h1 v-if="d">{{ appName(d) }}</h1>
|
||||
<span v-if="d" class="tag"><span class="chip" :style="{ background: catColor(d.category) }" />{{ catName(d) }}</span>
|
||||
<span v-if="d" class="tag"><span class="chip" :style="{ background: catColor(d.category) }" /><span class="faint">{{ groupName(d) }} ·</span> {{ catName(d) }}</span>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
@@ -15,23 +15,22 @@
|
||||
<Stat :label="t('range.month')" :value="bytes(d.totals.month.total)" :up="d.totals.month.up" :down="d.totals.month.down" />
|
||||
<Stat :label="t('apps.usedBy')" :value="d.hosts.length" :sub="t('range.' + ui.range)" />
|
||||
</div>
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('overview.trend') }}</div>
|
||||
<div class="panel-b"><Chart :option="trendOpt" height="240px" /></div>
|
||||
</div>
|
||||
<TrendPanel class="mt" :title="t('overview.trend')" :sub="t('range.' + ui.range)" :points="d.trend" :gran="gran" height="260px" />
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('apps.usedBy') }}</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('common.name') }}</th><th>{{ t('hosts.ip') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
|
||||
<thead><tr><th class="rk">#</th><th>{{ t('common.name') }}</th><th>{{ t('hosts.ip') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="h in d.hosts" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
|
||||
<tr v-for="(h, i) in d.hosts" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
|
||||
<td class="rk num faint">{{ i + 1 }}</td>
|
||||
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}</span></td>
|
||||
<td class="addr">{{ h.ip }}</td>
|
||||
<td class="r num">{{ bytes(h.down) }}</td>
|
||||
<td class="r num">{{ bytes(h.up) }}</td>
|
||||
<td class="r num"><b>{{ bytes(h.total) }}</b></td>
|
||||
<td class="r num muted">{{ pct(h.total / (d.range_total.total || 1)) }}</td>
|
||||
<td class="r share"><span class="num muted">{{ pct(h.total / (d.range_total.total || 1)) }}</span>
|
||||
<span class="bar"><i :style="{ width: (h.total / (d.hosts[0].total || 1) * 100) + '%' }" /></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -47,24 +46,27 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { trendOption } from '../charts.js'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Stat from '../components/Stat.vue'
|
||||
import { appName, bytes, catColor, catName, pct } from '../format.js'
|
||||
import TrendPanel from '../components/TrendPanel.vue'
|
||||
import { appName, bytes, catColor, catName, groupName, pct } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const d = ref(null)
|
||||
const { loading, error } = usePoll(async () => {
|
||||
d.value = await http.get(`/apps/${enc(props.id)}/detail`, { params: { range: ui.range } })
|
||||
}, 60000, () => [ui.range, props.id])
|
||||
const trendOpt = computed(() => (ui.theme, locale.value, trendOption(d.value.trend, ['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day')))
|
||||
const gran = computed(() => (['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day'))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mt { margin-top: 14px; }
|
||||
.nm { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.rk { width: 36px; padding-right: 0; }
|
||||
.share { min-width: 130px; }
|
||||
.bar { display: block; height: 5px; margin: 5px 0 0 auto; width: 100px; border-radius: 3px; background: color-mix(in srgb, var(--line) 55%, transparent); }
|
||||
.bar i { display: block; height: 100%; border-radius: 3px; background: var(--down); }
|
||||
</style>
|
||||
@@ -2,34 +2,37 @@
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<h1>{{ t('apps.title') }}</h1>
|
||||
<span class="lead" v-if="d">{{ bytes(d.range_total.total) }}</span>
|
||||
<span class="lead" v-if="d">{{ bytes(d.range_total.total) }} · {{ t('apps.count', { n: d.apps.length }) }}</span>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
<template v-if="d">
|
||||
<section class="mix">
|
||||
<Spectrum :cats="d.categories" clickable :height="18" :legend-max="30" @pick="pick" />
|
||||
</section>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('apps.byGroup') }}</h2><span class="faint small">{{ t('apps.byGroupHint') }}</span></div>
|
||||
<div class="panel-b"><Composition :groups="d.groups" clickable :active="group" :active-cat="cat" @pick="pick" /></div>
|
||||
</div>
|
||||
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">
|
||||
{{ cat ? catLabel : t('apps.byApp') }}
|
||||
<button v-if="cat" class="btn sm" @click="pick('')">{{ t('apps.showAll') }}</button>
|
||||
<h2>{{ filterLabel || t('apps.byApp') }}</h2>
|
||||
<span class="faint small" v-if="filterLabel">{{ bytes(shownTotal) }} · {{ pct(shownTotal / (d.range_total.total || 1)) }}</span>
|
||||
<button v-if="group || cat" class="btn sm" @click="pick({})">{{ t('apps.showAll') }}</button>
|
||||
<span class="sp" />
|
||||
<input class="input search" v-model="q" :placeholder="t('common.search')" :aria-label="t('common.search')" />
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th>
|
||||
<th class="rk">#</th><th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th>
|
||||
<th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th>
|
||||
<th class="r">{{ t('common.share') }}</th><th class="r">{{ t('apps.usedBy') }}</th><th class="r">{{ t('common.flows') }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="a in shown" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
|
||||
<tr v-for="(a, i) in shown" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
|
||||
<td class="rk num faint">{{ i + 1 }}</td>
|
||||
<td class="nm">{{ appName(a) }}</td>
|
||||
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" />{{ catName(a) }}</span></td>
|
||||
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" /><span class="faint">{{ groupName(a) }} ·</span> {{ catName(a) }}</span></td>
|
||||
<td class="r num">{{ bytes(a.down) }}</td>
|
||||
<td class="r num">{{ bytes(a.up) }}</td>
|
||||
<td class="r num"><b>{{ bytes(a.total) }}</b></td>
|
||||
@@ -55,9 +58,9 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import http, { enc } from '../api.js'
|
||||
import Composition from '../components/Composition.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Spectrum from '../components/Spectrum.vue'
|
||||
import { appName, bytes, catColor, catName, pct } from '../format.js'
|
||||
import { appName, bytes, CAT_GROUP, catColor, catName, groupName, pct } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
@@ -66,29 +69,35 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const d = ref(null)
|
||||
const q = ref('')
|
||||
// 筛选写在地址栏里:?group=media 或 ?cat=short_video(小类隐含所属大类)
|
||||
const cat = computed(() => route.query.cat || '')
|
||||
const group = computed(() => route.query.group || CAT_GROUP[cat.value] || '')
|
||||
const { loading, error } = usePoll(async () => { d.value = await http.get('/apps', { params: { range: ui.range } }) },
|
||||
60000, () => ui.range)
|
||||
|
||||
function pick(c) {
|
||||
router.replace({ query: c && c !== cat.value ? { cat: c } : {} })
|
||||
function pick(p) {
|
||||
const same = p.cat ? p.cat === cat.value : p.group && p.group === group.value && !cat.value
|
||||
router.replace({ query: same || (!p.cat && !p.group) ? {} : p.cat ? { cat: p.cat } : { group: p.group } })
|
||||
}
|
||||
|
||||
const shown = computed(() => {
|
||||
const s = q.value.trim().toLowerCase()
|
||||
return d.value.apps.filter(a => (!cat.value || a.category === cat.value) &&
|
||||
return d.value.apps.filter(a => (!cat.value || a.category === cat.value) && (!group.value || a.group === group.value) &&
|
||||
(!s || [a.id, a.name, a.name_en].some(v => v && v.toLowerCase().includes(s))))
|
||||
})
|
||||
const catLabel = computed(() => {
|
||||
const c = d.value.categories.find(x => x.id === cat.value)
|
||||
return c ? catName(c) : cat.value
|
||||
const shownTotal = computed(() => shown.value.reduce((n, a) => n + a.total, 0))
|
||||
const filterLabel = computed(() => {
|
||||
const g = d.value.groups.find(x => x.id === group.value)
|
||||
if (!g) return ''
|
||||
const c = cat.value && g.children.find(x => x.id === cat.value)
|
||||
return c ? `${groupName(g)} · ${catName(c)}` : groupName(g)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mix { padding-bottom: 4px; }
|
||||
.search { width: 220px; height: 32px; }
|
||||
.nm { font-weight: 500; }
|
||||
.rk { width: 36px; padding-right: 0; }
|
||||
.share { min-width: 110px; }
|
||||
.share .pair { margin-top: 5px; width: 90px; margin-left: auto; }
|
||||
@media (max-width: 640px) { .search { width: 100%; } }
|
||||
|
||||
@@ -63,7 +63,7 @@ function open(p) {
|
||||
if (!n) return
|
||||
if (n.kind === 'host' && n.key) router.push(`/hosts/${enc(n.key)}`)
|
||||
else if (n.kind === 'app' && n.app) router.push(`/apps/${enc(n.app)}`)
|
||||
else if (n.kind === 'category') router.push({ path: '/apps', query: { cat: n.category } })
|
||||
else if (n.kind === 'group') router.push({ path: '/apps', query: { group: n.group } })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
<div class="alias">
|
||||
<span class="k">{{ t('hosts.alias') }}</span>
|
||||
<input class="input" v-model="alias" :placeholder="t('hosts.aliasPlaceholder')" @keyup.enter="saveAlias" />
|
||||
<button class="btn sm" @click="saveAlias" :disabled="alias === (d.alias || '')">{{ saved ? t('common.saved') : t('common.save') }}</button>
|
||||
<div class="seg by" role="radiogroup" :aria-label="t('hosts.nameBy')">
|
||||
<button v-for="k in ['mac', 'ip']" :key="k" type="button" role="radio" :aria-checked="by === k" :class="{ on: by === k }"
|
||||
:disabled="k === 'mac' && !d.mac" :title="t('hosts.byHint.' + k)" @click="by = k">{{ t('hosts.by.' + k) }}</button>
|
||||
</div>
|
||||
<button class="btn sm" @click="saveAlias" :disabled="alias === (d.alias || '') && by === (d.alias_by || defBy)">{{ saved ? t('common.saved') : t('common.save') }}</button>
|
||||
<span class="faint small hint">{{ t('hosts.byHint.' + by) }}</span>
|
||||
<div class="err" v-if="aliasErr">{{ aliasErr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,24 +36,23 @@
|
||||
<Stat v-if="extraRange" :label="t('range.' + ui.range)" :value="bytes(d.range_total.total)" :up="d.range_total.up" :down="d.range_total.down" />
|
||||
</div>
|
||||
|
||||
<section class="mix mt">
|
||||
<h2>{{ t('overview.mix') }}</h2>
|
||||
<Spectrum :cats="d.categories" />
|
||||
</section>
|
||||
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('overview.trend') }}</div>
|
||||
<div class="panel-b"><Chart :option="trendOpt" height="240px" /></div>
|
||||
<div class="grid g-2-3 mt">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.mix') }}</h2></div>
|
||||
<div class="panel-b"><Composition :groups="d.groups" compact clickable :active="openGroup" :center-label="t('range.' + ui.range)"
|
||||
@pick="p => (openGroup = openGroup === p.group && !p.cat ? '' : p.group)" /></div>
|
||||
</div>
|
||||
<TrendPanel :title="t('overview.trend')" :sub="t('range.' + ui.range)" :points="d.trend" :groups="d.trend_groups" :gran="gran" height="260px" />
|
||||
</div>
|
||||
|
||||
<div class="grid g2 mt">
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('nav.apps') }}</div>
|
||||
<div class="panel-h">{{ t('nav.apps') }}<button v-if="openGroup" class="btn sm" @click="openGroup = ''">{{ t('apps.showAll') }}</button></div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="a in d.apps" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
|
||||
<tr v-for="a in appsShown" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
|
||||
<td>{{ appName(a) }}</td>
|
||||
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" />{{ catName(a) }}</span></td>
|
||||
<td class="r num">{{ bytes(a.down) }}</td>
|
||||
@@ -56,7 +61,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!d.apps.length" class="empty">{{ t('common.empty') }}</div>
|
||||
<div v-if="!appsShown.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
@@ -85,39 +90,47 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { trendOption } from '../charts.js'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import Composition from '../components/Composition.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Spectrum from '../components/Spectrum.vue'
|
||||
import Stat from '../components/Stat.vue'
|
||||
import TrendPanel from '../components/TrendPanel.vue'
|
||||
import { ago, appName, bytes, catColor, catName, datetime, pct, rate } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
// 参数不能叫 key(Vue 保留属性,不会作为 prop 传入)
|
||||
const props = defineProps({ hostKey: String })
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const d = ref(null)
|
||||
const alias = ref('')
|
||||
const by = ref('mac')
|
||||
const saved = ref(false)
|
||||
const aliasErr = ref('')
|
||||
const openGroup = ref('')
|
||||
|
||||
const defBy = computed(() => (d.value && d.value.mac ? 'mac' : 'ip'))
|
||||
const { loading, error } = usePoll(async () => {
|
||||
const first = !d.value
|
||||
d.value = await http.get(`/hosts/${enc(props.hostKey)}/detail`, { params: { range: ui.range } })
|
||||
if (first) alias.value = d.value.alias || ''
|
||||
if (first) {
|
||||
alias.value = d.value.alias || ''
|
||||
by.value = d.value.alias_by || defBy.value
|
||||
}
|
||||
}, 30000, () => [ui.range, props.hostKey])
|
||||
|
||||
// 今日/本周/本月 已有固定卡片,只有选其他范围时才多显示一张
|
||||
const extraRange = computed(() => !['today', 'week', 'month'].includes(ui.range))
|
||||
const deps = () => [ui.theme, locale.value]
|
||||
const trendOpt = computed(() => (deps(), trendOption(d.value.trend, ['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day')))
|
||||
const gran = computed(() => (['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day'))
|
||||
const appsShown = computed(() => d.value.apps.filter(a => !openGroup.value || a.group === openGroup.value))
|
||||
|
||||
async function saveAlias() {
|
||||
await http.patch(`/hosts/${enc(props.hostKey)}`, { alias: alias.value })
|
||||
d.value.alias = alias.value || null
|
||||
d.value.name = alias.value || d.value.hostname || d.value.ip
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 1500)
|
||||
aliasErr.value = ''
|
||||
try {
|
||||
const r = await http.patch(`/hosts/${enc(props.hostKey)}`, { alias: alias.value, by: by.value })
|
||||
Object.assign(d.value, { alias: r.alias, alias_by: r.alias_by, name: r.name })
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 1500)
|
||||
} catch (e) { aliasErr.value = e.message }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -127,8 +140,15 @@ h1 { display: flex; align-items: center; gap: 10px; }
|
||||
.info { padding: 14px 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; font-size: 13px; }
|
||||
.info > div { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||||
.k { color: var(--muted); flex: none; min-width: 64px; }
|
||||
.alias { grid-column: 1 / -1; max-width: 520px; }
|
||||
.alias { grid-column: 1 / -1; flex-wrap: wrap; }
|
||||
.alias .input { max-width: 280px; flex: 1 1 180px; }
|
||||
.by { border-bottom: 0; }
|
||||
.by button { padding: 4px 8px 6px; font-size: 13px; }
|
||||
.by button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.hint { flex-basis: 100%; padding-left: 74px; }
|
||||
.dom { max-height: 520px; overflow-y: auto; }
|
||||
.mix h2 { font: 600 17px/1.3 var(--cond); margin: 0 0 10px; }
|
||||
.g-2-3 { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); }
|
||||
@media (max-width: 1100px) { .g-2-3 { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 640px) { .hint { padding-left: 0; } }
|
||||
.dn { max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
|
||||
</style>
|
||||
@@ -6,8 +6,45 @@
|
||||
<span class="sp" />
|
||||
<label class="chk"><input type="checkbox" v-model="onlineOnly" /> {{ t('hosts.onlineOnly') }}</label>
|
||||
<input class="input search" v-model="q" :placeholder="t('hosts.search')" />
|
||||
<button class="btn" :class="{ primary: showNames }" @click="toggleNames">{{ t('hosts.names') }}<span class="faint num" v-if="names.length && !showNames"> {{ names.length }}</span></button>
|
||||
</div>
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
|
||||
<!-- 命名管理:按 MAC 或按 IP 给终端起名,可以提前给还没上线的设备命名 -->
|
||||
<div class="panel names" v-if="showNames">
|
||||
<div class="panel-h"><h2>{{ t('hosts.names') }}</h2><span class="faint small">{{ t('hosts.namesHint') }}</span></div>
|
||||
<form class="panel-b nf" @submit.prevent="addName">
|
||||
<div class="seg" role="radiogroup" :aria-label="t('hosts.nameBy')">
|
||||
<button v-for="k in ['mac', 'ip']" :key="k" type="button" role="radio" :aria-checked="nf.kind === k" :class="{ on: nf.kind === k }" @click="nf.kind = k">{{ t('hosts.by.' + k) }}</button>
|
||||
</div>
|
||||
<input class="input addr v" v-model="nf.value" :placeholder="nf.kind === 'mac' ? 'AA:BB:CC:DD:EE:FF' : '192.168.88.20'" :aria-label="t('hosts.' + nf.kind)" />
|
||||
<input ref="nameInput" class="input n" v-model="nf.name" :placeholder="t('hosts.aliasPlaceholder')" :aria-label="t('hosts.alias')" />
|
||||
<button class="btn primary" :disabled="!nf.value.trim() || !nf.name.trim()">{{ nfSaved ? t('common.saved') : t('common.save') }}</button>
|
||||
<span class="faint small hint">{{ t('hosts.byHint.' + nf.kind) }}</span>
|
||||
<div class="err" v-if="nfErr">{{ nfErr }}</div>
|
||||
</form>
|
||||
<div class="tbl-wrap" v-if="names.length">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('hosts.nameBy') }}</th><th>{{ t('hosts.address') }}</th><th>{{ t('hosts.alias') }}</th><th>{{ t('hosts.matched') }}</th><th /></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="n in names" :key="n.kind + n.value">
|
||||
<td><span class="kind">{{ n.kind.toUpperCase() }}</span></td>
|
||||
<td class="addr">{{ n.value }}</td>
|
||||
<td><b>{{ n.name }}</b></td>
|
||||
<td class="muted small">
|
||||
<router-link v-if="n.host" :to="`/hosts/${enc(n.host.key)}`" class="lk">{{ n.kind === 'mac' ? n.host.ip : (n.host.mac || n.host.ip) }}<span v-if="n.host.hostname"> · {{ n.host.hostname }}</span></router-link>
|
||||
<span v-else>{{ t('hosts.notSeen') }}</span>
|
||||
</td>
|
||||
<td class="r">
|
||||
<button class="btn sm ghost" @click="editName(n)">{{ t('common.edit') }}</button>
|
||||
<button class="btn sm danger" @click="delName(n)">{{ t('common.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
@@ -26,12 +63,16 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in shown" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
|
||||
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}</span>
|
||||
<span class="faint hn" v-if="h.alias && h.hostname">{{ h.hostname }}</span></td>
|
||||
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}
|
||||
<span v-if="h.alias_by" class="kind" :title="t('hosts.byHint.' + h.alias_by)">{{ h.alias_by.toUpperCase() }}</span>
|
||||
<span class="faint hn" v-if="h.alias && h.hostname">{{ h.hostname }}</span>
|
||||
<button class="btn sm ghost ren" :title="t('hosts.rename')" :aria-label="t('hosts.rename')" @click.stop="rename(h)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
</button></span></td>
|
||||
<td class="addr">{{ h.ip }}</td>
|
||||
<td class="addr faint">{{ h.mac || '-' }}</td>
|
||||
<td class="r num small"><span class="down">↓{{ rate(h.rate_down) }}</span><br /><span class="up">↑{{ rate(h.rate_up) }}</span></td>
|
||||
<td class="r num">{{ bytes(h.today.total) }}</td>
|
||||
<td class="r num">{{ bytes(h.today.total) }}<span class="mini"><i :style="{ width: (h.today.total / maxToday * 100) + '%' }" /></span></td>
|
||||
<td class="r num">{{ bytes(h.week.total) }}</td>
|
||||
<td class="r num">{{ bytes(h.month.total) }}</td>
|
||||
<td><span v-if="h.top_app" class="tag"><span class="chip" :style="{ background: catColor(h.top_app.category) }" />{{ appName(h.top_app) }}</span></td>
|
||||
@@ -46,7 +87,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { ago, appName, bytes, catColor, rate } from '../format.js'
|
||||
@@ -57,9 +98,10 @@ const rows = ref([])
|
||||
const q = ref('')
|
||||
const onlineOnly = ref(false)
|
||||
const sort = ref({ key: 'today', desc: true })
|
||||
const { loading, error } = usePoll(async () => { rows.value = await http.get('/hosts') }, 30000)
|
||||
const { loading, error, reload: refresh } = usePoll(async () => { rows.value = await http.get('/hosts') }, 30000)
|
||||
|
||||
const onlineN = computed(() => rows.value.filter(h => h.online).length)
|
||||
const maxToday = computed(() => Math.max(1, ...rows.value.map(h => h.today.total)))
|
||||
const ipNum = ip => (ip || '').split('.').reduce((s, x) => s * 256 + (+x || 0), 0)
|
||||
const keyFn = {
|
||||
name: h => (h.name || '').toLowerCase(), ip: h => ipNum(h.ip), rate: h => h.rate_up + h.rate_down,
|
||||
@@ -79,13 +121,67 @@ function sortBy(k) {
|
||||
sort.value = { key: k, desc: sort.value.key === k ? !sort.value.desc : k !== 'name' && k !== 'ip' }
|
||||
}
|
||||
const arrow = k => (sort.value.key === k ? (sort.value.desc ? '↓' : '↑') : '')
|
||||
|
||||
// ---------- 命名管理 ----------
|
||||
const showNames = ref(false)
|
||||
const names = ref([])
|
||||
const nf = reactive({ kind: 'mac', value: '', name: '' })
|
||||
const nfErr = ref('')
|
||||
const nfSaved = ref(false)
|
||||
const nameInput = ref(null)
|
||||
|
||||
async function loadNames() { names.value = await http.get('/names') }
|
||||
onMounted(loadNames)
|
||||
function toggleNames() { showNames.value = !showNames.value }
|
||||
|
||||
async function rename(h) {
|
||||
// 默认沿用当前的命名方式;没有命名过时有 MAC 按 MAC
|
||||
const kind = h.alias_by || (h.mac ? 'mac' : 'ip')
|
||||
Object.assign(nf, { kind, value: kind === 'mac' ? h.mac : h.ip, name: h.alias || '' })
|
||||
nfErr.value = ''
|
||||
showNames.value = true
|
||||
await nextTick()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
nameInput.value && nameInput.value.focus()
|
||||
}
|
||||
function editName(n) { rename({ alias_by: n.kind, mac: n.kind === 'mac' ? n.value : null, ip: n.kind === 'ip' ? n.value : null, alias: n.name }) }
|
||||
|
||||
async function addName() {
|
||||
nfErr.value = ''
|
||||
try {
|
||||
names.value = await http.put('/names', { kind: nf.kind, value: nf.value.trim(), name: nf.name.trim() })
|
||||
nf.value = nf.name = ''
|
||||
nfSaved.value = true
|
||||
setTimeout(() => (nfSaved.value = false), 1500)
|
||||
refresh()
|
||||
} catch (e) { nfErr.value = e.message }
|
||||
}
|
||||
async function delName(n) {
|
||||
if (!confirm(t('common.confirmDelete'))) return
|
||||
names.value = await http.delete(`/names/${n.kind}/${enc(n.value)}`)
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search { width: 240px; }
|
||||
.chk { display: flex; gap: 6px; align-items: center; color: var(--muted); font-size: 13px; cursor: pointer; }
|
||||
.nm { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.hn { margin-left: 8px; font-size: 12px; }
|
||||
.hn { font-size: 12px; }
|
||||
.small { font-size: 12px; line-height: 1.35; }
|
||||
@media (max-width: 640px) { .search { width: 100%; } }
|
||||
.kind { font: 600 10.5px/1 var(--cond); letter-spacing: .06em; color: var(--muted); border: 1px solid var(--line-strong); border-radius: 3px; padding: 2px 4px; }
|
||||
.ren { height: 24px; padding: 0 5px; opacity: 0; }
|
||||
tr:hover .ren, .ren:focus-visible { opacity: 1; }
|
||||
@media (hover: none) { .ren { opacity: .7; } }
|
||||
.mini { display: block; height: 3px; margin: 4px 0 0 auto; width: 72px; border-radius: 2px; background: color-mix(in srgb, var(--line) 55%, transparent); }
|
||||
.mini i { display: block; height: 100%; margin-left: auto; border-radius: 2px; background: var(--down); }
|
||||
|
||||
.names { margin-bottom: 16px; }
|
||||
.nf { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.nf .seg { border-bottom: 0; }
|
||||
.nf .v { width: 200px; }
|
||||
.nf .n { width: 220px; }
|
||||
.nf .hint { flex-basis: 100%; }
|
||||
.lk:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||
@media (max-width: 640px) { .search, .nf .v, .nf .n { width: 100%; } }
|
||||
</style>
|
||||
@@ -41,30 +41,28 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mix">
|
||||
<div class="mix-h"><h2>{{ t('overview.mix') }}</h2></div>
|
||||
<Spectrum :cats="d.categories" clickable :height="18" @pick="c => $router.push({ path: '/apps', query: { cat: c } })" />
|
||||
</section>
|
||||
|
||||
<div class="grid g-2-1 mt">
|
||||
<div class="grid g-3-2">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.trend') }}</h2><span class="faint small">{{ t('range.' + ui.range) }}</span></div>
|
||||
<div class="panel-b"><Chart :option="trendOpt" height="260px" /></div>
|
||||
<div class="panel-h"><h2>{{ t('overview.mix') }}</h2><span class="faint small">{{ t('overview.mixHint', { n: d.apps_count }) }}</span>
|
||||
<span class="sp" /><router-link to="/apps" class="small link">{{ t('common.allApps') }}</router-link></div>
|
||||
<div class="panel-b"><Composition :groups="d.groups" clickable @pick="p => $router.push({ path: '/apps', query: p.cat ? { cat: p.cat } : { group: p.group } })" /></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.realtime') }}</h2></div>
|
||||
<div class="panel-b"><Chart :option="rtOpt" height="260px" /></div>
|
||||
<div class="panel-h"><h2>{{ t('overview.realtime') }}</h2><span class="faint small">{{ t('overview.realtimeHint') }}</span></div>
|
||||
<div class="panel-b"><Chart :option="rtOpt" height="320px" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TrendPanel class="mt" :title="t('overview.trend')" :sub="t('range.' + ui.range)" :points="d.trend" :groups="d.trend_groups" :gran="gran" />
|
||||
|
||||
<div class="grid g2 mt">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.topApps') }}</h2><span class="sp" /><router-link to="/apps" class="small link">{{ t('common.allApps') }}</router-link></div>
|
||||
<div class="panel-b"><BarList :items="appItems" /></div>
|
||||
<div class="panel-b"><BarList :items="appItems" :total="d.range_total.total" /></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.topHosts') }}</h2><span class="sp" /><router-link to="/hosts" class="small link">{{ t('common.allHosts') }}</router-link></div>
|
||||
<div class="panel-b"><BarList :items="hostItems" /></div>
|
||||
<div class="panel-b"><BarList :items="hostItems" :total="d.range_total.total" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,9 +88,10 @@ import http, { enc } from '../api.js'
|
||||
import { trendOption } from '../charts.js'
|
||||
import BarList from '../components/BarList.vue'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import Composition from '../components/Composition.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Spectrum from '../components/Spectrum.vue'
|
||||
import { appName, bytes, catColor, catName, pct, rate } from '../format.js'
|
||||
import TrendPanel from '../components/TrendPanel.vue'
|
||||
import { appName, bytes, catColor, catName, groupName, pct, rate } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
@@ -114,11 +113,10 @@ const big = computed(() => {
|
||||
|
||||
const gran = computed(() => (['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day'))
|
||||
const deps = () => [ui.theme, locale.value]
|
||||
const trendOpt = computed(() => (deps(), trendOption(d.value.trend, gran.value)))
|
||||
const rtOpt = computed(() => (deps(), trendOption(d.value.realtime, 'minute', true)))
|
||||
|
||||
const appItems = computed(() => d.value.top_apps.map(a => ({
|
||||
key: a.id, label: appName(a), sub: catName(a), value: a.total, up: a.up, down: a.down,
|
||||
key: a.id, label: appName(a), sub: `${groupName(a)} · ${catName(a)}`, value: a.total, up: a.up, down: a.down,
|
||||
color: catColor(a.category), to: `/apps/${enc(a.id)}`,
|
||||
})))
|
||||
const hostItems = computed(() => d.value.top_hosts.map(h => ({
|
||||
@@ -130,7 +128,7 @@ const hostItems = computed(() => d.value.top_hosts.map(h => ({
|
||||
<style scoped>
|
||||
.nodata { padding: 14px 18px; margin-bottom: 20px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; border-left: 3px solid var(--warn); }
|
||||
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px 48px; align-items: end; padding: 4px 0 26px; }
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px 48px; align-items: end; padding: 4px 0 26px; margin-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||
.hero-lbl { color: var(--muted); font-size: 14.5px; display: flex; align-items: center; gap: 8px; }
|
||||
.hero-num { font-size: clamp(64px, 9vw, 112px); font-weight: 600; line-height: .92; letter-spacing: -.035em; margin: 10px 0 14px; }
|
||||
.unit { font-size: .36em; font-weight: 500; letter-spacing: 0; margin-left: .18em; color: var(--muted); }
|
||||
@@ -152,8 +150,8 @@ const hostItems = computed(() => d.value.top_hosts.map(h => ({
|
||||
.pulse { animation: pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .3; } }
|
||||
|
||||
.mix { padding: 18px 0 8px; border-top: 1px solid var(--line); }
|
||||
.mix-h h2 { font: 600 17px/1.3 var(--cond); margin: 0 0 12px; }
|
||||
.g-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); }
|
||||
@media (max-width: 1100px) { .g-3-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.link { color: var(--pair-blue); }
|
||||
.link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<button type="button" :class="{ on: newApp }" @click="newApp = true">{{ t('rules.newApp') }}</button>
|
||||
</div>
|
||||
<select v-if="!newApp" class="input" v-model="f.app_id">
|
||||
<optgroup v-for="c in meta.categories" :key="c.id" :label="catName(c)">
|
||||
<optgroup v-for="c in meta.categories" :key="c.id" :label="catLabel(c)">
|
||||
<option v-for="a in meta.apps.filter(x => x.category === c.id)" :key="a.id" :value="a.id">{{ appName(a) }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
@@ -24,7 +24,7 @@
|
||||
<input class="input" v-model="f.app_id" :placeholder="t('rules.appId')" />
|
||||
<input class="input" v-model="f.app_name" :placeholder="t('rules.appName')" />
|
||||
<select class="input" v-model="f.category">
|
||||
<option v-for="c in meta.categories" :key="c.id" :value="c.id">{{ catName(c) }}</option>
|
||||
<option v-for="c in meta.categories" :key="c.id" :value="c.id">{{ catLabel(c) }}</option>
|
||||
</select>
|
||||
</template>
|
||||
<div class="err" v-if="formErr">{{ formErr }}</div>
|
||||
@@ -82,11 +82,11 @@ import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http from '../api.js'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import { appName, bytes, catName } from '../format.js'
|
||||
import { appName, bytes, catName, groupName } from '../format.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const meta = ref({ categories: [], apps: [] })
|
||||
const meta = ref({ groups: [], categories: [], apps: [] })
|
||||
const rules = ref([])
|
||||
const unknown = ref([])
|
||||
const newApp = ref(false)
|
||||
@@ -106,6 +106,9 @@ async function loadUnknown() {
|
||||
onMounted(() => { load(); loadUnknown() })
|
||||
watch(() => ui.range, loadUnknown)
|
||||
|
||||
// 下拉框里分类带上所属大类,例如"影音娱乐 · 短视频"
|
||||
const catLabel = c => `${groupName(meta.value.groups.find(g => g.id === c.group))} · ${catName(c)}`
|
||||
|
||||
function appLabel(id) {
|
||||
const a = meta.value.apps.find(x => x.id === id)
|
||||
return a ? appName(a) : id
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<div class="kv"><span>{{ t('settings.dnsAnswers') }}</span><b class="num">{{ sys.dns.answers.toLocaleString() }}</b></div>
|
||||
<div class="kv"><span>{{ t('settings.dnsEntries') }}</span><span class="num">{{ sys.dns.entries.toLocaleString() }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.dns.last) }}</span></div>
|
||||
<div class="kv" v-if="sys.dns.servers && sys.dns.servers.length"><span>{{ t('settings.dnsServers') }}</span>
|
||||
<span class="srv"><span v-for="x in sys.dns.servers.slice(0, 4)" :key="x.ip" class="num">{{ x.ip }} <span class="faint">{{ x.lan ? t('settings.dnsLan') : t('settings.dnsUpstream') }} · {{ x.answers.toLocaleString() }}</span></span></span></div>
|
||||
<p class="faint small what">{{ t('settings.dnsWhat') }}</p>
|
||||
</div>
|
||||
<div class="st">
|
||||
<div class="st-h"><span class="dot" :class="!sys.router.enabled ? '' : sys.router.ok ? 'on' : 'bad'" />{{ t('settings.ros') }}
|
||||
@@ -139,7 +142,10 @@ const guide = computed(() => {
|
||||
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
|
||||
/ip traffic-flow target add dst-address=${ip} port=${p.ipfix} version=ipfix
|
||||
|
||||
# 2. DNS 嗅探(应用识别):路由器自身 DNS 的应答 + 终端直连外部 DNS 的应答
|
||||
# 2. DNS 嗅探(应用识别):只镜像 DNS 应答,用来知道"远端 IP 是哪个域名"
|
||||
# output:路由器自己当 DNS 服务器时发给终端的应答
|
||||
# forward:终端直连外部 DNS 的应答;内网自建 DNS(AdGuard/Pi-hole 等)向上游查询拿到的应答也走这里
|
||||
# 内网自建 DNS 的上游必须是普通 DNS(udp/53),上游用 DoH/DoT 时路由器看不到域名
|
||||
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=${ip} sniff-target-port=${p.tzsp} comment=ROSDPI-DNS
|
||||
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=${ip} sniff-target-port=${p.tzsp} comment=ROSDPI-DNS
|
||||
|
||||
@@ -164,6 +170,8 @@ async function copy() {
|
||||
.st-h { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 4px; }
|
||||
.kv { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); }
|
||||
.kv > :last-child { color: var(--text); text-align: right; overflow: hidden; text-overflow: ellipsis; }
|
||||
.srv { display: flex; flex-direction: column; align-items: flex-end; }
|
||||
.what { margin: 6px 0 0; line-height: 1.5; }
|
||||
.form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.form label { font-size: 13px; color: var(--muted); margin-top: 4px; }
|
||||
.guide { margin: 0 0 10px; }
|
||||
|
||||
@@ -23,6 +23,8 @@ SITES = [
|
||||
("img.alicdn.com", 8, .03), ("www.zhihu.com", 3, .08), ("xiaohongshu.com", 9, .05),
|
||||
("cdn.example-unknown.net", 6, .1), ("static.some-cdn.io", 4, .1), ("io.mi.com", .2, .5),
|
||||
("tuyacn.com", .1, .5), ("www.netflix.com", 2, .05), ("ipv4-c001.nflxvideo.net", 70, .01),
|
||||
("www.toutiao.com", 5, .05), ("weread.qq.com", 1, .1), ("mbank.cmbchina.com", .6, .2), ("kimi.moonshot.cn", 1, .3),
|
||||
("finder.video.qq.com", 25, .02), ("open.ys7.com", 3, .6),
|
||||
]
|
||||
DEVICES = ["iPhone", "MacBook", "iPad", "小米电视", "Win 台式机", "Switch", "安卓手机", "NAS", "摄像头", "扫地机"]
|
||||
|
||||
|
||||