From bf145173acaa3fac8416237be6c7355f66ca9776 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 26 Aug 2026 09:45:36 +0800 Subject: [PATCH 01/24] Improvements --- frontend/data/changelog.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 2fb1c9615..63d987d9c 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1997,6 +1997,17 @@ "pt-BR": "Para desenvolvedores: slots de promoção por seção adicionados; conteúdo promocional pode ser inserido onde necessário" } }, + { + "type": "add", + "change": { + "en": "Added new languages: Brazilian Portuguese, Traditional Chinese", + "zh": "增加新语言:巴西葡萄牙语、繁体中文", + "zh-TW": "增加新語言:巴西葡萄牙語、繁體中文", + "fr": "Ajout de nouvelles langues : portugais du Brésil et chinois traditionnel", + "ru": "Добавлены новые языки: португальский (Бразилия) и традиционный китайский", + "pt-BR": "Adição de novos idiomas: português do Brasil e chinês tradicional" + } + }, { "type": "improve", "change": { From 6da1e69d4a8796f079bc86180d82c1434f7149c8 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 26 Aug 2026 09:55:03 +0800 Subject: [PATCH 02/24] Improvements --- README.md | 2 +- README_FR.md | 2 +- README_PT-BR.md | 2 +- README_RU.md | 2 +- README_ZH_TW.md => README_ZH-TW.md | 2 +- README_ZH.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename README_ZH_TW.md => README_ZH-TW.md (99%) diff --git a/README.md b/README.md index 8212d0c3b..ce8cd9d33 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) An open-source, all-in-one IP toolbox: IP lookup from multiple sources, connectivity tests, WebRTC & DNS-leak detection, speed test, MTR, censorship checks, Whois, and more — self-hostable with one Docker command. diff --git a/README_FR.md b/README_FR.md index 94d750318..c71d73b78 100644 --- a/README_FR.md +++ b/README_FR.md @@ -21,7 +21,7 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) Une boîte à outils IP tout-en-un et open source : recherche d'IP depuis plusieurs sources, tests de connectivité, détection WebRTC et de fuites DNS, test de vitesse, MTR, vérification de la censure, Whois et plus encore — auto-hébergeable en une seule commande Docker. diff --git a/README_PT-BR.md b/README_PT-BR.md index c4c47eae0..ae60cd3db 100644 --- a/README_PT-BR.md +++ b/README_PT-BR.md @@ -21,7 +21,7 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) Uma caixa de ferramentas de IP completa e de código aberto: consulta de IP a partir de múltiplas fontes, testes de conectividade, detecção de WebRTC e de vazamento de DNS, teste de velocidade, MTR, verificação de censura, Whois e muito mais — auto-hospedável com um único comando Docker. diff --git a/README_RU.md b/README_RU.md index 286bcfe0b..f34b85c13 100644 --- a/README_RU.md +++ b/README_RU.md @@ -21,7 +21,7 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) Универсальный набор IP-инструментов с открытым исходным кодом: определение IP по нескольким источникам, проверка доступности сайтов, обнаружение утечек WebRTC и DNS, тест скорости, MTR, проверка цензуры, Whois и многое другое — разворачивается на своём сервере одной командой Docker. diff --git a/README_ZH_TW.md b/README_ZH-TW.md similarity index 99% rename from README_ZH_TW.md rename to README_ZH-TW.md index fe87da928..950c255b3 100644 --- a/README_ZH_TW.md +++ b/README_ZH-TW.md @@ -21,7 +21,7 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) 一個開源的多合一 IP 工具箱:多來源 IP 查詢、連通性測試、WebRTC 與 DNS 洩漏偵測、網速測試、MTR、審查封鎖檢查、Whois 等——一條 Docker 指令即可自行架設。 diff --git a/README_ZH.md b/README_ZH.md index 5e72966be..e0f86598d 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -21,7 +21,7 @@ ![CodeQL](https://github.com/jason5ng32/MyIP/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main) ![Docker Build and Push](https://github.com/jason5ng32/MyIP/actions/workflows/docker-image.yml/badge.svg?branch=main) -[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH_TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) +[English](README.md) | [简体中文](README_ZH.md) | [繁體中文](README_ZH-TW.md) | [Русский](README_RU.md) | [Français](README_FR.md) | [Português (BR)](README_PT-BR.md) 一个开源的一站式 IP 工具箱:多来源 IP 查询、连通性测试、WebRTC 与 DNS 泄露检测、网速测试、MTR、封锁检测、Whois 等等 —— 一条 Docker 命令即可自行部署。 From 955ab60ff095a3800e8e59e3f5bc5b697f69a94b Mon Sep 17 00:00:00 2001 From: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:12:46 +0800 Subject: [PATCH 03/24] Feat(connectivity): add Germany country list Co-authored-by: Codex Signed-off-by: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> --- frontend/data/connectivity-import-lists.js | 18 ++++++++++++++++++ frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/locales/pt-BR.json | 1 + frontend/locales/ru.json | 1 + frontend/locales/zh-TW.json | 1 + frontend/locales/zh.json | 1 + public/favicons/amazon-de.png | Bin 0 -> 1920 bytes public/favicons/bund-de.png | Bin 0 -> 537 bytes public/favicons/check24.png | Bin 0 -> 962 bytes public/favicons/deutsche-bahn.png | Bin 0 -> 3220 bytes public/favicons/dhl-de.png | Bin 0 -> 451 bytes public/favicons/google-de.png | Bin 0 -> 1063 bytes public/favicons/kleinanzeigen.png | Bin 0 -> 632 bytes public/favicons/otto.png | Bin 0 -> 508 bytes public/favicons/spiegel.png | Bin 0 -> 1051 bytes public/favicons/tagesschau.png | Bin 0 -> 1807 bytes public/favicons/telekom-de.png | Bin 0 -> 2145 bytes public/favicons/zdf.png | Bin 0 -> 665 bytes 19 files changed, 24 insertions(+) create mode 100644 public/favicons/amazon-de.png create mode 100644 public/favicons/bund-de.png create mode 100644 public/favicons/check24.png create mode 100644 public/favicons/deutsche-bahn.png create mode 100644 public/favicons/dhl-de.png create mode 100644 public/favicons/google-de.png create mode 100644 public/favicons/kleinanzeigen.png create mode 100644 public/favicons/otto.png create mode 100644 public/favicons/spiegel.png create mode 100644 public/favicons/tagesschau.png create mode 100644 public/favicons/telekom-de.png create mode 100644 public/favicons/zdf.png diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index caf8b38e2..9ddd093bc 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -146,6 +146,24 @@ export const IMPORT_LISTS = [ { id: 'reclameaqui', name: 'Reclame Aqui', url: 'https://www.reclameaqui.com.br/favicon.ico' }, ], }, + { + id: 'germany', + emoji: '🇩🇪', + members: [ + { id: 'deutsche-bahn', name: 'Deutsche Bahn', url: 'https://www.bahn.de/favicon.ico' }, + { id: 'dhl-de', name: 'DHL', url: 'https://www.dhl.de/favicon.ico' }, + { id: 'otto', name: 'OTTO', url: 'https://www.otto.de/favicon.ico' }, + { id: 'spiegel', name: 'DER SPIEGEL', url: 'https://www.spiegel.de/favicon.ico' }, + { id: 'check24', name: 'CHECK24', url: 'https://www.check24.de/favicon.ico' }, + { id: 'bund-de', name: 'Bund.de', url: 'https://www.bund.de/favicon.ico' }, + { id: 'tagesschau', name: 'tagesschau', url: 'https://www.tagesschau.de/favicon.ico' }, + { id: 'kleinanzeigen', name: 'Kleinanzeigen', url: 'https://www.kleinanzeigen.de/favicon.ico' }, + { id: 'zdf', name: 'ZDF', url: 'https://www.zdf.de/favicon.ico' }, + { id: 'telekom-de', name: 'Deutsche Telekom', url: 'https://www.telekom.de/favicon.ico' }, + { id: 'amazon-de', name: 'Amazon.de', url: 'https://www.amazon.de/favicon.ico' }, + { id: 'google-de', name: 'Google.de', url: 'https://www.google.de/favicon.ico' }, + ], + }, { id: 'ai', emoji: '🤖', diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0bbf4cecb..80ea0e675 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1238,6 +1238,7 @@ "india": "India Essentials", "china": "China Essentials", "brazil": "Brazil Essentials", + "germany": "Germany Essentials", "ai": "AI Services", "social": "Social & Messaging", "productivity": "Work & Productivity", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b2ce4fceb..cacea8a71 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1238,6 +1238,7 @@ "india": "Essentiels Inde", "china": "Essentiels Chine", "brazil": "Essentiels Brésil", + "germany": "Essentiels Allemagne", "ai": "Services IA", "social": "Réseaux sociaux", "productivity": "Travail et productivité", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index e5173c793..fb7915113 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1238,6 +1238,7 @@ "india": "Essenciais da Índia", "china": "Essenciais da China", "brazil": "Essenciais do Brasil", + "germany": "Essenciais da Alemanha", "ai": "Serviços de IA", "social": "Redes sociais e mensagens", "productivity": "Trabalho e produtividade", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index dbe4e7ea8..745d5ce0e 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1238,6 +1238,7 @@ "india": "Индия: основные", "china": "Китай: основные", "brazil": "Бразилия: основные", + "germany": "Германия: основные", "ai": "ИИ-сервисы", "social": "Соцсети", "productivity": "Работа и продуктивность", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 41b5bfe56..f3d7995de 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1238,6 +1238,7 @@ "india": "印度常用", "china": "中國常用", "brazil": "巴西常用", + "germany": "德國常用", "ai": "AI 服務", "social": "社交通訊", "productivity": "工作與生產力", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 62fcd9baf..09a220cb4 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1238,6 +1238,7 @@ "india": "印度常用", "china": "中国常用", "brazil": "巴西常用", + "germany": "德国常用", "ai": "AI 服务", "social": "社交通讯", "productivity": "工作与生产力", diff --git a/public/favicons/amazon-de.png b/public/favicons/amazon-de.png new file mode 100644 index 0000000000000000000000000000000000000000..c97dfdaab46c6a68551d1fd87d71c5a5b6b7c527 GIT binary patch literal 1920 zcmV-`2Y>j9P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuC>PbXFRCodHn_FxYRT#&=*^eK1iINR08t2NR8NeKVREAB@HqV${S3Zx~|)jS;07 zM8Il*A{V(72)DLeT1ugp_4l8%L$}M6U7Bv`&hD2?&&=6#X3qbA{@-`boYV2_I+m=p zm>sr7mas=Dw!nJqKGJy{<2m9S!^yg7%z2Ijw0axqKI^iLHpg~*wY0n+^TP6zYpjx+ zR9Uw@Wu84|Nt;5sa?Zzct1+a*4ZMSqq{T72NPFAJXs4ZLIRA;(eoMO>y|vboiMI~{ zNu$adn+S6?R9eW@)7)Sgl@gg13*@>B40T{w2Q>|#sJFFt7SKR-c9j4h{4-W&6YL2J zzd^-nij55l7If^)ln_zwU(nIpz^G|ct4;y@FaUg6jn zxI;_`eV;dE-(#c81@`2o?!0QO}f*4ZJ^tPhF!3?DN6~ZnBXl_4x zAa^M6I>J0pem@*vY=(kcAG;I~<#9h7k#P}F)K(&(sJkEVB?XieP~;4hT%gDWPzr)Y z?*h?T0~o~u_O#p0W-N56eEakP=i$3Huw`;G)Z4=e7(dNyL>bFRET?u{;Amytuq*Ti zH#O5kz?HLjott4cmi5j|0w&IM%{#sm4D6L$)_zG}3n$=?6=qZBIuZ_TH#@x5tc^{T zt}A_O-|VZzNV5l?Gh4PAB4*P`+8x8V&PX7~5z>cq2 zD|y)L)$d);Z~2U!wmP$|pQqOyKIhwLuaqjFvRQXQNR?UDJ!W(7cW%*oCX|49ZH?9X zmD2z8z+~5Ddi|_f^>VgJP{YhSoy5fS`4gdXO25UBoG01qXgOlm_?Po{_4d(vvlDyR z8AP%3mpKsz8w!E~qkwS`-~Zw@vuEFRa+U6A~aI9~OgnwPiNGJ*kRL zsd0gBBn!M}QR45PnEgm+)vR>wUHYQgFCTKek7^HvwkSIRs-`iv964!>UA&41Jc;C{ z_1;kOx_r?MmkLIw_B+W1;&G!E0$Zh7j9D>G7a4J*` zJ5A3^vHe(X3dpRiu~!mQAYA@3i|Ra7y%c9$&YR(KaxH&^0#Nf|v&|nniHi(b;35w` z?}FpkZ@T>J{2dqpnt*5)CQ~31A@d(HTSA&IQ?zfaC&p|y+w!TCwR$#F+KO3fpJG(! zrL3(P7y+5Ll?y0FX!4;Lp$7Pp#Z_kE!x}Yi)Z}s}_Hs0sog`I|Yb3~aK*TxRDHHgO z-al0yx(31I-+qw8-xMR{3L-#xZNv>pQ$3?VCev^6v_}hU`n!ha?d|6r@PGjS{shxO zw!u+AE{z(ELNrB{%_Nm#0@KTiCSHMQNR{M=z=nIahmsL3QX(Lt9h)<(k^*w3Dxw8T3W#XO z<_xQ(0H|WxRkYSE1!&TpnxYjI5yE_41n3nj`-^k*xDn<9G4_YIanc$+d|45-3cD1r zkE1Z6WN>I-Wldu`z4w}^mpUquen4^qm#r)wUtf01OxZycVyrwAIefTViMR^64!( zK>xcEadvupfP;*Xli1tb!nNh00000AbW%=J0RR90|NsB@tPs(;w2%M*0d`45K~z|U z?bmC!L?9Ff;23SUrV9@L|DT?aB4t$P!&%n6pNv_arP?+jR97XAOj*?k)v;HvsZo^% zR4DrX*OVyjdW@e0FwkL90FYdTKpc?k{3BpelM)TIP|W!Pw*f~X1p%&74gy^H0)Gq8 zp#vzo@ziY0GB!HrWgO+=5_v=ba_Rw^=B3g!79fdYOI`r?056?0a0uig0w8J-%W)SB z0B}kv2lX3a0M{~Z5sc1Zt^Ei7P)K)jS;|2`Y@PrlZ#K b)b0N-m@-patim^&00000NkvXXu0mjfUJ&sb literal 0 HcmV?d00001 diff --git a/public/favicons/check24.png b/public/favicons/check24.png new file mode 100644 index 0000000000000000000000000000000000000000..1034e28f36d71fbb16ac7ceae7948093d75ad5e1 GIT binary patch literal 962 zcmV;z13mnSP)rskJ{bp_xSswrpvy;*^H6D=<4!fX{bui!vFvO|NsC0|Ns91mpH8R0009WNklzb<|475pC+Mpo#{_lDaxg-&LzTM7`-73zIge1T;WxSqmGyekS{d`RfKffVY z{51OOFZ2ViFZosB@BDfGv;P%8LkFN?ND@P@>8XYZO$T;|gGGAIS=462setFBHHmd<2hoT!2RW$D`XQ`R4!= zpmoT91Q^bL6)-(?2ikW#D1QAK^ z1obIC_2G=+eAf-2k&NW`VAH(-5XNe>!^U5cTefQ#I@ttPk`Y(IZ!wG`fkfUNE4YK@ z1%HO3x0GN{eD1gka(2M9UvhCQY5|Ixeas5Gp2g3+=ltqSy^; z3`dAuz>AkGG5oy%4`N?jP#|XoY7A|OVHGHgYgdJkGFzaPBJw=A57n+*Ql;1gZbPMG z0_D92DR8qR)TYt-;UYZAEKqPtptb(K-JJr<7|hZl;1=uL>H|oD+1%iAIxuNbh={!} z%UFEp9xqGpZWQKw zW`QN*J+lEr_8|-e%D(Xtke#qR#m`kpDRWebGar2b42S*e%!5@0Lf-!ue?1s>`q{P} zCNnMqTgr{>+_obPG=ONgExSQiBP}Us>ltWSqtr+(0fJk2br_w>xexYJiZHqY$X+&;KB6ud7Nj?)T+RQ2W3L1WubJa6x8A8$P!ukSBi-(UN_ kzj%Iq_5J?x{nhpV9~LN7bc;~HqyPW_07*qoM6N<$g1!;UY5)KL literal 0 HcmV?d00001 diff --git a/public/favicons/deutsche-bahn.png b/public/favicons/deutsche-bahn.png new file mode 100644 index 0000000000000000000000000000000000000000..6ca4bd0625bef61a49a0ffb0e06a0c90ac146906 GIT binary patch literal 3220 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 literal 0 HcmV?d00001 diff --git a/public/favicons/kleinanzeigen.png b/public/favicons/kleinanzeigen.png new file mode 100644 index 0000000000000000000000000000000000000000..edab9dc6fe4a94ebc78ef0ddbe0b8048e3432f9f GIT binary patch literal 632 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 Xh2#zRG{`8_00000NkvXXu0mjfVuJ5X literal 0 HcmV?d00001 diff --git a/public/favicons/zdf.png b/public/favicons/zdf.png new file mode 100644 index 0000000000000000000000000000000000000000..e6fc2486b264c4cffb042690ddffaf6aaf2468c6 GIT binary patch literal 665 zcmV;K0%rY*P) zeHs7%|Nns)02d+v3nK#OTJO;XEM#NC7}(pFWsH*p&obLE<%gB;Fr) zP#GZE0}A2Xj#qSoyA@b)BVLuUxYLKB1EoM-0Frt+!}Evb Date: Wed, 26 Aug 2026 15:51:47 +0800 Subject: [PATCH 04/24] Fix(i18n): refine Traditional Chinese terminology Clarify Taiwan-facing terminology across IP details, WebRTC, DNS leak, advanced DNSSEC, ping, and MTR copy without changing the source meaning.\n\nCo-authored-by: OpenAI Codex --- frontend/locales/zh-TW.json | 76 ++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 41b5bfe56..e08fbb4c1 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -51,7 +51,7 @@ "SignedIn": { "Name": "登入使用者", "Item1": "包含未登入使用者的所有權益", - "Item2": "可以檢視 IP 的品質分、代理判斷、型別、原生性等進階資訊", + "Item2": "可以檢視 IP 品質評分、代理判斷、網路類型、ASN 與地理位置一致性等進階資訊", "Item3": "可以使用隱身測試", "Item4": "可以使用深度 DNS 洩漏測試", "Item5": "可以看到自己獲得的成就勳章", @@ -176,11 +176,11 @@ }, "PrettyDirty": { "Title": "你挺髒的", - "Meet": "任意一張 IP 卡片的 IP 品質分為 1" + "Meet": "任意一張 IP 卡片的 IP 品質評分為 1" }, "SqueakyClean": { "Title": "清清白白", - "Meet": "任意一張 IP 卡片的 IP 品質分為 100" + "Meet": "任意一張 IP 卡片的 IP 品質評分為 100" }, "WalkWithPenguins": { "Title": "與鵝同行", @@ -485,7 +485,7 @@ "invisibilitytest": { "Title": "隱身測試", "Note": "當你掛上代理或 VPN 之後,你造訪的網站真的不知道你掛著代理嗎?你真的隱身了嗎?我們會透過多種手段,嘗試找出偽裝後的漏洞,以及你正在使用代理的證據。儘管部分測試結果可能不完全準確,本測試依然有較高的參考價值。", - "Note2": "點選執行後,系統將會執行一個指令碼,通常在 10s 內將會完成所有的測試,在這個過程中,你的 IP 資訊、瀏覽器資訊等將會被收集並在後臺進行分析。", + "Note2": "按下「執行」後,系統會執行一段指令碼,通常可在 10 秒內完成所有測試。測試期間會收集你的 IP、瀏覽器等資訊,並在背景進行分析。", "fetchError": "無法取得測試結果", "scriptLoadError": "測試指令碼載入失敗,無法繼續測試。請檢查網路後重試。", "yourIP": "你的主測 IP 是", @@ -858,7 +858,7 @@ "PingTestNote": "全球的 Ping 值測試", "MTRTestNote": "全球的 MTR 路由測試", "DNSResolverNote": "即時多渠道 DNS 解析", - "EnhancedDnsLeakTest": "捕獲所有遞迴解析器,並分析 ECS 與 DNSSEC", + "EnhancedDnsLeakTest": "找出所有正在使用的遞迴 DNS 解析器,並分析 ECS 與 DNSSEC", "RuleTestNote": "檢查代理軟體的規則設定", "CensorshipCheck": "檢查一個網站在全球哪些國家被封鎖", "ServiceStatus": "檢視知名 AI 與開發者服務的即時可用性,確認你遇到的中斷是不是對方的故障", @@ -909,36 +909,36 @@ }, "enhanceddnsleaktest": { "Title": "深度 DNS 洩漏測試", - "Note": "你已經在首頁的 DNS 洩漏測試中大致了解了目前是否存在 DNS 洩漏,以及 DNS 請求的出口地理歸屬。在這個深度測試裡,我們會從更多維度進行檢測:捕獲更多 DNS 解析器出口 IP、檢查這些解析器是否透過 ECS 把你真實 IP 段的字首轉發給權威伺服器,以及它們是否支援 DNSSEC,從而協助你對自己的 DNS 鏈路安全性形成更完整的認識。", - "Note2": "點選「開始測試」即可開始,通常幾秒內就能得到結果。我們會在後臺發起多次 DNS 探測,由我們的權威 DNS 伺服器捕獲你所使用的遞迴解析器出口 IP,一次測試通常可以捕獲 20–40 條記錄。若完全沒有捕獲到解析器,可能是瀏覽器的 DNS 快取吃掉了請求,稍等片刻再試一次即可。", - "Note3": "由於這是深度測試,頁面裡會出現一些較為專業的概念(例如 ECS、DNSSEC 等)。不用擔心,執行測試後你可以參考下方的「欄位說明」來了解每個欄位的具體含義。", + "Note": "首頁的 DNS 洩漏測試已讓你初步了解是否發生洩漏,以及 DNS 流量從哪裡送出。這項進階測試會進一步偵測更多遞迴 DNS 解析器的出口 IP、檢查解析器是否透過 ECS 將你真實 IP 網段的前綴轉送至上游權威 DNS 伺服器,以及是否支援 DNSSEC,讓你更完整地了解 DNS 連線的安全狀況。", + "Note2": "按下「開始測試」後,系統會在背景執行多次 DNS 探測,通常幾秒內就能取得結果。我們的權威 DNS 伺服器會記錄你實際使用的遞迴解析器出口 IP;一次測試通常可取得 20–40 筆紀錄。如果完全沒有偵測到解析器,可能是瀏覽器沿用了先前的 DNS 查詢結果,因此這次測試沒有產生新的查詢紀錄。請稍候片刻再試一次。", + "Note3": "這是較深入的測試,因此頁面會出現 ECS、DNSSEC 等專業概念。執行測試後,可以參考下方的「欄位說明」,了解各欄位的意義。", "Run": "開始測試", "RunAgain": "再測一次", "RunningProbes": "正在發起 DNS 探測...", "PollingResult": "正在收集結果...", "FetchError": "取得 DNS 洩漏測試結果失敗,請稍後重試。", - "Empty": "本次會話沒有捕獲到任何 DNS 解析器,可能是瀏覽器的 DNS 快取吃掉了請求,請稍後重試。", - "TableTitle": "捕獲到的 DNS 查詢記錄", + "Empty": "本次測試未偵測到任何 DNS 解析器。可能是瀏覽器沿用了先前的 DNS 查詢結果,因此這次測試沒有產生新的查詢紀錄。請稍候片刻再試一次。", + "TableTitle": "偵測到的 DNS 查詢紀錄", "DedupeToggle": "隱藏重複項", "Flow": { "Probing": "正在發起第 {current} / {total} 次探測...", "Waiting": "等待遞迴 DNS 存取我們的權威伺服器(通常 1–3 秒)。", - "Fetching": "正在取得捕獲到的 DNS 查詢記錄...", + "Fetching": "正在取得 DNS 查詢紀錄...", "Done": "測試已完成。" }, "DnssecChip": { - "Signed": "已請求 DNSSEC 簽名", - "Unsigned": "未請求 DNSSEC 簽名", - "Validated": "DNSSEC 校驗正常", - "CdSet": "DNSSEC 校驗被停用" + "Signed": "已要求 DNSSEC 簽章", + "Unsigned": "未要求 DNSSEC 簽章", + "Validated": "DNSSEC 驗證正常", + "CdSet": "DNSSEC 驗證已停用" }, "Summary": { "ResolverCount": "去重後的解析器數量", - "RawCount": "原始捕獲記錄數", + "RawCount": "原始查詢紀錄數", "DnssecPosture": "DNSSEC 狀態", - "DnssecGood": "支援 DNSSEC —— 所有解析器都請求了簽名,並且沒有跳過校驗。", - "DnssecNoDo": "較弱 —— 至少有一個解析器沒有請求 DNSSEC 簽名。", - "DnssecCdSet": "可疑 —— 至少有一個解析器要求跳過 DNSSEC 校驗。", + "DnssecGood": "支援 DNSSEC —— 所有解析器都要求提供簽章,且沒有略過驗證。", + "DnssecNoDo": "較弱 —— 至少有一個解析器未要求 DNSSEC 簽章。", + "DnssecCdSet": "可疑 —— 至少有一個解析器要求略過 DNSSEC 驗證。", "DnssecNone": "暫無資料。" }, "Fields": { @@ -946,16 +946,16 @@ "Location": "歸屬地", "ASN": "組織 / ASN", "QueryType": "請求記錄型別", - "Transport": "請求傳輸協議", + "Transport": "請求傳輸協定", "ECS": "ECS" }, "Legend": { "Title": "欄位說明", "ResolverIP": "實際與我們權威 DNS 伺服器通訊的遞迴解析器出口 IP。如果你使用 VPN,把它的 ASN 與你目前 VPN 出口的 ASN 做對比,就能看出 DNS 請求是否繞過了 VPN 通道(即發生洩漏)。", "QueryType": "解析器向權威伺服器請求的記錄型別(A、AAAA、HTTPS 等)。瀏覽器通常會同時發起 A 與 AAAA 查詢。", - "Transport": "DNS 預設走 UDP;出現 TCP 通常意味著響應過大,而響應過大往往是因為帶上了 DNSSEC 相關記錄。", - "ECS": "EDNS Client Subnet —— 部分遞迴 DNS 會在向上遊轉發查詢時附帶你真實 IP 的一段字首(通常是 IPv4 的 /24 或 IPv6 的 /48)。這意味著你的 IP 段會被 DNS 鏈路上的權威伺服器獲知,可能用於 CDN 的就近排程,也可能在一定程度上削弱匿名性。", - "DO": "DNSSEC OK —— 標記為對勾時,表示遞迴解析器在查詢時主動請求了 DNSSEC 簽名,代表鏈路具備密碼學校驗能力;標記為叉時,表示本次查詢未請求籤名,響應在鏈路上被偽造或篡改時可能無法被察覺。", + "Transport": "DNS 預設使用 UDP。使用 TCP 通常表示回應內容較大,而這通常是因為回應中包含 DNSSEC 相關紀錄。", + "ECS": "EDNS Client Subnet —— 部分遞迴 DNS 解析器將查詢轉送至上游權威 DNS 伺服器時,會附上你真實 IP 的一段前綴(通常是 IPv4 的 /24 或 IPv6 的 /48)。這表示 DNS 連線中的權威伺服器會得知你的部分 IP 網段,可用於 CDN 的就近路由,但也可能降低匿名性。", + "DO": "DNSSEC OK —— 顯示勾號時,代表遞迴解析器在這次查詢中主動要求 DNSSEC 簽章,因此 DNS 連線具有密碼學驗證機制;顯示叉號時,代表這次查詢未要求簽章,傳輸中的回應若遭偽造或竄改,可能無法被偵測。", "CD": "Checking Disabled —— 標記為勾選時,表示呼叫方沒有要求跳過 DNSSEC 驗證,遞迴解析器會進行正常的簽章驗證;標記為叉號時,表示呼叫方明確要求跳過驗證,即使簽章不符也會接受結果。正常情況下應該為勾選;如果出現叉號,大多是由特定除錯工具或用戶端的安全降級造成的,在極少數情況下也可能是連線上存在中間人竄改的徵兆。" } }, @@ -1032,11 +1032,11 @@ "dnsleak": "實際回應瀏覽器查詢的 DNS 解析器出口 IP——解析器不屬於使用者的 ISP/VPN 供應商即為 DNS 洩漏跡象。", "speedtest": "對 Cloudflare 測得的頻寬、延遲與負載延遲(bufferbloat);分數衡量流媒體/遊戲/即時通話體驗,越高越好。", "pingtest": "全球 Globalping 探針對使用者 IP 的 ICMP ping 統計。", - "mtrtest": "全球探針到使用者 IP 的逐跳 MTR 路由追蹤;中間跳的丟包可能只是 ICMP 限速,從某一跳起持續丟包才代表真實問題。", + "mtrtest": "全球探針針對使用者 IP 進行 MTR 路由追蹤。途中某些節點發生封包遺失,可能只是 ICMP 速率限制;如果從某個節點開始,後續都持續出現封包遺失,才較可能代表實際問題。", "ruletest": "存取 8 個測試端點時觀測到的出口 IP——出現多個不同 IP 表示流量被分流到不同線路(如代理規則)。", "browserinfo": "被測裝置的瀏覽器、作業系統與顯示環境。", "invisibility": "代理/VPN 檢測得分(0-100,越高越可能被識別)及各項命中的檢測訊號。", - "enhanceddnsleak": "解析器級 DNS 捕獲:向測試網域名稱發起查詢的每個解析器 IP,含傳輸協議、ECS 暴露與 DNSSEC 標誌(do/cd)。", + "enhanceddnsleak": "解析器層級的 DNS 查詢紀錄:包含向測試網域名稱發出查詢的每個解析器 IP、傳輸協定、ECS 暴露情況與 DNSSEC 標記(DO/CD)。", "persona": "瀏覽器與所選國家本地居民的相符程度:一個總體評級,加上每項檢查的結論。只有結論——各項檢查依據的具體數值刻意不寫入可分享的報告。" } } @@ -1082,10 +1082,10 @@ }, "ipInfos": { "Title": "IP 資訊", - "Notes": "程式會先從多個不同的來源(包含 IPv4 和 IPv6)檢查你的 IP 位址,然後透過你選擇的 IP 地理位置資訊源查詢對應的地理資料。如果目前 IP 堆疊只有 1 個,則沒有資料的來源會顯示為空。", + "Notes": "程式會先透過多個來源檢查你的 IP 位址(包括 IPv4 和 IPv6),再從你選擇的 IP 地理位置資料來源查詢相關位置資訊。如果目前只有 IPv4 或 IPv6,其中沒有資料的項目會顯示為空白。", "Source": "IP 來源", "Country": "地區", - "Region": "省份", + "Region": "行政區", "City": "城市", "TimeZone": "時區", "ISP": "網路", @@ -1095,7 +1095,7 @@ "IPv6Error": "取得失敗或不存在 IPv6 位址", "type": "型別", "isProxy": "代理", - "qualityScore": "IP 品質分", + "qualityScore": "IP 品質評分", "qualityScoreUnknown": "未知分數", "advancedUnlockCta": "為避免濫用,登入後可檢視以下資訊", "advancedQuotaCta": "本月新 IP 查詢額度已用完", @@ -1148,9 +1148,9 @@ "Hosting": "資料中心", "unknownType": "未知型別" }, - "Nativeness": "原生性", - "NativeIPYes": "原生", - "NativeIPNo": "非原生", + "Nativeness": "ASN 與地理位置一致性", + "NativeIPYes": "一致", + "NativeIPNo": "不一致", "nativenessTooltip": "判斷 ASN 的註冊國家是否與 IP 的歸屬國家一致" } }, @@ -1254,7 +1254,7 @@ "id": "webrtc", "Title": "WebRTC 洩漏測試", "Name": "WebRTC 連線", - "Note": "WebRTC 往往透過 UDP 直連進行建立,如果測試回傳了真實 IP,則意味著你的代理設定沒有覆蓋這些連線。除了檢測你連線 WebRTC 時所使用的 IP,我們還會檢測你的 NAT 型別。然而,NAT 型別的檢測並不是 100% 準確的,僅供參考。", + "Note": "WebRTC 通常會透過 UDP 直接建立連線。如果測試結果顯示你的真實 IP,代表代理設定未涵蓋這些連線。除了偵測 WebRTC 連線使用的 IP,我們也會偵測 NAT 類型;但 NAT 類型的結果不一定完全準確,僅供參考。", "StatusWait": "待檢測", "StatusError": "連線錯誤", "StatusUnavailable": "WebRTC 不可用", @@ -1328,9 +1328,9 @@ "MaxDelay": "最大延遲(ms)", "AvgDelay": "平均延遲(ms)", "TotalPackets": "總包數", - "PacketLoss": "丟包率", + "PacketLoss": "封包遺失率", "ReceivedPackets": "收到", - "DroppedPackets": "丟失", + "DroppedPackets": "遺失", "Error": "測試失敗,看起來你的 IP 不允許進行 Ping 測試。" }, "mtrtest": { @@ -1339,7 +1339,7 @@ "UseStored": "使用已知 IP", "Title": "MTR 測試", "Note": "MTR (My Traceroute) 測試會利用 Globalping 全球探針從遍佈全球不同大洲和地區的伺服器出發,對你的 IP 位址進行詳細的路由追蹤。MTR 結合了傳統 traceroute 和 ping 命令的功能,能夠即時地診斷網路連線的品質和效能。", - "Note2": "這個過程涵蓋了從各個地理位置到你的網路環境的完整路徑,提供關於每一跳的延遲和封包丟失資訊。無論是路由路徑的擁堵、某個特定節點的延遲問題,還是封包在傳輸過程中的丟失,MTR 測試都能提供關鍵的診斷資訊。", + "Note2": "這個過程涵蓋了從各個地理位置到你的網路環境的完整路徑,並提供沿途每個節點的延遲和封包遺失資訊。無論是路由壅塞、特定節點延遲,或封包在傳輸途中遺失,MTR 測試都能提供關鍵的診斷資訊。", "Note3": "請選取你的 IP 位址進行測試:", "SelectIP": "選擇一個 IP 位址", "Region": "地區", @@ -1347,9 +1347,9 @@ "ColHost": "主機", "ColIP": "IP", "ColASN": "ASN", - "ColLoss": "丟包", + "ColLoss": "遺失率", "ColSent": "傳送", - "ColDrop": "丟棄", + "ColDrop": "遺失", "ColRecv": "接收", "ColLast": "最近", "ColAvg": "平均", @@ -1357,7 +1357,7 @@ "ColWorst": "最差", "ColStDev": "標準差", "ColJitter": "抖動", - "NoReply": "無響應" + "NoReply": "無回應" }, "ipcheck": { "Title": "IP 查詢", From 0c2b2d2bbe1abced462652ba74b07d5d88425b21 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 26 Aug 2026 17:57:53 +0800 Subject: [PATCH 05/24] Feat(ipinfo): add country online-activity heatmap dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A weekday × hour heatmap of Cloudflare Radar HTTP traffic for the IP's country, opened from a button beside the Country field. Country-level rather than per-ASN because global ASNs have no single diurnal rhythm, and ~250 cache keys let a 30-day edge cache absorb nearly every request. Defaults to likely-human traffic with a switch for all traffic (the other scope prefetches for instant flipping); hours render in the IP's own timezone. Co-Authored-By: Claude Fable 5 --- api/AGENTS.md | 5 +- api/cf-radar-traffic.js | 62 +++++++ api/cf-radar.js | 7 +- backend-server.js | 4 +- common/guards.js | 15 ++ .../components/ip-infos/CountryTraffic.vue | 170 ++++++++++++++++++ frontend/components/ip-infos/IPCard.vue | 3 +- .../components/ip-infos/IpDetailPanel.vue | 29 +++ frontend/locales/en.json | 7 + frontend/locales/fr.json | 7 + frontend/locales/pt-BR.json | 7 + frontend/locales/ru.json | 7 + frontend/locales/zh-TW.json | 7 + frontend/locales/zh.json | 7 + frontend/utils/time-utils.js | 40 +++-- tests/api-handlers.test.js | 62 +++++++ tests/guards.test.js | 32 +++- tests/time-utils.test.js | 42 +++++ 18 files changed, 496 insertions(+), 17 deletions(-) create mode 100644 api/cf-radar-traffic.js create mode 100644 frontend/components/ip-infos/CountryTraffic.vue diff --git a/api/AGENTS.md b/api/AGENTS.md index b920d96b9..c86dffd4f 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -14,7 +14,7 @@ services, service-status poller), parts of which the frontend also imports Roughly one handler file per route: IP-geolocation sources (`ipinfo-io` / `ipapi-com` / `ipapi-is` / `ip2location-io` / `ip-sb` / `ipcheck-ing` / `maxmind`), tool backends (`get-whois` / `dns-resolver` / `mac-checker` / -`cf-radar` / `net-outages` / `asn-history` / `asn-connectivity` / +`cf-radar` / `cf-radar-traffic` / `net-outages` / `asn-history` / `asn-connectivity` / `ooni-blocking` / `globalping-probes` / `service-status` / `google-map` / `github-stars` / `invisibility-test` / `dns-leak-test` / `persona`), user proxies (`get-user-info` / `update-user-achievement`), platform @@ -85,6 +85,9 @@ these checks: the BGP DFZ floor (/24 v4, /48 v6) for maximal CF edge-cache reuse. - `requireValidASN()` — `?asn=`, strips `AS`, rewrites to numeric (`cf-radar` predates it and still validates inline). +- `requireValidCountry()` — `?country=` (alpha-2), uppercases in place for one + canonical edge-cache key. Syntactic only — an unassigned code just yields an + empty upstream series. - `requireValidProviderId()` — whitelists `?id=` against service-status slugs. - `requireValidRecordType()` — whitelists `?type=` against `DNS_RECORD_TYPES` in `common/dns-record-types.js` and uppercases it. That list is the single diff --git a/api/cf-radar-traffic.js b/api/cf-radar-traffic.js new file mode 100644 index 000000000..70ab4340c --- /dev/null +++ b/api/cf-radar-traffic.js @@ -0,0 +1,62 @@ +// Backend for /api/cfradar-traffic — country online-activity heatmap. +// GET /api/cfradar-traffic?country=XX[&human=1]. Aggregates Cloudflare +// Radar's hourly HTTP-requests timeseries (requests track people being +// online better than NetFlows bytes) into a Monday-first 7×24 weekday/hour +// matrix. Country-level on purpose: global ASNs have no single diurnal +// rhythm, and ~250 cache keys let the 30-day edge cache absorb nearly +// every request. +import { fetchFromCloudflare } from './cf-radar.js'; +import logger from '../common/logger.js'; + +// 28d × 1h ≈ 4 samples per weekday/hour cell — one week alone would let a +// single holiday skew the heatmap. +const seriesEndpoint = (country, humanOnly) => + `/radar/http/timeseries?location=${country}${humanOnly ? '&botClass=LIKELY_HUMAN' : ''}&dateRange=28d&aggInterval=1h`; + +// Mean of Radar's MIN0_MAX-normalized values per weekday/hour cell, re-scaled +// so the busiest cell is 1. Stays in UTC — the response is edge-cached across +// timezones, so the frontend does the hour rotation. Null when the series +// covers less than a full week of hourly points. +export const buildTrafficMatrix = (serie) => { + const timestamps = serie?.timestamps; + const values = serie?.values; + if (!Array.isArray(timestamps) || !Array.isArray(values)) return null; + + const sums = Array.from({ length: 7 }, () => new Array(24).fill(0)); + const counts = Array.from({ length: 7 }, () => new Array(24).fill(0)); + let usable = 0; + timestamps.forEach((ts, i) => { + const value = parseFloat(values[i]); + const ms = Date.parse(ts); + if (!Number.isFinite(value) || !Number.isFinite(ms)) return; + const date = new Date(ms); + const day = (date.getUTCDay() + 6) % 7; // getUTCDay is Sunday-first + sums[day][date.getUTCHours()] += value; + counts[day][date.getUTCHours()] += 1; + usable += 1; + }); + if (usable < 7 * 24) return null; + + const means = sums.map((row, d) => row.map((sum, h) => (counts[d][h] ? sum / counts[d][h] : 0))); + const max = Math.max(...means.flat()); + if (max <= 0) return null; + return means.map((row) => row.map((v) => Math.round((v / max) * 1000) / 1000)); +}; + +export default async (req, res) => { + if (!process.env.CLOUDFLARE_API_KEY && !process.env.CLOUDFLARE_API) { + return res.status(500).json({ error: 'API key is missing' }); + } + + const country = req.query.country; + // Strict '1' keeps the cache key space to two variants per country. + const humanOnly = req.query.human === '1'; + try { + const json = await fetchFromCloudflare(seriesEndpoint(country, humanOnly)); + // null (no usable series) is a valid, cacheable answer. + res.json({ trafficMatrix: buildTrafficMatrix(json?.result?.serie_0) }); + } catch (error) { + logger.error({ err: error, country }, 'cf-radar-traffic handler failed'); + res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/api/cf-radar.js b/api/cf-radar.js index 8d82cf433..e98fe7f46 100644 --- a/api/cf-radar.js +++ b/api/cf-radar.js @@ -1,10 +1,10 @@ import { fetchUpstream } from '../common/fetch-with-timeout.js'; import logger from '../common/logger.js'; -// Common fetch request function. +// Common fetch request function, shared with cf-radar-traffic. // CLOUDFLARE_API is the pre-rename spelling — keep reading it so existing // deployments don't lose the key on upgrade. -async function fetchFromCloudflare(endpoint) { +export async function fetchFromCloudflare(endpoint) { const url = `https://api.cloudflare.com/client/v4${endpoint}`; const response = await fetchUpstream(url, { headers: { @@ -20,7 +20,8 @@ async function fetchFromCloudflare(endpoint) { } // The five Radar segments backing one /api/cfradar response, keyed by the -// field name cleanUpResponseData expects. +// field name cleanUpResponseData expects. (The country online-activity +// heatmap lives on its own route, cf-radar-traffic.) const SEGMENTS = { asnInfo: (asn) => `/radar/entities/asns/${asn}`, ipVersion: (asn) => `/radar/http/summary/ip_version?asn=${asn}&dateRange=7d`, diff --git a/backend-server.js b/backend-server.js index af67af8db..856ac68e2 100644 --- a/backend-server.js +++ b/backend-server.js @@ -8,7 +8,7 @@ import rateLimit from 'express-rate-limit'; import pinoHttp from 'pino-http'; import logger from './common/logger.js'; import { requireReferer, requirePublicIP, requireValidPrefix, requireValidASN, requireValidDomain, requireValidProviderId, - requireValidRecordType, requireValidReportId } from './common/guards.js'; + requireValidRecordType, requireValidReportId, requireValidCountry } from './common/guards.js'; import { withTimeZone } from './common/ip-timezone.js'; // Backend APIs @@ -23,6 +23,7 @@ import ipsbHandler from './api/ip-sb.js'; import maxmindHandler from './api/maxmind.js'; // Others import cfHander from './api/cf-radar.js'; +import cfRadarTrafficHandler from './api/cf-radar-traffic.js'; import netOutagesHandler from './api/net-outages.js'; import asnHistoryHandler from './api/asn-history.js'; import asnConnectivityHandler from './api/asn-connectivity.js'; @@ -269,6 +270,7 @@ app.get('/api/globalping-probes', cacheable(SEVEN_DAYS_CACHE), globalpingProbesH // (or slower) cadence: IEEE OUI assignments, ASN metadata, ASN interconnection, // and append-only BGP routing history. app.get('/api/cfradar', cacheable(THIRTY_DAYS_CACHE), cfHander); +app.get('/api/cfradar-traffic', requireValidCountry(), cacheable(THIRTY_DAYS_CACHE), cfRadarTrafficHandler); app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler); app.get('/api/asn-connectivity', requireValidASN(), cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler); app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker); diff --git a/common/guards.js b/common/guards.js index 70f3a6738..733802b61 100644 --- a/common/guards.js +++ b/common/guards.js @@ -90,6 +90,21 @@ export const requireValidASN = (paramName = 'asn') => (req, res, next) => { next(); }; +// Reject requests without a two-letter country code; uppercases in place so +// the edge cache sees one canonical key. Syntactic only — an unassigned code +// just yields an empty upstream series. +export const requireValidCountry = (paramName = 'country') => (req, res, next) => { + const raw = req.query[paramName]; + if (!raw) { + return res.status(400).json({ error: 'No country provided' }); + } + if (!/^[A-Za-z]{2}$/.test(raw)) { + return res.status(400).json({ error: 'Invalid country' }); + } + req.query[paramName] = String(raw).toUpperCase(); + next(); +}; + // Reject requests whose report id doesn't look like one we issued: 16 random // bytes as base64url, always exactly 22 chars. Reads the ROUTE param (not the // query) — the share-report read endpoint is /api/report/:id. diff --git a/frontend/components/ip-infos/CountryTraffic.vue b/frontend/components/ip-infos/CountryTraffic.vue new file mode 100644 index 000000000..ef9e6faf3 --- /dev/null +++ b/frontend/components/ip-infos/CountryTraffic.vue @@ -0,0 +1,170 @@ + + + diff --git a/frontend/components/ip-infos/IPCard.vue b/frontend/components/ip-infos/IPCard.vue index 95a6ab754..8f6486d93 100644 --- a/frontend/components/ip-infos/IPCard.vue +++ b/frontend/components/ip-infos/IPCard.vue @@ -42,7 +42,8 @@ + :configs="configs" :is-dark-mode="isDarkMode" :enable-map="true" + :enable-country-traffic="true" /> diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 71c88fca9..9b518f326 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -17,6 +17,14 @@ {{ data.country_name }} + + + @@ -216,6 +224,11 @@ + + + @@ -261,6 +274,7 @@ import { toBgpPrefix } from '@/utils/bgp-prefix.js'; import { getZoneUtcOffset, getZoneLocalTime } from '@/utils/time-utils.js'; import ASNInfo from './ASNInfo.vue'; import ASNHistory from './ASNHistory.vue'; +import CountryTraffic from './CountryTraffic.vue'; // ASNConnectivity is heavy (dagre + SVG render); async-import so it // only enters the bundle when a user opens the Connectivity panel. import { defineAsyncComponent } from 'vue'; @@ -273,6 +287,7 @@ import { Button } from '@/components/ui/button'; import { Icon } from '@iconify/vue'; import { Earth } from '@lucide/vue'; import { + Activity, Building2, CornerUpRight, Equal, @@ -313,6 +328,8 @@ const props = defineProps({ // IPCard opts in to show the Map button (in the City cell) + the Map Dialog. // QueryIP opts out — the parent is already a Dialog, stacking dialogs is confusing. enableMap: { type: Boolean, default: false }, + // Same opt-in for the Country online-activity button + dialog (Country cell). + enableCountryTraffic: { type: Boolean, default: false }, }); // Consumers rendering this panel inside a dialog listen to close themselves @@ -330,6 +347,7 @@ const openUsageDialog = () => { const activePanel = ref(null); const isPanelOpen = ref(false); const isMapDialogOpen = ref(false); +const isTrafficDialogOpen = ref(false); // The backend sends the zone name only; the offset follows DST, so it is // resolved here rather than travelling through the routes' 24h edge cache. @@ -405,6 +423,17 @@ const openMapDialog = () => { trackEvent('IPCheck', 'ViewOnMapClick', props.data.source || 'unknown'); }; +// Consumer opt-in + a Cloudflare key on the deployment + a country to query. +const canShowCountryTraffic = computed(() => + props.enableCountryTraffic && !props.collapsed + && Boolean(props.configs.cloudFlare) && Boolean(props.data.country_code) +); + +const openTrafficDialog = () => { + isTrafficDialogOpen.value = true; + trackEvent('IPCheck', 'CountryTrafficClick', props.data.country_code || 'unknown'); +}; + // BGP DFZ-floor prefix for the IP — /24 v4, /48 v6. Used both as the query // param sent to /api/asn-history (so CF dedupes across every IP in the same // prefix) and as the local session-cache key. diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0bbf4cecb..e2a2f0a49 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -993,6 +993,7 @@ "CopyIP": "Copy IP Address", "CopySdpLog": "Copy SDP Log", "ViewOnMap": "View location on map", + "ShowCountryTraffic": "Show Online Activity Pattern", "InfoMask": "Hide IP Information", "IPHistory": "View IP History", "ShareReport": "Create Diagnostic Report", @@ -1152,6 +1153,12 @@ "NativeIPYes": "Aligned", "NativeIPNo": "Mismatched", "nativenessTooltip": "Whether the ASN's registered country matches the IP's geolocation country" + }, + "countryTraffic": { + "subtitle": "Typical weekly online activity in this country/region — view human-generated traffic only, or all traffic (including bot/automated traffic).", + "human": "Human Only", + "loading": "Loading...", + "error": "Failed to load online activity." } }, "serviceStatus": { diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b2ce4fceb..8733d1926 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -993,6 +993,7 @@ "CopyIP": "Copier l'adresse IP", "CopySdpLog": "Copier le journal SDP", "ViewOnMap": "Voir l'emplacement sur la carte", + "ShowCountryTraffic": "Voir le rythme d'activité en ligne", "InfoMask": "Masquer les informations IP", "IPHistory": "Voir l'historique des IP", "ShareReport": "Créer un rapport de diagnostic", @@ -1152,6 +1153,12 @@ "NativeIPYes": "Aligné", "NativeIPNo": "Décalé", "nativenessTooltip": "Indique si le pays d'enregistrement de l'ASN correspond au pays de géolocalisation de l'IP" + }, + "countryTraffic": { + "subtitle": "Répartition hebdomadaire de l'activité en ligne de ce pays/région — affichez uniquement le trafic généré par des humains, ou tout le trafic (bots et trafic automatisé inclus).", + "human": "Humains uniquement", + "loading": "Chargement...", + "error": "Échec du chargement de l'activité en ligne." } }, "serviceStatus": { diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index e5173c793..31a7cd373 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -993,6 +993,7 @@ "CopyIP": "Copiar IP", "CopySdpLog": "Copiar log SDP", "ViewOnMap": "Ver localização no mapa", + "ShowCountryTraffic": "Ver padrão de atividade online", "InfoMask": "Ocultar informações de IP", "IPHistory": "Ver histórico de IP", "ShareReport": "Criar relatório diagnóstico", @@ -1152,6 +1153,12 @@ "NativeIPYes": "Alinhado", "NativeIPNo": "Divergente", "nativenessTooltip": "Se o país registrado do ASN corresponde ao país de geolocalização do IP" + }, + "countryTraffic": { + "subtitle": "Distribuição semanal da atividade online deste país/região — veja apenas o tráfego gerado por humanos ou todo o tráfego (incluindo bots/tráfego automatizado).", + "human": "Somente humanos", + "loading": "Carregando...", + "error": "Falha ao carregar a atividade online." } }, "serviceStatus": { diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index dbe4e7ea8..1ccb3a11b 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -993,6 +993,7 @@ "CopyIP": "Копировать IP-адрес", "CopySdpLog": "Копировать журнал SDP", "ViewOnMap": "Показать место на карте", + "ShowCountryTraffic": "Показать график онлайн-активности", "InfoMask": "Скрыть сведения об IP", "IPHistory": "Открыть историю IP", "ShareReport": "Создать диагностический отчёт", @@ -1152,6 +1153,12 @@ "NativeIPYes": "Совпадает", "NativeIPNo": "Не совпадает", "nativenessTooltip": "Совпадает ли страна регистрации ASN со страной геолокации IP" + }, + "countryTraffic": { + "subtitle": "Недельное распределение онлайн-активности в этой стране/регионе — можно смотреть только трафик, создаваемый людьми, или весь трафик (включая ботов и автоматизированный трафик).", + "human": "Только люди", + "loading": "Загрузка...", + "error": "Не удалось загрузить данные об активности." } }, "serviceStatus": { diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 41b5bfe56..8d88c34f4 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -993,6 +993,7 @@ "CopyIP": "複製 IP 位址", "CopySdpLog": "複製 SDP 日誌", "ViewOnMap": "在地圖上檢視位置", + "ShowCountryTraffic": "查看網路活躍時段", "InfoMask": "隱藏資訊", "IPHistory": "檢視 IP 歷史記錄", "ShareReport": "產生診斷報告", @@ -1152,6 +1153,12 @@ "NativeIPYes": "原生", "NativeIPNo": "非原生", "nativenessTooltip": "判斷 ASN 的註冊國家是否與 IP 的歸屬國家一致" + }, + "countryTraffic": { + "subtitle": "該國家/地區的每週網路活躍分布——你可以選擇僅看人類所產生的流量或全部流量(包含機器人/自動化流量)。", + "human": "僅人類流量", + "loading": "載入中...", + "error": "載入網路活躍資料失敗。" } }, "serviceStatus": { diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 62fcd9baf..1293c5c86 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -993,6 +993,7 @@ "CopyIP": "复制 IP 地址", "CopySdpLog": "复制 SDP 日志", "ViewOnMap": "在地图上查看位置", + "ShowCountryTraffic": "查看网络活跃时段", "InfoMask": "隐藏信息", "IPHistory": "查看 IP 历史记录", "ShareReport": "生成诊断报告", @@ -1152,6 +1153,12 @@ "NativeIPYes": "原生", "NativeIPNo": "非原生", "nativenessTooltip": "判断 ASN 的注册国家是否与 IP 的归属国家一致" + }, + "countryTraffic": { + "subtitle": "该国家/地区的每周网络活跃分布—— 你可以选择仅看人类所产生的流量或全部流量(包含机器人/自动化流量)。", + "human": "仅人类流量", + "loading": "加载中...", + "error": "加载网络活跃数据失败。" } }, "serviceStatus": { diff --git a/frontend/utils/time-utils.js b/frontend/utils/time-utils.js index 5bdfbdbd0..93bcb93c0 100644 --- a/frontend/utils/time-utils.js +++ b/frontend/utils/time-utils.js @@ -18,14 +18,15 @@ export const formatUtcOffset = (offsetMinutes) => { return `${sign}${hh}:${mm}`; }; -// Current UTC offset of an IANA zone name ("Asia/Singapore" → "+08:00"), '' for -// a missing or unrecognized zone. The backend sends geo results the zone name -// alone — offsets are computed here, per view, because those routes sit behind -// a 24h edge cache that would keep serving a pre-DST offset after the switch. +// Current UTC offset of an IANA zone in east-positive minutes +// ("Asia/Singapore" → 480), null for a missing or unrecognized zone. The +// backend sends geo results the zone name alone — offsets are computed here, +// per view, because those routes sit behind a 24h edge cache that would keep +// serving a pre-DST offset after the switch. // // `at` exists so tests can pin an instant; callers pass nothing and get now. -export const getZoneUtcOffset = (timezone, at = new Date()) => { - if (!timezone) return ''; +export const getZoneUtcOffsetMinutes = (timezone, at = new Date()) => { + if (!timezone) return null; let label = ''; try { @@ -34,18 +35,25 @@ export const getZoneUtcOffset = (timezone, at = new Date()) => { .find((part) => part.type === 'timeZoneName')?.value || ''; } catch { // Unknown zone name — Intl throws rather than falling back. - return ''; + return null; } // Intl spells the offset "GMT+08:00" / "GMT-03:30", and a bare "GMT" at zero. const match = label.match(/^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/); - if (!match) return ''; + if (!match) return null; const [, sign, hours, minutes] = match; - if (!sign) return formatUtcOffset(0); + if (!sign) return 0; const total = Number(hours) * 60 + Number(minutes || 0); - return formatUtcOffset(sign === '-' ? -total : total); + return sign === '-' ? -total : total; +}; + +// Same offset formatted for display ("Asia/Singapore" → "+08:00"), '' for a +// missing or unrecognized zone. +export const getZoneUtcOffset = (timezone, at = new Date()) => { + const minutes = getZoneUtcOffsetMinutes(timezone, at); + return minutes == null ? '' : formatUtcOffset(minutes); }; // Wall-clock time in an IANA zone right now, localized for the viewer @@ -81,6 +89,18 @@ export const getTimezoneInfo = () => { return { timezone, offset }; }; +// Monday-first localized weekday abbreviations ("Mon" / "周一"), built by +// formatting a fixed known-Monday week (2024-01-01, UTC) so Intl owns the +// wording. Used for the traffic heatmap's row labels. +export const getWeekdayNames = (locale) => { + try { + const fmt = new Intl.DateTimeFormat(locale || undefined, { weekday: 'short', timeZone: 'UTC' }); + return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 1 + i)))); + } catch { + return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + } +}; + /* ------------------------------------------------------------------ */ /* Relative time & durations (Pulse widgets) */ /* ------------------------------------------------------------------ */ diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index fd10a4dd3..82df399d1 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -18,6 +18,7 @@ import dnsResolverHandler from '../api/dns-resolver.js'; import getUserInfoHandler from '../api/get-user-info.js'; import getWhoisHandler from '../api/get-whois.js'; import cfRadarHandler from '../api/cf-radar.js'; +import cfRadarTrafficHandler, { buildTrafficMatrix } from '../api/cf-radar-traffic.js'; import netOutagesHandler from '../api/net-outages.js'; import invisibilityHandler from '../api/invisibility-test.js'; import macCheckerHandler from '../api/mac-checker.js'; @@ -360,6 +361,67 @@ describe('cf-radar handler', () => { }); }); +// -- cf-radar-traffic handler ----------------------------------------------- + +describe('cf-radar-traffic handler', () => { + it('returns 500 when no Cloudflare API key is configured', async () => { + delete process.env.CLOUDFLARE_API_KEY; + delete process.env.CLOUDFLARE_API; + const res = createResponse(); + await cfRadarTrafficHandler(createRequest({ query: { country: 'US' } }), res); + assert.equal(res.statusCode, 500); + assert.deepEqual(res.body, { error: 'API key is missing' }); + }); +}); + +describe('buildTrafficMatrix', () => { + // 28 days of hourly points from Monday 2024-01-01T00:00Z, Radar-style + // string values. `valueAt` receives the point's UTC date. + const makeSerie = (valueAt) => { + const start = Date.UTC(2024, 0, 1); + const timestamps = []; + const values = []; + for (let i = 0; i < 28 * 24; i++) { + const date = new Date(start + i * 60 * 60 * 1000); + timestamps.push(date.toISOString()); + values.push(String(valueAt(date))); + } + return { timestamps, values }; + }; + + it('returns null for a missing or malformed serie', () => { + assert.equal(buildTrafficMatrix(undefined), null); + assert.equal(buildTrafficMatrix({}), null); + assert.equal(buildTrafficMatrix({ timestamps: [], values: [] }), null); + }); + + it('returns null when the serie covers less than a full week', () => { + const serie = makeSerie(() => 0.5); + serie.timestamps = serie.timestamps.slice(0, 100); + serie.values = serie.values.slice(0, 100); + assert.equal(buildTrafficMatrix(serie), null); + }); + + it('aggregates into a Monday-first 7×24 matrix scaled to a max of 1', () => { + // Baseline 0.1 with a spike every Tuesday at 05:00 UTC. + const serie = makeSerie((date) => (date.getUTCDay() === 2 && date.getUTCHours() === 5 ? 1 : 0.1)); + const matrix = buildTrafficMatrix(serie); + assert.equal(matrix.length, 7); + assert.ok(matrix.every((row) => row.length === 24)); + assert.equal(matrix[1][5], 1); // Tuesday is row 1 when Monday-first + assert.equal(matrix[0][0], 0.1); + assert.equal(Math.max(...matrix.flat()), 1); + }); + + it('skips unparsable points without breaking the aggregate', () => { + const serie = makeSerie(() => 0.5); + serie.values[0] = 'not-a-number'; + const matrix = buildTrafficMatrix(serie); + assert.equal(matrix.length, 7); + assert.equal(Math.max(...matrix.flat()), 1); + }); +}); + // -- net-outages handler ---------------------------------------------------- describe('net-outages handler', () => { diff --git a/tests/guards.test.js b/tests/guards.test.js index 9316cb561..68e185713 100644 --- a/tests/guards.test.js +++ b/tests/guards.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { requireReferer, requirePublicIP, requireValidPrefix, requireValidDomain, requireValidProviderId, requireValidRecordType, requireValidReportId } from '../common/guards.js'; +import { requireReferer, requirePublicIP, requireValidPrefix, requireValidDomain, requireValidProviderId, requireValidRecordType, requireValidReportId, requireValidCountry } from '../common/guards.js'; // Minimal (req, res, next) stubs — just enough to observe what the // middleware does. @@ -143,6 +143,36 @@ describe('requireValidPrefix', () => { }); }); +describe('requireValidCountry', () => { + const guard = requireValidCountry(); + + it('calls next() and uppercases the code in place', () => { + let nextCalled = false; + const req = makeReq({ query: { country: 'iq' } }); + guard(req, makeRes(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + assert.equal(req.query.country, 'IQ'); + }); + + it('returns 400 "No country provided" when country is missing', () => { + const res = makeRes(); + let nextCalled = false; + guard(makeReq({ query: {} }), res, () => { nextCalled = true; }); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'No country provided'); + assert.equal(nextCalled, false); + }); + + it('returns 400 "Invalid country" for anything but two letters', () => { + for (const bad of ['USA', 'U', 'U1', 'us,de', 'ÜS']) { + const res = makeRes(); + guard(makeReq({ query: { country: bad } }), res, () => {}); + assert.equal(res.statusCode, 400, bad); + assert.equal(res.body.error, 'Invalid country', bad); + } + }); +}); + describe('requireValidProviderId', () => { const guard = requireValidProviderId(); diff --git a/tests/time-utils.test.js b/tests/time-utils.test.js index acc2fdb40..1f7d96c21 100644 --- a/tests/time-utils.test.js +++ b/tests/time-utils.test.js @@ -12,6 +12,8 @@ import { getTimezoneInfo, getZoneUtcOffset, getZoneLocalTime, + getZoneUtcOffsetMinutes, + getWeekdayNames, relativeTimeFromMinutes, relativeTimeSince, formatDuration, @@ -123,6 +125,46 @@ describe('getTimezoneInfo', () => { }); }); +describe('getZoneUtcOffsetMinutes', () => { + it('returns east-positive minutes for whole and half-hour zones', () => { + const jan = new Date('2026-01-15T12:00:00Z'); + assert.equal(getZoneUtcOffsetMinutes('Asia/Singapore', jan), 480); + assert.equal(getZoneUtcOffsetMinutes('Asia/Kolkata', jan), 330); + assert.equal(getZoneUtcOffsetMinutes('America/New_York', jan), -300); + assert.equal(getZoneUtcOffsetMinutes('UTC', jan), 0); + }); + + it('follows DST at the pinned instant', () => { + assert.equal(getZoneUtcOffsetMinutes('America/New_York', new Date('2026-07-15T12:00:00Z')), -240); + }); + + it('returns null for missing or unknown zones', () => { + assert.equal(getZoneUtcOffsetMinutes(''), null); + assert.equal(getZoneUtcOffsetMinutes('Not/AZone'), null); + }); +}); + +describe('getWeekdayNames', () => { + it('returns seven Monday-first names in English', () => { + const names = getWeekdayNames('en'); + assert.equal(names.length, 7); + assert.equal(names[0], 'Mon'); + assert.equal(names[6], 'Sun'); + }); + + it('localizes for every registered locale without throwing', () => { + for (const code of LOCALE_CODES) { + const names = getWeekdayNames(code); + assert.equal(names.length, 7); + assert.ok(names.every((n) => typeof n === 'string' && n.length > 0), code); + } + }); + + it('starts the Chinese week on 周一', () => { + assert.equal(getWeekdayNames('zh')[0], '周一'); + }); +}); + /* ------------------------------------------------------------------ */ /* Relative time & durations (Pulse widgets) */ /* ------------------------------------------------------------------ */ From d7e46c5dd5ff84e7cc175b3847abe03650e1a5a0 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 26 Aug 2026 18:01:38 +0800 Subject: [PATCH 06/24] Refactor(ipinfo): extract map dialog into LocationMap component Verbatim markup move out of IpDetailPanel so the map lives at the same component layer as CountryTraffic. Co-Authored-By: Claude Fable 5 --- .../components/ip-infos/IpDetailPanel.vue | 33 ++---------- frontend/components/ip-infos/LocationMap.vue | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 30 deletions(-) create mode 100644 frontend/components/ip-infos/LocationMap.vue diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 9b518f326..11fc5fcee 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -230,34 +230,8 @@ :country-name="data.country_name" :timezone="data.timezone" :isDarkMode="isDarkMode" /> - - - - - -
- - - {{ t('ipInfos.Coordinates') }} - - {{ data.longitude }}, {{ data.latitude - }} -
- - Map - -
-
+ From 4af258a069ce6994afce399aceba84a798e17744 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 26 Aug 2026 18:18:57 +0800 Subject: [PATCH 07/24] Feat(ipinfo): expose map and traffic panels inline in QueryIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split CountryTraffic and LocationMap into bare content panels plus dialog shells, and turn the enableMap/enableCountryTraffic booleans into mapMode/countryTrafficMode ('dialog' | 'inline' | 'off'). IPCard keeps the dialogs; QueryIP — itself a Dialog, where stacking another one is confusing — now offers both affordances as a single-select Collapsible under the metadata grid, mirroring its ASN panels. Co-Authored-By: Claude Fable 5 --- .../components/ip-infos/CountryTraffic.vue | 146 +---------------- .../ip-infos/CountryTrafficPanel.vue | 154 ++++++++++++++++++ frontend/components/ip-infos/IPCard.vue | 4 +- .../components/ip-infos/IpDetailPanel.vue | 85 +++++++--- frontend/components/ip-infos/LocationMap.vue | 22 +-- .../components/ip-infos/LocationMapPanel.vue | 30 ++++ frontend/components/widgets/QueryIP.vue | 6 +- frontend/data/changelog.json | 11 ++ 8 files changed, 271 insertions(+), 187 deletions(-) create mode 100644 frontend/components/ip-infos/CountryTrafficPanel.vue create mode 100644 frontend/components/ip-infos/LocationMapPanel.vue diff --git a/frontend/components/ip-infos/CountryTraffic.vue b/frontend/components/ip-infos/CountryTraffic.vue index ef9e6faf3..72d88aa99 100644 --- a/frontend/components/ip-infos/CountryTraffic.vue +++ b/frontend/components/ip-infos/CountryTraffic.vue @@ -1,6 +1,6 @@ -

{{ t('ipInfos.countryTraffic.subtitle') }}

- -
- {{ t('ipInfos.countryTraffic.error') }} -
-
- {{ t('ipInfos.countryTraffic.loading') }} -
-
- -
- - -
-
- {{ weekdayNames[d] - }} -
-
-
-
-
- -
- -
- - {{ (h - 1) % 6 === 0 ? String(h - 1).padStart(2, '0') : '' }} - -
-
- - - UTC{{ heatmapUtcOffset }} -
+
diff --git a/frontend/components/ip-infos/CountryTrafficPanel.vue b/frontend/components/ip-infos/CountryTrafficPanel.vue new file mode 100644 index 000000000..9ce42f584 --- /dev/null +++ b/frontend/components/ip-infos/CountryTrafficPanel.vue @@ -0,0 +1,154 @@ + + + diff --git a/frontend/components/ip-infos/IPCard.vue b/frontend/components/ip-infos/IPCard.vue index 8f6486d93..f25261e8b 100644 --- a/frontend/components/ip-infos/IPCard.vue +++ b/frontend/components/ip-infos/IPCard.vue @@ -42,8 +42,8 @@ + :configs="configs" :is-dark-mode="isDarkMode" map-mode="dialog" + country-traffic-mode="dialog" /> diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 11fc5fcee..85a408980 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -20,7 +20,8 @@ @@ -47,7 +48,9 @@ @@ -75,6 +78,20 @@ + + + +
+
+ + +
+
+
+
+
@@ -224,18 +241,18 @@
- - + - - + diff --git a/frontend/components/widgets/QueryIP.vue b/frontend/components/widgets/QueryIP.vue index 565633078..f98652409 100644 --- a/frontend/components/widgets/QueryIP.vue +++ b/frontend/components/widgets/QueryIP.vue @@ -45,8 +45,8 @@ + :configs="configs" :is-dark-mode="isDarkMode" map-mode="inline" + country-traffic-mode="inline" @view-usage="onOpenChange(false)" /> @@ -57,7 +57,7 @@ // QueryIP — manual IP lookup. Shares IpDetailPanel with IPCard so the info display stays in sync. // Differences from IPCard: // - No Copy button (the IP was typed by the user — copying it is pointless). -// - No Map button (Dialog-in-Dialog stacking is avoided; enableMap=false). +// - Map & country-traffic expand inline instead of opening dialogs (this IS a Dialog already). // - The panel's "view usage" link closes this dialog first, for the same reason. // - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard). import { ref, computed, watch, nextTick } from 'vue'; diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 63d987d9c..4d7a033a8 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -2040,6 +2040,17 @@ "ru": "Исправлены некоторые проблемы", "pt-BR": "Corrigidos alguns problemas" } + }, + { + "type": "add", + "change": { + "en": "Added a country online-activity heatmap: see when an IP's country is online by weekday and hour, with a human-only traffic switch", + "zh": "新增国家/地区网络活跃时段热力图:按星期和小时查看 IP 所在国家的活跃分布,可切换仅看人类流量", + "zh-TW": "新增國家/地區網路活躍時段熱力圖:依星期與小時查看 IP 所在國家的活躍分布,可切換僅看人類流量", + "fr": "Ajout d'une carte thermique de l'activité en ligne par pays : répartition par jour de la semaine et par heure, avec option trafic humain uniquement", + "ru": "Добавлена тепловая карта онлайн-активности страны: распределение по дням недели и часам, с переключателем «только люди»", + "pt-BR": "Adicionado mapa de calor da atividade online por país: distribuição por dia da semana e hora, com opção de tráfego somente humano" + } } ] } From 3c68dba4900627f0c248745b86ae055e587dd876 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 26 Aug 2026 18:20:23 +0800 Subject: [PATCH 08/24] Improvements --- frontend/data/changelog.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 4d7a033a8..e7336700e 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1975,6 +1975,17 @@ "version": "v7.5.0", "date": "Beta", "content": [ + { + "type": "add", + "change": { + "en": "Added a country online-activity heatmap: see when an IP's country is online by weekday and hour, with a human-only traffic switch", + "zh": "新增国家/地区网络活跃时段热力图:按星期和小时查看 IP 所在国家的活跃分布,可切换仅看人类流量", + "zh-TW": "新增國家/地區網路活躍時段熱力圖:依星期與小時查看 IP 所在國家的活躍分布,可切換僅看人類流量", + "fr": "Ajout d'une carte thermique de l'activité en ligne par pays : répartition par jour de la semaine et par heure, avec option trafic humain uniquement", + "ru": "Добавлена тепловая карта онлайн-активности страны: распределение по дням недели и часам, с переключателем «только люди»", + "pt-BR": "Adicionado mapa de calor da atividade online por país: distribuição por dia da semana e hora, com opção de tráfego somente humano" + } + }, { "type": "add", "change": { @@ -2040,17 +2051,6 @@ "ru": "Исправлены некоторые проблемы", "pt-BR": "Corrigidos alguns problemas" } - }, - { - "type": "add", - "change": { - "en": "Added a country online-activity heatmap: see when an IP's country is online by weekday and hour, with a human-only traffic switch", - "zh": "新增国家/地区网络活跃时段热力图:按星期和小时查看 IP 所在国家的活跃分布,可切换仅看人类流量", - "zh-TW": "新增國家/地區網路活躍時段熱力圖:依星期與小時查看 IP 所在國家的活躍分布,可切換僅看人類流量", - "fr": "Ajout d'une carte thermique de l'activité en ligne par pays : répartition par jour de la semaine et par heure, avec option trafic humain uniquement", - "ru": "Добавлена тепловая карта онлайн-активности страны: распределение по дням недели и часам, с переключателем «только люди»", - "pt-BR": "Adicionado mapa de calor da atividade online por país: distribuição por dia da semana e hora, com opção de tráfego somente humano" - } } ] } From 57af5ff3270d39c49e6c60ac0c301d4ac351ffcd Mon Sep 17 00:00:00 2001 From: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:58:29 +0800 Subject: [PATCH 09/24] Feat(connectivity): add Music & Audio list Co-authored-by: OpenAI Codex --- frontend/data/connectivity-import-lists.js | 18 ++++++++++++++++++ frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/locales/pt-BR.json | 1 + frontend/locales/ru.json | 1 + frontend/locales/zh-TW.json | 1 + frontend/locales/zh.json | 1 + public/favicons/apple-music.png | Bin 0 -> 890 bytes public/favicons/audiomack.png | Bin 0 -> 570 bytes public/favicons/deezer.png | Bin 0 -> 2588 bytes public/favicons/genius.png | Bin 0 -> 697 bytes public/favicons/iheart.png | Bin 0 -> 903 bytes public/favicons/lastfm.png | Bin 0 -> 669 bytes public/favicons/mixcloud.png | Bin 0 -> 640 bytes public/favicons/pandora.png | Bin 0 -> 6639 bytes public/favicons/qobuz.png | Bin 0 -> 673 bytes public/favicons/tidal.png | Bin 0 -> 1747 bytes public/favicons/tunein.png | Bin 0 -> 454 bytes public/favicons/youtube-music.png | Bin 0 -> 741 bytes 19 files changed, 24 insertions(+) create mode 100644 public/favicons/apple-music.png create mode 100644 public/favicons/audiomack.png create mode 100644 public/favicons/deezer.png create mode 100644 public/favicons/genius.png create mode 100644 public/favicons/iheart.png create mode 100644 public/favicons/lastfm.png create mode 100644 public/favicons/mixcloud.png create mode 100644 public/favicons/pandora.png create mode 100644 public/favicons/qobuz.png create mode 100644 public/favicons/tidal.png create mode 100644 public/favicons/tunein.png create mode 100644 public/favicons/youtube-music.png diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index 9ddd093bc..6ed185c63 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -238,6 +238,24 @@ export const IMPORT_LISTS = [ { id: 'dailymotion', name: 'Dailymotion', url: 'https://www.dailymotion.com/favicon.ico' }, ], }, + { + id: 'music', + emoji: '🎵', + members: [ + { id: 'tidal', name: 'TIDAL', url: 'https://tidal.com/favicon.ico' }, + { id: 'deezer', name: 'Deezer', url: 'https://www.deezer.com/favicon.ico' }, + { id: 'tunein', name: 'TuneIn', url: 'https://tunein.com/favicon.ico' }, + { id: 'youtube-music', name: 'YouTube Music', url: 'https://music.youtube.com/favicon.ico' }, + { id: 'pandora', name: 'Pandora', url: 'https://www.pandora.com/favicon.ico' }, + { id: 'lastfm', name: 'Last.fm', url: 'https://www.last.fm/favicon.ico' }, + { id: 'audiomack', name: 'Audiomack', url: 'https://audiomack.com/favicon.ico' }, + { id: 'genius', name: 'Genius', url: 'https://genius.com/favicon.ico' }, + { id: 'apple-music', name: 'Apple Music', url: 'https://music.apple.com/favicon.ico' }, + { id: 'mixcloud', name: 'Mixcloud', url: 'https://www.mixcloud.com/favicon.ico' }, + { id: 'qobuz', name: 'Qobuz', url: 'https://www.qobuz.com/favicon.ico' }, + { id: 'iheart', name: 'iHeartRadio', url: 'https://www.iheart.com/favicon.ico' }, + ], + }, { id: 'gaming', emoji: '🎮', diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 86c8f8d75..a5c85868e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1250,6 +1250,7 @@ "social": "Social & Messaging", "productivity": "Work & Productivity", "streaming": "Streaming", + "music": "Music & Audio", "gaming": "Gaming", "developer": "Developer", "cloud": "Cloud & CDN", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 6f4ebc210..41a132512 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1250,6 +1250,7 @@ "social": "Réseaux sociaux", "productivity": "Travail et productivité", "streaming": "Streaming", + "music": "Musique et audio", "gaming": "Jeux vidéo", "developer": "Développeurs", "cloud": "Cloud et CDN", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 6776fa06d..dfaea8c5e 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1250,6 +1250,7 @@ "social": "Redes sociais e mensagens", "productivity": "Trabalho e produtividade", "streaming": "Streaming", + "music": "Música e áudio", "gaming": "Jogos", "developer": "Desenvolvimento", "cloud": "Nuvem e CDN", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 48a997db1..d6a6ae00d 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1250,6 +1250,7 @@ "social": "Соцсети", "productivity": "Работа и продуктивность", "streaming": "Стриминг", + "music": "Музыка и аудио", "gaming": "Игровые платформы", "developer": "Для разработчиков", "cloud": "Облака и CDN", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 4a14b64af..c560c3453 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1250,6 +1250,7 @@ "social": "社交通訊", "productivity": "工作與生產力", "streaming": "流媒體", + "music": "音樂與音訊", "gaming": "遊戲平臺", "developer": "開發者", "cloud": "雲與 CDN", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 4d99cc370..493c0372a 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1250,6 +1250,7 @@ "social": "社交通讯", "productivity": "工作与生产力", "streaming": "流媒体", + "music": "音乐与音频", "gaming": "游戏平台", "developer": "开发者", "cloud": "云与 CDN", diff --git a/public/favicons/apple-music.png b/public/favicons/apple-music.png new file mode 100644 index 0000000000000000000000000000000000000000..f570fff81069a17d040f8ab118829afcfe99d3eb GIT binary patch literal 890 zcmV-=1BLvFP)HU+R{>aY!bAi>V}21EozYr8XhsURUa1lqAeh*McsS(ZJo zg&tW|mgJ~v_(;{?!Vie-^Y~%N_V{OOG_-Fu*kbf_^Sjug4O*{B!F!uS^?u?rnSCg~ zjqWio{e4#bmum6SDYW<8J4IuVo&8ewLU}t}E!)s&n^iAWF~snsltu zSo^+Rhij<16Dd{G){C`($g3h*Iy{*70`Mfybo_FNT>;Gj&?f<}bo>FJI-;v%0@F=E zhX7^eZcrD1(6>`WrSuNq=^p{(jNi;C4*;4{Jw>t8i!uo_0%BJ}Rif+w6DymhMqgQThbM5g2bR-Dbjr#az<Cf99F={1GkCk3xEf21=_5wY&S0g3>9pnUC0Bpmo@Czapd7BSO%@cyNZbAF;Q7NQ&7V!Z07*qoM6N<$f<~L1zyJUM literal 0 HcmV?d00001 diff --git a/public/favicons/audiomack.png b/public/favicons/audiomack.png new file mode 100644 index 0000000000000000000000000000000000000000..af739b9dcddd3c8b284a9ebcd7028001c91150b9 GIT binary patch literal 570 zcmV-A0>%A_P)y-i^9vSwW0X8cahFT6+J`*h^ z7@TJdlVJ>POA)$w2-b-OPB;`XC>N=23UW>m&4dQReFrxy7mZ#GM>G{vJQU}7gi8Pb z0gp*UK~z|U?UmVbgD?z5CCqL^SP}?Jn&kg~vXbnyFC_&0Snf;MfI%v_Z3pD+ z8vrH&Y*@yxMnY!TN8_6S+K>HboF(2}@3T?{_fP%Q^0J`VMB-BrOT>KLT2Q^xd91k=TxE<`jl z&uEN$yM6K>KY`Ib3WBlk|Dp_4eU&$~ZM!~k&h%@!9YzsT*0JUps)Z~bsp;>T?6@d& z*_(`$*9?~(C6pzAld-+B>lOohd#WRHZ7^LGz6W%6wPokf)_+i#2|8M$F5bIIv5sG$ zGOz$jj|wcnqXJMjcem_T)}i+LC<9zQK8S}79r`c&27v(%90g~8w*UYD07*qo IM6N<$f{*U(>Hq)$ literal 0 HcmV?d00001 diff --git a/public/favicons/deezer.png b/public/favicons/deezer.png new file mode 100644 index 0000000000000000000000000000000000000000..1023083398b8a00feaa17cd06e42c7ab54b25545 GIT binary patch literal 2588 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 literal 0 HcmV?d00001 diff --git a/public/favicons/iheart.png b/public/favicons/iheart.png new file mode 100644 index 0000000000000000000000000000000000000000..1577d426981d293ade765c14ca2cb8b6e1f7f122 GIT binary patch literal 903 zcmV;219<$2P)Px%%}GQ-R9J=WR@;`VAPfzlf}nV<_0raM+Rp!f;z__d7qv6z z?X0t!7gqz>+1VsefS0}OW&f&;McZ|4F@E)yZ(Vooho8gi#>Og1l(bDTJM)7KoKv6M z=i&KLhitM?-*WHmvgqODQWQ_(%Rn%uk`IU)oz-4`gmcF2eVcxU>p_2=5m;70bry4c zQ>Vx!IBoIQoUzz|iD|QEZHJqS7gSk0POXwTgo}%0*$CRj1$({Ae%H z=IONL^qc^&O47nE0q)6zA5(9zqom?d9W_-}RRpdL$WKe(L`*s-1&QdGOmjo^;L($a zSiA|=Ma(m|OdmSNCZTpVw9+6Oj`G|PN474-+A%uTnDdSWhuDaFMPa-49txVv68eDJ z3V;N$;|hQw3TNN!636CFl?xS7`{2!BsHm)l4gu8qL?8Up;D+)7PBU+)YVQ=h`ZRns z9w4Q0ETFWA5HVsRVjU3WD5VAq4p7ngX5|JzuDCm=GMc6+xRm42O6m!kY$U!%LG0T` zJj$TyN-BNpVZ7IRNZdqH_uV2l(%VMtxxDz>Xg=)TUyA&g3RlN7GXaV2rIYjhJKd^O zmEOKmy$zDj*N}NQ)7fXQKg1(~;M)LC^!u>wgiCkK^tDvB?V|+%*IWI!YB%)_fXC+9{-rhQ3jkNY_-pXhcJdQ|2>&`BZ`t?x`Dwz{$M9t@ dd)fbGe*nF4BDf7PA)EjJ002ovPDHLkV1k%J#n1o% literal 0 HcmV?d00001 diff --git a/public/favicons/lastfm.png b/public/favicons/lastfm.png new file mode 100644 index 0000000000000000000000000000000000000000..b5b91095fede4acd47fa1839b1d563686ead4cd9 GIT binary patch literal 669 zcmV;O0%HA%P)q+4{ZiZg)7aHY#O>p~ zt2}Nw{_jk9vOnJcY`jvG!>Rnq9w+r%Eh#C9S4x;bO5rI3JTF%n;40}svWHN^sNxUs zjR1sfc;J5vKnR2Gl4;|LwFo2mt(j^>4FFHkY_Gf&f-T$*jsW1g+B7Z2v35_;2JGT9 z0r;2Ac^qZHL9zHK1^pNz0baasG;;UQ0)XKVBsGcPQGs5AXpwFLu&bT{6>dPmocTDn z6`eIh@2~-YX$}a>GtNjcfR)3|h!lWrau~Q?DxnM8+1dhh7OnPZfS%PIoZ3MOz^=ut zzDHd9YRy<9wg6W>EjllJ~qFnkkWw<0R#i@A&4 z^OITUa}UV*?rP{3Uu4YN*d}1eqq}%=ABxfE1J?qu)JqBoX^JW3aSiZ?02U$E2lK$j zcZfqyA)Q1z3Z!eZgm_N4&(33*G&hi6QT7!g)Q_a1X#(=;DJ(V6Zk@GI^($>lh({Pf z@8a8oG!v$Bol!`_*GcGuQ6>U7L!CAguKfr-d84}zM~7)y-S`7poo|lwN&5l50wDg- z6o2Z9Keol6`{E0S{K_M~bjh!M;)|#J>MOr|ORoR_Eqxy+My<#u00000NkvXXu0mjf DGaNcv literal 0 HcmV?d00001 diff --git a/public/favicons/mixcloud.png b/public/favicons/mixcloud.png new file mode 100644 index 0000000000000000000000000000000000000000..e32edd6259675ca51fbfba4b85342d5165c15822 GIT binary patch literal 640 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!bOU@sT-^(N0vP^7P#~BAA%P-5 z1`q{<$sn-Qn(U9pK#hDQL4LsuuSGs{{NlT^xqO;>l*6OurU?uTj9)xm977^nuTH-` ztyzJ`mCGWb4@k^4>HQsnYW~?>TUAi z^IkPYh^69g>0NgVh1sWfynL-VnQ5Ab^l!OH8NTVS{kAi)o!IC4(!ihL`5h%o8(9YX z98Dwnpo)44-7jt1UdFH2zS4-Eob;1 zmN@a%SNUtqD-!NAlp63eaIX$~&A7l-b{c!bThHaw*&SN1+DM5r+?t%>rN|Ir^718< zL!xQwG1i7}M`j1{Y!J(>`ps~8Ue3nvjL(i-^J2L0J9p(bMzy5dMYRr>&gJB>FD0;*SqJIYG`I?nL%xEwE| zB15m(o^$REx0T}moKI(Icr5z9RL0@39CHI>{=>rw3tw~g&%S6O#lJFsOC4imROZq7 TbKLF$QwD>ltDnm{r-UW|_r(zX literal 0 HcmV?d00001 diff --git a/public/favicons/pandora.png b/public/favicons/pandora.png new file mode 100644 index 0000000000000000000000000000000000000000..02cec4137577266173c7b5bf3c4a53b92b38116b GIT binary patch literal 6639 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 literal 0 HcmV?d00001 diff --git a/public/favicons/qobuz.png b/public/favicons/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..4d7e15ddefceb8dca84e9ca3752caf31b716cbd4 GIT binary patch literal 673 zcmV;S0$%-zP)j@F1qRKvluCdJvht}21~)pc_yt(^HtVl9P!gpRu)MI@<+wvt%h>gZVDM0rEB+1s zGIq#2xZ**)-E;XmSB?Z=BlY5=@F z38pX4(gT70Clf^2n1sbR~~{>4Rtb=$kxzr;Yz#ux}JISa~_A00000NkvXX Hu0mjfA{abW literal 0 HcmV?d00001 diff --git a/public/favicons/tidal.png b/public/favicons/tidal.png new file mode 100644 index 0000000000000000000000000000000000000000..63a84997a61ea4c421a0469dea0fa5e6a4e0c0a8 GIT binary patch literal 1747 zcmZ`)XH*ji5>7QFGM^_+c{-bP|xM17;?0LLW#^3MlPv|hz&I=HKwAVclR5wGL$>5-jrzs-H#ZK&}b zrtD19!>2zEzI3WwF0@+wVP@+Sx$%2nVFvHc|MAXxjT*C``F>K>Do%BEtkbMKd((E)8rNv6%`yTkjZ?wn)$PPyE{@OR!k%3a8Odk?e{kAcpC) z($a?J<`fO0?x8j_uB(Pq(6KsggO=Br+_MIdWCcez+LkxCc&FO0V^bngkVwnJ#{uh- z)yd}P&yDSk16x{KfiYC7{hRY%p!<}QTX1jy_vpVj9L?2(fsUxOKFUhTv_{`7dh`f| zLRIQb@_6^BSP@CP+ue!&>UycJML!ms!(cE?-S;#z6B84EtFvbTCRj)5w>F8g^}kZ` z^70z=&-7g5PfkuwO(g^byb>l3I0O93&tb7x0|S`k8rjqN0T3B*I2AH*x`)fi>|ESdivRX&7kTQ{{ibB1ZH!#d#%7I+x}+aNI5aI8=j;AxT_ z)TDDapod3vbu9x8JV(B0pbl}l@OCg5TyfslSJ#}lO2C~)H)Ld}?z@mkD{E`?f^%2k zHLMY_?XW&D5?i4sc3#xg)_%_6^l0Rad^$yJa-&d2J3H~Ap$6x&mH4=rH%hr-yAu-= z#l;xL=u^u;4Xu(U)|7Uz?J ztT)MHvv&6a0s;&R{ry2k)>dMENl=2dV?(Xg!-K&`-I^D?8*iczZW#a3G;GJ}B zrglD{(`^goxwYBZ-zLy9z*~r)k#7os})`L!U z{?gij*Fq~@#SxnN?qw^UMn81qvrbH#hAa=qrZSm?ukOS@3vD-qMyjhb*{vxNu9nEh zT?X;jHJph2&J_rG*|WMv;gY}_7HPCbjKT7=X5t&EAU4cI87UH2GYOLZzm;`;{8%#h z?)TO$FEd~ZEFnBRJTdXdQOG!ATH;R;v#yoB|udt_UTb^vNJ zr^vLE^?nP9j$ZukA`l?1=>C!#cyE(v>P=i8l37$XScZ%_z~?W}tD&JoJpEP;{{XUN^ znTG|ngr{wW213>U*l1Xg>O%%HwCo$4BRM#?sX;x9oCg{b@8*2&X?D@brxus_DJAQ%u?qXK(TBSEN;+s>haF-l4W9$Z6$?ppV6 wAjwR@uH;J)esHq)$07*qoM6N<$g2!99-v9sr literal 0 HcmV?d00001 diff --git a/public/favicons/youtube-music.png b/public/favicons/youtube-music.png new file mode 100644 index 0000000000000000000000000000000000000000..8b4d87c59093939cd87f41c053fb0b2ae4a7409f GIT binary patch literal 741 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!0t0+PT-^(N{xcZkL;wH(XVCr6 zpz@zV5?G7 zV1_oo))Kv?d}rSS4M7163Fr5gFfcG_c)B=-M6@mqzqx3&0?!)TJk>W5+5i8$=U-XO z+wERG*?hWWno~o;?x~Bj--X_`XF0Ywe|7a`**_6|&%}Rorex|Ze$BFH@|AFlU9TAS z1%#yg?fNCL*X^&m>-G5$TVMQ7u#o*1enjx!bE&wF`9}Ym{@iurydQU*`(m72{HboK zqW?jfYg@NEWiKt7rNSTiZ)r)L$%Yomy{D(T6kl9g@OB=zm4vWZ-}1|vtSj5M)Gd)t z>)D>c#2aD1nO84SK~z+FLe-JLy}vEOPgRB(t0}$O#Q0N6!N=&Fw4EPgTlkp`o+}o` zw=#YXQ{w)5j9)2fMOcmV_4PH95+RKC(vk;Uq~v_(G1{g$DHk^{%fF`M!sKSqy6WaW zE#_pE#6pMv1&VL}aofC-buyXlyQ$;Cfh`Z$YNr-;`EDr`^km=g`L@B!IZjLgoDn`H zd{2b`qm-dbk!9nK76vwbZnymk2G3dkXEx3~lD|A~$>kGzDrK6R#f7Ua<#RPY zy=psj@6wOiTGi3d<#paH?ycSNssHO;N&kLi$^QA(?xGEP_g7C|ef@v^oRIE$lad_{1Cthmr>mdKI;Vst E04iZxg#Z8m literal 0 HcmV?d00001 From 78724bafb1a917a7d4fbe5178f1ff24cd655e734 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 27 Aug 2026 09:09:04 +0800 Subject: [PATCH 10/24] Improvements --- frontend/components/DnsLeaksTest.vue | 1 - frontend/locales/en.json | 17 ++++++++--------- frontend/locales/fr.json | 17 ++++++++--------- frontend/locales/pt-BR.json | 17 ++++++++--------- frontend/locales/ru.json | 17 ++++++++--------- frontend/locales/zh-TW.json | 17 ++++++++--------- frontend/locales/zh.json | 17 ++++++++--------- 7 files changed, 48 insertions(+), 55 deletions(-) diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index cf026be13..07a5868a3 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -17,7 +17,6 @@

{{ t('dnsleaktest.Note') }}

-

{{ t('dnsleaktest.Note2') }}

diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 86c8f8d75..bf9144db3 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -854,9 +854,9 @@ "OpenInNewTab": "Open in new tab", "BackToHome": "Back to Home", "Title": "Advanced Tools", - "Note": "Tools that are used relatively infrequently, but are very useful when performing network testing.", - "PingTestNote": "Global Ping value test", - "MTRTestNote": "Global MTR route test", + "Note": "People who want the very best from their network usually reach for extra tools to fine-tune their connection.", + "PingTestNote": "Ping your IP from around the world", + "MTRTestNote": "Trace your network route from around the world", "DNSResolverNote": "Real-time multi-channel DNS resolution", "EnhancedDnsLeakTest": "Captures every recursive resolver, with ECS and DNSSEC analysis", "RuleTestNote": "Check the rule settings of proxy software", @@ -1083,8 +1083,8 @@ }, "ipInfos": { "Title": "IP Infos", - "Notes": "The program will first check your IP address from different sources (including IPv4 and IPv6), and then query the corresponding geographical data from the IP geolocation source you selected. If there is only 1 IP stack, the source without data will be displayed as empty.", - "Source": "IP Source", + "Notes": "The program will first check your IP address from several different probes (including IPv4 and IPv6), and then query the corresponding geographical data from the IP lookup data source you selected. If there is only 1 IP stack, the source without data will be displayed as empty.", + "Source": "IP Probe", "Country": "Region", "Region": "State", "City": "City", @@ -1203,7 +1203,7 @@ }, "connectivity": { "Title": "Network Connectivity", - "Note": "This test checks whether you can reach a given website. It works by loading a small image from that site. Delay values are for reference only and will be smaller in reality. You can add other websites you frequently use to the test list for quick testing.", + "Note": "This test checks your connectivity to a website. The higher the latency, the slower your connection to that site. You can import or add other websites you frequently use to the test list for quick testing.", "StatusWait": "Awaiting Test", "StatusAvailable": "OK", "StatusUnavailable": "Unavailable", @@ -1262,7 +1262,7 @@ "id": "webrtc", "Title": "WebRTC Leak Test", "Name": "WebRTC Connection", - "Note": "WebRTC often establishes connections directly via UDP. If the test returns your real IP, it means your proxy settings do not cover these connections. In addition to detecting the IP you use when connecting to WebRTC, we also detect your NAT type. However, NAT type detection is not 100% accurate and is for reference only.", + "Note": "When the browser obtains network addresses through WebRTC, it may bypass your VPN or proxy and expose your real IP to websites. This test checks which IP is exposed when you connect over WebRTC; if the returned IP address or region is not what you expect, there may be a risk of WebRTC leaks and you may need to adjust your proxy settings. Besides the IP used for WebRTC connections, we also detect your NAT type.", "StatusWait": "Awaiting Test", "StatusError": "Connection Error", "StatusUnavailable": "WebRTC Unavailable", @@ -1278,8 +1278,7 @@ "dnsleaktest": { "Name": "DNS Endpoint Test", "Title": "DNS Leak Test", - "Note": "A DNS leak means that when you are connected to a VPN/proxy, your domain name resolutions are still done through your local ISP, thus posing a risk of DNS leaks.", - "Note2": "The method to test for DNS leaks involves accessing a newly generated domain name to detect which regional DNS exit you are resolving through. If the endpoint region returned matches your local service provider's region, there is a risk of DNS leakage. In this case, you may need to modify your VPN/proxy settings.", + "Note": "Even while you are using a VPN or proxy, DNS queries may still be sent over your local network, exposing the domains of the sites you visit. ISPs, firewalls, public Wi-Fi providers and others along the path can intercept them and learn which sites you are visiting. This test visits a newly generated domain name to detect which region your DNS queries go through; if the returned region differs from the region of the VPN/proxy you are using, there may be a risk of DNS leaks.", "EndpointCountry": "Endpoint Region", "StatusWait": "Awaiting Test", "StatusError": "Test Error" diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 6f4ebc210..7c2f7e346 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -854,9 +854,9 @@ "OpenInNewTab": "Ouvrir dans un nouvel onglet", "BackToHome": "Retour à l'accueil", "Title": "Outils avancés", - "Note": "Certains outils sont utilisés relativement rarement, mais sont très utiles lors des tests de réseaux.", - "PingTestNote": "Test de la valeur Ping mondiale", - "MTRTestNote": "Test de route MTR mondial", + "Note": "Ceux qui exigent le meilleur de leur réseau utilisent souvent davantage d'outils pour optimiser leur connexion.", + "PingTestNote": "Pinguez votre IP depuis le monde entier", + "MTRTestNote": "Tracez votre route réseau depuis le monde entier", "DNSResolverNote": "Résolution DNS multi-canal en temps réel", "EnhancedDnsLeakTest": "Capture tous les résolveurs récursifs avec analyse ECS et DNSSEC", "RuleTestNote": "Vérifiez les paramètres de règles du logiciel de proxy", @@ -1083,8 +1083,8 @@ }, "ipInfos": { "Title": "Infos IP", - "Notes": "Le programme vérifie d'abord votre adresse IP à partir des sources différentes (y compris IPv4 et IPv6), puis interroge les données géographiques correspondantes à partir de la source d'informations de localisation IP que vous avez sélectionnée. Si vous n'avez qu'une seule pile IP active, les sources de données non disponibles seront affichées comme vides.", - "Source": "Source IP", + "Notes": "Le programme vérifie d'abord votre adresse IP à partir de plusieurs sondes différentes (y compris IPv4 et IPv6), puis interroge les données géographiques correspondantes auprès de la source de données de localisation IP que vous avez sélectionnée. Si vous n'avez qu'une seule pile IP active, les sources de données non disponibles seront affichées comme vides.", + "Source": "Sonde IP", "Country": "Région", "Region": "État", "City": "Ville", @@ -1203,7 +1203,7 @@ }, "connectivity": { "Title": "Connectivité réseau", - "Note": "Ce test vérifie si vous pouvez atteindre un site web donné. Il fonctionne en chargeant une petite image depuis ce site. Les valeurs de délai sont données à titre indicatif seulement et seront plus petites en réalité. Vous pouvez ajouter d'autres sites web que vous utilisez fréquemment à la liste de tests pour des tests rapides.", + "Note": "Ce test mesure votre connectivité avec un site web : plus la latence est élevée, plus votre connexion à ce site est lente. Vous pouvez importer ou ajouter d'autres sites que vous utilisez fréquemment à la liste de tests pour des vérifications rapides.", "StatusWait": "En attente du test", "StatusAvailable": "OK", "StatusUnavailable": "Non disponible", @@ -1262,7 +1262,7 @@ "id": "webrtc", "Title": "Test WebRTC Leak", "Name": "Connexion WebRTC", - "Note": "WebRTC établit souvent des connexions directes via UDP. Si le test renvoie votre véritable adresse IP, cela signifie que vos paramètres de proxy ne couvrent pas ces connexions. En plus de détecter l'adresse IP que vous utilisez lors de la connexion à WebRTC, nous détectons également votre type de NAT. Cependant, la détection du type de NAT n'est pas précise à 100% et est fournie à titre indicatif uniquement.", + "Note": "Lorsque le navigateur obtient des adresses réseau via WebRTC, il peut contourner votre VPN ou votre proxy et exposer votre véritable adresse IP aux sites web. Ce test vérifie quelle IP est exposée lors de vos connexions WebRTC ; si l'adresse IP ou la région renvoyée ne correspond pas à ce que vous attendez, il peut y avoir un risque de fuite WebRTC et vous devrez peut-être ajuster les paramètres de votre logiciel proxy. Outre l'adresse IP utilisée pour les connexions WebRTC, nous détectons également votre type de NAT.", "StatusWait": "En attente du test", "StatusError": "Erreur de connexion", "StatusUnavailable": "WebRTC indisponible", @@ -1278,8 +1278,7 @@ "dnsleaktest": { "Name": "Test de point de DNS", "Title": "Test de fuite DNS", - "Note": "Une fuite DNS signifie que lorsque vous êtes connecté à un VPN/proxy, vos résolutions de noms de domaine sont toujours effectuées via votre fournisseur d'accès Internet local, ce qui pose un risque de fuite DNS.", - "Note2": "La méthode de test des fuites DNS consiste à accéder à un nom de domaine nouvellement généré pour détecter la région DNS de sortie à laquelle vous effectuez une résolution. Si la région de l'extrémité renvoyée correspond à la région de votre fournisseur de services local, il y a un risque de fuite DNS. Dans ce cas, vous devrez peut-être modifier vos paramètres VPN/proxy.", + "Note": "Même lorsque vous utilisez un VPN ou un proxy, les requêtes DNS peuvent encore transiter par votre réseau local et révéler les noms de domaine des sites que vous consultez. Les fournisseurs d'accès, les pare-feux ou les opérateurs de Wi-Fi public présents sur le trajet peuvent les intercepter et savoir quels sites vous visitez. Ce test accède à un nom de domaine nouvellement généré pour détecter la région par laquelle vos requêtes DNS passent ; si la région renvoyée diffère de celle du VPN/proxy que vous utilisez, il peut y avoir un risque de fuite DNS.", "EndpointCountry": "Région de l'extrémité", "StatusWait": "En attente du test", "StatusError": "Erreur de test" diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 6776fa06d..76d5a6d13 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -854,9 +854,9 @@ "OpenInNewTab": "Abrir em nova aba", "BackToHome": "Voltar para o início", "Title": "Ferramentas avançadas", - "Note": "Ferramentas usadas com menos frequência, mas muito úteis para testes de rede.", - "PingTestNote": "Teste de ping global", - "MTRTestNote": "Teste global de rota MTR", + "Note": "Quem exige o máximo da qualidade da rede costuma usar mais ferramentas para otimizar a própria conexão.", + "PingTestNote": "Faça ping no seu IP a partir de vários pontos do mundo", + "MTRTestNote": "Trace sua rota de rede a partir de vários pontos do mundo", "DNSResolverNote": "Resolução DNS multicanal em tempo real", "EnhancedDnsLeakTest": "Captura todos os resolvedores recursivos, com análise de ECS e DNSSEC", "RuleTestNote": "Verifique as regras do software de proxy", @@ -1083,8 +1083,8 @@ }, "ipInfos": { "Title": "Informações de IP", - "Notes": "O programa verificará primeiro seu IP em diferentes fontes (incluindo IPv4 e IPv6) e depois consultará os dados geográficos correspondentes na fonte de geolocalização de IP selecionada. Se houver apenas 1 pilha de IP, a fonte sem dados será exibida como vazia.", - "Source": "Fonte de IP", + "Notes": "O programa verificará primeiro seu IP em várias sondas diferentes (incluindo IPv4 e IPv6) e depois consultará os dados geográficos correspondentes na fonte de dados de IP selecionada. Se houver apenas 1 pilha de IP, a fonte sem dados será exibida como vazia.", + "Source": "Sonda de IP", "Country": "Região", "Region": "Estado", "City": "Cidade", @@ -1203,7 +1203,7 @@ }, "connectivity": { "Title": "Conectividade de rede", - "Note": "Este teste verifica se você consegue acessar um site informado. Ele funciona carregando uma pequena imagem daquele site. Os valores de atraso são apenas referência e serão menores na prática. Você pode adicionar outros sites que usa com frequência à lista de testes para verificações rápidas.", + "Note": "Este teste mede sua conectividade com um site: quanto maior a latência, mais lenta é sua conexão com ele. Você pode importar ou adicionar outros sites que usa com frequência à lista de testes para verificações rápidas.", "StatusWait": "Aguardando teste", "StatusAvailable": "OK", "StatusUnavailable": "Indisponível", @@ -1262,7 +1262,7 @@ "id": "webrtc", "Title": "Teste de vazamento WebRTC", "Name": "Conexão WebRTC", - "Note": "WebRTC costuma estabelecer conexões diretamente via UDP. Se o teste retornar seu IP real, isso significa que suas configurações de proxy não cobrem essas conexões. Além de detectar o IP usado ao se conectar via WebRTC, também detectamos seu tipo de NAT. No entanto, a detecção do tipo de NAT não é 100% precisa e serve apenas como referência.", + "Note": "Quando o navegador obtém endereços de rede via WebRTC, ele pode contornar sua VPN ou proxy e expor seu IP real aos sites. Este teste verifica qual IP é exposto nas suas conexões WebRTC; se o endereço IP ou a região retornada não for o que você espera, pode haver risco de vazamento por WebRTC e talvez seja necessário ajustar as configurações do seu software de proxy. Além do IP usado nas conexões WebRTC, também detectamos seu tipo de NAT.", "StatusWait": "Aguardando teste", "StatusError": "Erro de conexão", "StatusUnavailable": "WebRTC indisponível", @@ -1278,8 +1278,7 @@ "dnsleaktest": { "Name": "Teste de endpoint DNS", "Title": "Teste de vazamento de DNS", - "Note": "Um vazamento de DNS significa que, quando você está conectado a uma VPN/proxy, suas resoluções de nomes de domínio ainda são feitas pelo seu ISP local, criando risco de vazamento de DNS.", - "Note2": "O método para testar vazamentos de DNS envolve acessar um nome de domínio recém-gerado para detectar por qual saída DNS regional você está resolvendo. Se a região do endpoint retornado corresponder à região do seu provedor local, há risco de vazamento de DNS. Nesse caso, talvez seja necessário ajustar suas configurações de VPN/proxy.", + "Note": "Mesmo usando uma VPN ou proxy, as consultas DNS ainda podem sair pela sua rede local, expondo os domínios dos sites que você acessa. Provedores, firewalls, operadores de Wi-Fi público e outros pontos no caminho podem interceptá-las e descobrir quais sites você visita. Este teste acessa um domínio recém-gerado para detectar por qual região suas consultas DNS passam; se a região retornada for diferente da região da VPN/proxy que você usa, pode haver risco de vazamento de DNS.", "EndpointCountry": "Região do endpoint", "StatusWait": "Aguardando teste", "StatusError": "Erro no teste" diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 48a997db1..d5720da95 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -854,9 +854,9 @@ "OpenInNewTab": "Открыть в новой вкладке", "BackToHome": "На главную", "Title": "Расширенные инструменты", - "Note": "Эти инструменты используются относительно редко, но очень полезны при проверке сети.", - "PingTestNote": "Глобальная проверка задержки Ping", - "MTRTestNote": "Глобальная проверка маршрута MTR", + "Note": "Те, кто требователен к качеству сети, обычно используют больше инструментов, чтобы оптимизировать своё подключение.", + "PingTestNote": "Пинг вашего IP из разных точек мира", + "MTRTestNote": "Трассировка вашего сетевого маршрута из разных точек мира", "DNSResolverNote": "Многоканальное разрешение DNS в реальном времени", "EnhancedDnsLeakTest": "Обнаруживает все рекурсивные резолверы и анализирует ECS и DNSSEC", "RuleTestNote": "Проверка правил прокси-программы", @@ -1083,8 +1083,8 @@ }, "ipInfos": { "Title": "Сведения об IP", - "Notes": "Программа сначала проверит ваш IP-адрес по разным источникам (включая IPv4 и IPv6), а затем запросит географические данные у выбранного источника геолокации IP. Если доступен только один стек IP, источник без данных будет показан пустым.", - "Source": "Источник IP", + "Notes": "Программа сначала проверит ваш IP-адрес с помощью нескольких разных зондов (включая IPv4 и IPv6), а затем запросит географические данные у выбранного вами источника данных об IP. Если доступен только один стек IP, источник без данных будет показан пустым.", + "Source": "Зонд IP", "Country": "Регион", "Region": "Область", "City": "Город", @@ -1203,7 +1203,7 @@ }, "connectivity": { "Title": "Сетевое подключение", - "Note": "Этот тест проверяет доступность указанного сайта, загружая с него небольшое изображение. Значения задержки приведены для справки и в действительности будут меньше. Для быстрой проверки можно добавить в список другие часто используемые сайты.", + "Note": "Этот тест проверяет ваше соединение с сайтом: чем больше значение задержки, тем медленнее связь с этим сайтом. Для быстрой проверки можно импортировать или добавить в список другие часто используемые сайты.", "StatusWait": "Ожидание теста", "StatusAvailable": "OK", "StatusUnavailable": "Недоступно", @@ -1262,7 +1262,7 @@ "id": "webrtc", "Title": "Тест утечки WebRTC", "Name": "Подключение WebRTC", - "Note": "WebRTC часто устанавливает прямые подключения через UDP. Если тест возвращает ваш реальный IP, значит настройки прокси не охватывают такие подключения. Помимо IP, используемого при подключении WebRTC, мы также определяем тип NAT. Определение типа NAT не гарантирует 100% точности и приводится только для справки.", + "Note": "Когда браузер получает сетевые адреса через WebRTC, он может обойти VPN или прокси и раскрыть сайтам ваш реальный IP. Этот тест показывает, какой IP раскрывается при подключении по WebRTC: если полученный IP-адрес или регион отличается от ожидаемого, возможна утечка через WebRTC и вам может потребоваться изменить настройки прокси. Помимо IP, используемого при подключении WebRTC, мы также определяем тип NAT.", "StatusWait": "Ожидание теста", "StatusError": "Ошибка подключения", "StatusUnavailable": "WebRTC недоступен", @@ -1278,8 +1278,7 @@ "dnsleaktest": { "Name": "Проверка конечной точки DNS", "Title": "Тест утечки DNS", - "Note": "Утечка DNS означает, что при подключении через VPN или прокси доменные имена по-прежнему разрешаются через вашего местного ISP, создавая риск раскрытия DNS-запросов.", - "Note2": "Для проверки утечки DNS выполняется обращение к недавно созданному доменному имени, чтобы определить регион выхода DNS. Если возвращённый регион совпадает с регионом вашего местного провайдера, возможна утечка DNS. В таком случае может потребоваться изменить настройки VPN или прокси.", + "Note": "Даже при использовании VPN или прокси DNS-запросы могут по-прежнему отправляться через локальную сеть, раскрывая домены сайтов, которые вы посещаете. Провайдеры, межсетевые экраны, операторы публичного Wi-Fi и другие узлы на пути могут перехватить их и узнать, какие сайты вы открываете. Тест обращается к недавно созданному доменному имени, чтобы определить, через DNS какого региона выполняется запрос: если возвращённый регион не совпадает с регионом используемого VPN или прокси, возможен риск утечки DNS.", "EndpointCountry": "Регион конечной точки", "StatusWait": "Ожидание теста", "StatusError": "Ошибка теста" diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 4a14b64af..7ae143c66 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -854,9 +854,9 @@ "OpenInNewTab": "在新標籤頁開啟", "BackToHome": "回到首頁", "Title": "進階工具", - "Note": "一些使用頻率相對較低,但在進行網路測試時,非常有用的工具。", - "PingTestNote": "全球的 Ping 值測試", - "MTRTestNote": "全球的 MTR 路由測試", + "Note": "對網路品質追求極致的人,往往會使用更多工具來最佳化自己的網路連線。", + "PingTestNote": "從世界各地 ping 你的 IP", + "MTRTestNote": "從世界各地追蹤你的網路路由", "DNSResolverNote": "即時多渠道 DNS 解析", "EnhancedDnsLeakTest": "找出所有正在使用的遞迴 DNS 解析器,並分析 ECS 與 DNSSEC", "RuleTestNote": "檢查代理軟體的規則設定", @@ -1083,8 +1083,8 @@ }, "ipInfos": { "Title": "IP 資訊", - "Notes": "程式會先透過多個來源檢查你的 IP 位址(包括 IPv4 和 IPv6),再從你選擇的 IP 地理位置資料來源查詢相關位置資訊。如果目前只有 IPv4 或 IPv6,其中沒有資料的項目會顯示為空白。", - "Source": "IP 來源", + "Notes": "程式會先透過多個不同的探針檢查你的 IP 位址(包括 IPv4 和 IPv6),再從你選擇的 IP 解析資料來源查詢相關位置資訊。如果目前只有 IPv4 或 IPv6,其中沒有資料的項目會顯示為空白。", + "Source": "IP 探針", "Country": "地區", "Region": "行政區", "City": "城市", @@ -1203,7 +1203,7 @@ }, "connectivity": { "Title": "網路連通性", - "Note": "本測試用於測試你是否能夠連線到某個網站。測試方法是透過載入對應網站上的小圖片進行。延遲值僅供參考,實際會更小。你可以將其他常用網站新增到測試清單中,方便快速測試。", + "Note": "測試你與網站之間的連線狀況,延遲數字越大,代表你與該網站的連線速度越慢。你可以匯入/新增其他常用網站到測試清單中,方便快速測試。", "StatusWait": "待檢測", "StatusAvailable": "可用", "StatusUnavailable": "不可用", @@ -1262,7 +1262,7 @@ "id": "webrtc", "Title": "WebRTC 洩漏測試", "Name": "WebRTC 連線", - "Note": "WebRTC 通常會透過 UDP 直接建立連線。如果測試結果顯示你的真實 IP,代表代理設定未涵蓋這些連線。除了偵測 WebRTC 連線使用的 IP,我們也會偵測 NAT 類型;但 NAT 類型的結果不一定完全準確,僅供參考。", + "Note": "瀏覽器透過 WebRTC 取得網路位址時,有可能繞過 VPN 或代理,進而向網站暴露你的真實 IP。本測試會檢查你透過 WebRTC 連線時暴露出來的 IP,如果回傳的 IP 位址、地區與你預期的不一致,則可能存在 WebRTC 洩漏風險,你可能需要調整代理軟體的設定。除了偵測 WebRTC 連線使用的 IP,我們也會偵測你的 NAT 類型。", "StatusWait": "待檢測", "StatusError": "連線錯誤", "StatusUnavailable": "WebRTC 不可用", @@ -1278,8 +1278,7 @@ "dnsleaktest": { "Name": "DNS 出口檢測", "Title": "DNS 洩漏測試", - "Note": "DNS 洩漏(DNS Leak)是指當你連上 VPN/代理後,解析網域名稱時依然透過當地的網路服務供應商進行解析,此時就有 DNS 洩漏的風險。", - "Note2": "洩漏測試會透過造訪新產生的網域名稱,檢測你是經由哪個地區的 DNS 出口進行解析。如果回傳的出口區域和你當地的網路服務供應商區域相同,則有 DNS 洩漏風險,你可能需要修改 VPN/代理設定。", + "Note": "當你使用 VPN 或代理時,DNS 查詢仍可能透過本地網路送出,進而暴露你正在造訪的網站網域。線路中的網路服務供應商、防火牆、公共 Wi-Fi 提供者等,都可能直接攔截並得知你造訪的網域。本測試會透過造訪新產生的網域名稱,檢測你是經由哪個地區的 DNS 進行查詢,如果回傳的出口區域和你正在使用的 VPN/代理所屬地區不同,則可能存在 DNS 洩漏風險。", "EndpointCountry": "出口地區", "StatusWait": "待檢測", "StatusError": "測試出錯" diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 4d99cc370..4eec0bb64 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -854,9 +854,9 @@ "OpenInNewTab": "在新标签页打开", "BackToHome": "回到首页", "Title": "高级工具", - "Note": "一些使用频率相对较低,但在进行网络测试时,非常有用的工具。", - "PingTestNote": "全球的 Ping 值测试", - "MTRTestNote": "全球的 MTR 路由测试", + "Note": "对网络质量追求极致的人,往往会使用更多工具来优化自己的网络连接。", + "PingTestNote": "从世界各地 ping 你的 IP", + "MTRTestNote": "从世界各地追踪你的网络路由", "DNSResolverNote": "实时多渠道 DNS 解析", "EnhancedDnsLeakTest": "捕获所有递归解析器,并分析 ECS 与 DNSSEC", "RuleTestNote": "检查代理软件的规则设置", @@ -1083,8 +1083,8 @@ }, "ipInfos": { "Title": "IP 信息", - "Notes": "程序会先从多个不同的来源(包含 IPv4 和 IPv6)检查你的 IP 地址,然后通过你选择的 IP 地理位置信息源查询对应的地理数据。如果当前 IP 栈只有 1 个,则没有数据的来源会显示为空。", - "Source": "IP 来源", + "Notes": "程序会先从多个不同的探针(包含 IPv4 和 IPv6)检查你的 IP 地址,然后通过你选择的 IP 解析数据源查询对应的地理数据。如果当前 IP 栈只有 1 个,则没有数据的来源会显示为空。", + "Source": "IP 探针", "Country": "地区", "Region": "省份", "City": "城市", @@ -1203,7 +1203,7 @@ }, "connectivity": { "Title": "网络连通性", - "Note": "本测试用于测试你是否能够连通到某个网站。测试方法是通过加载对应网站上的小图片进行的。延迟值仅供参考,实际会更小。你可以添加其它你常用的网站到测试列表中,方便快速测试。", + "Note": "测试你与网站的连通性,延迟数字越大,意味着你与该网站的连接速度越慢。你可以导入/添加其它你常用的网站到测试列表中,方便快速测试。", "StatusWait": "待检测", "StatusAvailable": "可用", "StatusUnavailable": "不可用", @@ -1262,7 +1262,7 @@ "id": "webrtc", "Title": "WebRTC 泄露测试", "Name": "WebRTC 连接", - "Note": "WebRTC 往往通过 UDP 直连进行建立,如果测试返回了真实 IP,则意味着你的代理设置没有覆盖这些连接。除了检测你连接 WebRTC 时所使用的 IP,我们还会检测你的 NAT 类型。然而,NAT 类型的检测并不是 100% 准确的,仅供参考。", + "Note": "浏览器通过 WebRTC 获取网络地址时有可能绕过 VPN 或代理,从而向网站暴露你的真实 IP。本测试检查你通过 WebRTC 连接的时候暴露出来的 IP,如果返回的 IP 地址、地区与你预期的不一致,则可能存在 WebRTC 泄露风险,你可能需要调整你的代理软件设置。除了检测你连接 WebRTC 时所使用的 IP,我们还会检测你的 NAT 类型。", "StatusWait": "待检测", "StatusError": "连接错误", "StatusUnavailable": "WebRTC 不可用", @@ -1278,8 +1278,7 @@ "dnsleaktest": { "Name": "DNS 出口检测", "Title": "DNS 泄露测试", - "Note": "DNS 泄露(DNS Leaks)的意思是,当你挂上 VPN/代理后,你解析域名时,依然通过当地的运营商进行解析,这时就有 DNS 泄露的风险。", - "Note2": "泄露测试的方法是通过访问新生成的域名,检测你是通过哪个地区的 DNS 出口进行解析,如果返回的出口区域和你当地的运营商区域相同,则有 DNS 泄露风险,你可能需要修改 VPN/代理设置。", + "Note": "当你在使用 VPN 或代理时,DNS 查询仍可能通过本地网络发送,从而暴露你正在访问的网站域名。链路中的运营商、防火墙、公共 Wi-Fi 提供者等都可能直接截获并得知你访问的网站域名。本测试通过访问新生成的域名,检测你是通过哪个地区的 DNS 进行查询,如果返回的出口区域和你正在使用的 VPN/代理的地区归属地不同,则可能存在 DNS 泄露风险。", "EndpointCountry": "出口地区", "StatusWait": "待检测", "StatusError": "测试出错" From 983382e628bdab40a291fef64806458e86070408 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 27 Aug 2026 10:07:47 +0800 Subject: [PATCH 11/24] Feat(connectivity): Organize tests into user-managed multiple lists - Multi-list model in the connectivityLists preference (utils/connectivity-lists.js), migrated from the flat connectivityTargets / customConnectivityTargets eras; legacy keys kept for rollback, never written - Header gains a list picker once a second list exists; in-page switching is per-visit, the startup list is a preference - Curated imports pick a destination per list (or spin up a new list named after the preset); custom adds pick a destination once a second list exists - Per-card retest moves to the latency figure (retry icon on failed cards); card titles optionally open the site in a new tab via a new setting, off by default (titles are plain text otherwise) - Preferences gets a dedicated Connectivity section (default list + three switches) and a reworked two-line PrefRow layout that fixes vertical centering and tip wrapping - List names allow 40 chars, truncated with an ellipsis where tight Co-Authored-By: Claude Fable 5 --- frontend/components/ConnectivityTest.vue | 183 ++++++--- .../widgets/ConnectivityAddDialog.vue | 300 ++++++++++++--- frontend/components/widgets/Preferences.vue | 103 +++-- frontend/composables/use-shortcuts.js | 23 +- frontend/data/changelog.json | 11 + frontend/data/connectivity-import-lists.js | 39 +- frontend/data/default-preferences.js | 15 +- frontend/locales/en.json | 39 +- frontend/locales/fr.json | 39 +- frontend/locales/pt-BR.json | 39 +- frontend/locales/ru.json | 39 +- frontend/locales/zh-TW.json | 39 +- frontend/locales/zh.json | 39 +- frontend/store.js | 14 +- frontend/utils/connectivity-import.js | 80 ---- frontend/utils/connectivity-lists.js | 206 ++++++++++ tests/composable-shortcuts.test.js | 24 +- tests/connectivity-import-lists.test.js | 7 + tests/connectivity-import.test.js | 151 -------- tests/connectivity-lists.test.js | 352 ++++++++++++++++++ tests/default-preferences.test.js | 3 + 21 files changed, 1316 insertions(+), 429 deletions(-) delete mode 100644 frontend/utils/connectivity-import.js create mode 100644 frontend/utils/connectivity-lists.js delete mode 100644 tests/connectivity-import.test.js create mode 100644 tests/connectivity-lists.test.js diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index 4b3f47951..449acdb91 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -6,12 +6,38 @@ class="m-0 flex min-w-0 flex-1 items-center gap-2 text-xl md:text-3xl font-semibold tracking-tight leading-tight"> 🚦 {{ t('connectivity.Title') }} - - - + +
+ + + + + + + + {{ listName(list) }} + + + + + {{ t('connectivity.lists.Manage') }} + + + + + + +

{{ t('connectivity.Note') }}

@@ -35,12 +61,14 @@ ? - -
+ + @@ -48,9 +76,12 @@ class="size-6 shrink-0 rounded-lg inline-flex items-center justify-center text-xs font-semibold text-muted-foreground border"> {{ (test.name || '?').charAt(0).toUpperCase() }} - {{ test.name }} -
- + {{ test.name }} + +
@@ -62,10 +93,19 @@ {{ test.status }} - + +
-
- - + + @@ -120,16 +160,19 @@ import { trackEvent } from '@/utils/analytics'; import { emitAppEvent, waitForAppEvent } from '@/utils/app-events'; import { useAppCommand } from '@/composables/use-app-command.js'; import { CONNECTIVITY_STATUS } from '@/utils/report-schema.js'; -import { TILE_PREVIEW, faviconPath } from '@/data/connectivity-import-lists.js'; -import { removeTarget } from '@/utils/connectivity-import.js'; +import { TILE_PREVIEW, faviconPath, MINE_LIST_ID } from '@/data/connectivity-import-lists.js'; +import { siteUrlOf, removeMember } from '@/utils/connectivity-lists.js'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import ConnectivityAddDialog from '@/components/widgets/ConnectivityAddDialog.vue'; import InfoBanner from '@/components/widgets/InfoBanner.vue'; import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; import { - Play, Frown, Meh, Plus, RotateCw, Smile, X, + Check, ChevronDown, Play, Frown, Meh, Plus, RotateCw, Settings2, Smile, X, } from '@lucide/vue'; const { t } = useI18n(); @@ -155,14 +198,43 @@ const totalRounds = computed(() => 1 + maxCounts.value); const allRoundsDone = ref(false); const alertFired = ref(false); -// One reactive test entry per stored target (the connectivityTargets -// preference holds the whole set, defaults included). `roundResults` -// records per-round { tone, time } for the latency bars; icons come from -// the committed favicon set, hand-added targets fall back to letter tiles. +// ── Lists ────────────────────────────────────────────────────────────────── +// One list renders (and tests) at a time. +const lists = computed(() => userPreferences.value.connectivityLists?.lists || []); + +// Open on the preferred default list (in-page switching is per-visit +// only); a stale preference falls back to Mine. +const initialListId = () => { + const preferred = userPreferences.value.connectivityDefaultListId; + return lists.value.some((l) => l.id === preferred) ? preferred : MINE_LIST_ID; +}; +const activeListId = ref(initialListId()); +const activeList = computed(() => lists.value.find((l) => l.id === activeListId.value)); + +// Mine's stored name is null — its display name is localized. +const listName = (list) => (list.id === MINE_LIST_ID ? t('connectivity.lists.Mine') : list.name); + +const switchList = (id) => { + if (id === activeListId.value) return; + activeListId.value = id; + trackEvent('Section', 'SwitchConnectivityList', 'Connectivity'); +}; + +// A deleted active list falls back to Mine instead of an empty grid. +watch(lists, (all) => { + if (!all.some((l) => l.id === activeListId.value)) activeListId.value = MINE_LIST_ID; +}); + +const titleOpensSite = computed(() => userPreferences.value.connectivityCardTitleOpensSite); + +// ── Test entries ─────────────────────────────────────────────────────────── +// One reactive entry per member of the active list. `roundResults` records +// per-round { tone, time } for the latency bars. const targetEntry = (target) => reactive({ id: target.id, name: target.name, url: target.url, + siteUrl: target.siteUrl || null, favicon: target.favicon || null, status: t('connectivity.StatusWait'), time: 0, @@ -171,26 +243,33 @@ const targetEntry = (target) => reactive({ }); const connectivityTests = reactive([]); -// Reconcile by id (not wipe-and-refill) so existing cards don't flash back -// to "Awaiting Test" each time the user adds or removes another one. +// Rebuild vs reconcile: a list switch replaces the whole grid (the same +// member id can exist in two lists and must not carry results across); a +// same-list change reconciles in place so existing cards don't flash back +// to "Awaiting Test". watch( - () => userPreferences.value.connectivityTargets, - (newTargets) => { - const targets = newTargets || []; - const targetIds = new Set(targets.map((t) => t.id)); + () => ({ listId: activeListId.value, members: activeList.value?.members || [] }), + ({ listId, members }, prev) => { + if (!prev || prev.listId !== listId) { + connectivityTests.splice(0, connectivityTests.length, ...members.map(targetEntry)); + // A newly shown list runs a full pass right away, toast suppressed. + if (prev && isStarted.value) handelCheckStart('refresh'); + return; + } - // Drop targets no longer in storage. + // Drop members no longer stored. + const memberIds = new Set(members.map((m) => m.id)); for (let i = connectivityTests.length - 1; i >= 0; i--) { - if (!targetIds.has(connectivityTests[i].id)) { + if (!memberIds.has(connectivityTests[i].id)) { connectivityTests.splice(i, 1); } } // Push only newcomers. const existingIds = new Set(connectivityTests.map((entry) => entry.id)); - for (const target of targets) { - if (existingIds.has(target.id)) continue; - const entry = targetEntry(target); + for (const member of members) { + if (existingIds.has(member.id)) continue; + const entry = targetEntry(member); connectivityTests.push(entry); // Cards added after the bootstrap pass (import / hand-add) test // themselves right away instead of sitting at "Awaiting Test". @@ -424,26 +503,34 @@ const updateConnectivityAlert = (type) => { } }; -// ── Add/remove targets ───────────────────────────────────────────────────── -// The add/import dialog itself lives in ConnectivityAddDialog.vue; this -// component only owns the open flag and per-card removal. +// ── Add / import / manage dialog + per-card removal ─────────────────────── +// The dialog lives in ConnectivityAddDialog.vue; this component owns the +// open flag and the tab it opens on. const addDialogOpen = ref(false); +const dialogTab = ref('import'); +const openAddDialog = (tab) => { + dialogTab.value = tab; + addDialogOpen.value = true; +}; const removeTargetById = (id) => { - const result = removeTarget(userPreferences.value.connectivityTargets || [], id); + const result = removeMember(lists.value, activeListId.value, id); if (result.error) return; - store.updatePreference('connectivityTargets', result.targets); + store.updatePreference('connectivityLists', { + ...userPreferences.value.connectivityLists, + lists: result.lists, + }); trackEvent('Section', 'RemoveCustomTarget', 'Connectivity'); }; // Two-step removal: first click arms "X?" (self-reverting), second click -// deletes. The last remaining target skips the dance — the set must never -// go empty, so the click just explains itself via toast. +// deletes. Mine's last remaining site just toasts — the default list must +// never go empty (other lists may be emptied; deleting them requires it). const confirmingRemoveId = ref(null); let confirmResetTimer = null; const handleRemoveClick = (id) => { clearTimeout(confirmResetTimer); - if ((userPreferences.value.connectivityTargets || []).length <= 1) { + if (activeListId.value === MINE_LIST_ID && (activeList.value?.members.length || 0) <= 1) { confirmingRemoveId.value = null; store.setAlert(true, 'text-warning', t('connectivity.addCustom.LastTargetMessage'), t('connectivity.addCustom.LastTargetTitle')); @@ -469,7 +556,7 @@ const finalizeMultiTestAlert = () => { // `trigger` selects three behaviors (arming the toast = setting alertToShow): // 'boot' — startup auto-run: arm toast, no card reset // 'manual' — section refresh button: arm toast, reset cards -// 'refresh' — global "refresh everything": suppress toast (global alert covers it), reset cards +// 'refresh' — global "refresh everything" + list switches: suppress toast, reset cards // Multi-round mode applies to every trigger, not just boot — with the // connectivity auto-run switch off, the manual/global refresh IS the user's // only entry point, and the rounds preference must still hold there. diff --git a/frontend/components/widgets/ConnectivityAddDialog.vue b/frontend/components/widgets/ConnectivityAddDialog.vue index f683a9825..2f4d6a9fd 100644 --- a/frontend/components/widgets/ConnectivityAddDialog.vue +++ b/frontend/components/widgets/ConnectivityAddDialog.vue @@ -6,16 +6,19 @@ - + {{ t('connectivity.importDialog.ImportOption') }} {{ t('connectivity.importDialog.CustomOption') }} + + {{ t('connectivity.lists.Tab') }} - +
  • - - - + + + + + + + + {{ listName(dest) }} + + + + + {{ t('connectivity.importDialog.ImportAsNew') }} + + +
- -
+ +
{{ importResult || ' ' }} - - {{ t('connectivity.importDialog.Capacity', { used: targets.length, limit: CONNECTIVITY_TARGET_LIMIT - }) }} -
- +
@@ -74,6 +84,19 @@ :aria-invalid="isUrlError ? 'true' : undefined" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore="true" @keyup.enter="handleAdd" />
+
+ + +

{{ t('connectivity.addCustom.Hint') }}

{{ addError }}

@@ -85,83 +108,195 @@
+ + + +
+ + +
+
    +
  • + + + +
  • +
+
+ {{ listError || ' ' }} + {{ lists.length }}/{{ CONNECTIVITY_LIST_LIMIT }} +
+

{{ t('connectivity.lists.Hint') }}

+
diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index de701eabc..808586916 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -89,8 +89,8 @@ + runs (no switch). The Connectivity test options live in their own + section below: they apply to manual runs too, not just startup. -->
{{ t('nav.preferences.autoRun') }}
@@ -107,20 +107,53 @@ {{ t('nav.preferences.autoRunTips') }}
- -
- {{ t('nav.preferences.appSettings') }} + +
+ {{ t('nav.Connectivity') }}
- +
+
+ + {{ t('nav.preferences.connectivity.defaultList') }} + +
+

+ {{ t('nav.preferences.connectivity.defaultListTips') }}

+
+ + - + +
+
+ + +
+ {{ t('nav.preferences.appSettings') }} +
@@ -165,6 +198,7 @@ import { trackEvent } from '@/utils/analytics'; import { emitAppEvent } from '@/utils/app-events.js'; import { clampRetentionDays } from '@/utils/ip-history.js'; import { LOCALES } from '@/utils/locale-registry.js'; +import { MINE_LIST_ID } from '@/data/connectivity-import-lists.js'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Slider } from '@/components/ui/slider'; @@ -172,6 +206,7 @@ import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; import { + Activity, AppWindow, Database, History, @@ -231,6 +266,23 @@ const prefLanguage = (value) => { trackEvent('Nav', 'PrefereceClick', 'LanguageChange'); }; +// Which Connectivity list the section opens on; a stale preference falls +// back to Mine (whose display name is localized, never stored). +const connectivityLists = computed(() => userPreferences.value.connectivityLists?.lists || []); +const connectivityListName = (list) => (list.id === MINE_LIST_ID ? t('connectivity.lists.Mine') : list.name); +const currentDefaultListId = computed(() => { + const preferred = userPreferences.value.connectivityDefaultListId; + return connectivityLists.value.some((l) => l.id === preferred) ? preferred : MINE_LIST_ID; +}); +const currentDefaultListName = computed(() => { + const list = connectivityLists.value.find((l) => l.id === currentDefaultListId.value); + return list ? connectivityListName(list) : '—'; +}); +const prefConnectivityDefaultList = (value) => { + store.updatePreference('connectivityDefaultListId', value); + trackEvent('Nav', 'PrefereceClick', 'ConnectivityDefaultList'); +}; + const prefConnectivityMultipleTests = (value) => { store.updatePreference('connectivityMultipleTests', value); // Achievement rule (ResourceHog) lives in data/achievement-rules.js. @@ -259,6 +311,11 @@ const prefconnectivityShowNoti = (value) => { trackEvent('Nav', 'PrefereceClick', 'ConnectivityNotifications'); }; +const prefConnectivityCardTitleOpensSite = (value) => { + store.updatePreference('connectivityCardTitleOpensSite', value); + trackEvent('Nav', 'PrefereceClick', 'ConnectivityCardTitleOpensSite'); +}; + // IP history recorder: on/off + retention days (1–90). The draft ref feeds the // slider's live readout; the preference is written on value-commit only. const ipHistoryDaysDraft = ref(clampRetentionDays(userPreferences.value.ipHistoryDays)); @@ -302,23 +359,27 @@ SectionTitle.props = ['icon']; const SectionTip = (props, { slots }) => h('p', { class: 'mt-2 text-xs text-muted-foreground leading-relaxed' }, slots.default?.()); -// App Settings switch row: label (+ optional tip) on left, Switch on right. +// Preference switch row. Two stacked lines: label + Switch vertically +// centered on the first, the optional tip full-width on the second (so it +// runs under the control instead of wrapping beside it). Tip rows trim the +// bottom padding — the tip's own line-height already provides visual air. const PrefRow = (props, { emit }) => - h('div', { class: 'flex items-start justify-between gap-3 p-3' }, [ - h('div', { class: 'flex-1 min-w-0' }, [ + h('div', { class: props.tip ? 'p-3 pb-2' : 'p-3' }, [ + h('div', { class: 'flex items-center justify-between gap-3' }, [ h('label', { for: props.id, - class: 'text-sm font-medium cursor-pointer select-none', + class: 'min-w-0 flex-1 text-sm font-medium cursor-pointer select-none', }, props.label), - props.tip - ? h('p', { class: 'mt-0.5 text-xs text-muted-foreground leading-relaxed' }, props.tip) - : null, + h(Switch, { + id: props.id, + class: 'shrink-0', + modelValue: props.modelValue, + 'onUpdate:modelValue': (v) => emit('update:modelValue', v), + }), ]), - h(Switch, { - id: props.id, - modelValue: props.modelValue, - 'onUpdate:modelValue': (v) => emit('update:modelValue', v), - }), + props.tip + ? h('p', { class: 'mt-1 text-xs text-muted-foreground leading-relaxed' }, props.tip) + : null, ]); PrefRow.props = ['id', 'label', 'tip', 'modelValue']; PrefRow.emits = ['update:modelValue']; diff --git a/frontend/composables/use-shortcuts.js b/frontend/composables/use-shortcuts.js index fb4ee1fd8..1be3dd32f 100644 --- a/frontend/composables/use-shortcuts.js +++ b/frontend/composables/use-shortcuts.js @@ -74,19 +74,28 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => { description: t('shortcutKeys.GoPrevious'), }, { - // Open the currently J/K-highlighted card if it's an Advanced Tool - // (those carry data-adv-slug). No-op for any other card type — - // IP cards, Connectivity, WebRTC, etc. have their own refresh - // shortcuts rather than an "open" concept. + // Open the currently J/K-highlighted card: an Advanced Tool card + // (data-adv-slug) opens its tool; a Connectivity card opens its + // website via the card's own — which only + // exists with the open-website App Setting on, so target/rel and + // the preference gate all ride the real link. No-op for any + // other card type — IP cards, WebRTC, etc. have their own + // refresh shortcuts rather than an "open" concept. keys: 'o', action: () => { const highlighted = document.querySelector( '.keyboard-shortcut-card[data-keyboard-hover="true"]' ); const slug = highlighted?.getAttribute('data-adv-slug'); - if (!slug) return; - advancedToolsRef.value.openTool(slug); - trackEvent('ShortCut', 'ShortCut', 'OpenHighlightedTool'); + if (slug) { + advancedToolsRef.value.openTool(slug); + trackEvent('ShortCut', 'ShortCut', 'OpenHighlightedTool'); + return; + } + const link = highlighted?.querySelector('a[data-card-open][href]'); + if (!link) return; + link.click(); + trackEvent('ShortCut', 'ShortCut', 'OpenHighlightedSite'); }, description: t('shortcutKeys.OpenHighlightedTool'), }, diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index e7336700e..14591b0ec 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1975,6 +1975,17 @@ "version": "v7.5.0", "date": "Beta", "content": [ + { + "type": "add", + "change": { + "en": "Connectivity: organize tests into multiple lists — create your own, import presets into any list or as a new one, retest a single site by clicking its latency, and optionally open sites from card titles", + "zh": "网络连通性支持多列表:可自建列表,预设列表可导入任意列表或导入为新列表,点击延迟数字可单独重测,还可在偏好设置中开启点击卡片标题打开网站", + "zh-TW": "網路連線支援多清單:可自建清單,預設清單可匯入任意清單或匯入為新清單,點擊延遲數字可單獨重測,還可在偏好設定中開啟點擊卡片標題開啟網站", + "fr": "Connectivité : organisez vos tests en plusieurs listes — créez les vôtres, importez des présélections dans n'importe quelle liste ou comme nouvelle liste, relancez un test en cliquant sur sa latence, et ouvrez un site depuis le titre de sa carte (option)", + "ru": "Проверка доступности: тесты можно распределять по нескольким спискам — создавайте свои, импортируйте наборы в любой список или как новый, повторяйте тест кликом по задержке и открывайте сайт по заголовку карточки (опционально)", + "pt-BR": "Conectividade: organize os testes em várias listas — crie as suas, importe conjuntos em qualquer lista ou como uma nova, repita um teste clicando na latência e, opcionalmente, abra o site pelo título do cartão" + } + }, { "type": "add", "change": { diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index 9ddd093bc..3416e4368 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -8,25 +8,37 @@ // same-origin, so icons render even when the tested site is blocked for // the visitor. -// Total cap on stored targets, defaults included; enforced at add/import -// time only, so an over-cap migrated set keeps working but can't grow. +// Per-list cap on stored targets, defaults included; enforced at +// add/import time only, so an over-cap migrated set keeps working but +// can't grow. export const CONNECTIVITY_TARGET_LIMIT = 60; +// How many lists a user can hold, the undeletable "Mine" list included. +export const CONNECTIVITY_LIST_LIMIT = 10; + +// Max characters in a user-given list name; the UI truncates with an +// ellipsis where space is tight. +export const CONNECTIVITY_LIST_NAME_LIMIT = 40; + +// Fixed id of the seeded "Mine" list — localized display name, can't be +// deleted or renamed. +export const MINE_LIST_ID = 'mine'; + // Same-origin favicon path for a member id (fetched at dev time). export const faviconPath = (id) => `/favicons/${id}.png`; -// Targets every fresh install starts with (migration appends legacy -// customs); all removable once stored. Cloudflare tests -// speed.cloudflare.com, not www: the marketing site attaches font-preload -// headers to every response, and those preloads then fail CORS. +// Targets every fresh install starts with. `siteUrl` overrides the card's +// open-website link when the test URL's host isn't the right page to open. +// Cloudflare tests speed.cloudflare.com, not www: the marketing site +// attaches font-preload headers to every response, which then fail CORS. export const DEFAULT_LIST_MEMBERS = [ { id: 'google', name: 'Google', url: 'https://www.google.com/favicon.ico' }, { id: 'youtube', name: 'YouTube', url: 'https://www.youtube.com/favicon.ico' }, { id: 'github', name: 'GitHub', url: 'https://github.com/favicon.ico' }, - { id: 'cloudflare', name: 'Cloudflare', url: 'https://speed.cloudflare.com/favicon.ico', iconDomain: 'www.cloudflare.com' }, + { id: 'cloudflare', name: 'Cloudflare', url: 'https://speed.cloudflare.com/favicon.ico', iconDomain: 'www.cloudflare.com', siteUrl: 'https://www.cloudflare.com' }, { id: 'claude', name: 'Claude', url: 'https://claude.com/favicon.ico' }, { id: 'chatgpt', name: 'ChatGPT', url: 'https://chatgpt.com/favicon.ico' }, - { id: 'wechat', name: 'WeChat', url: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico', iconDomain: 'weixin.qq.com' }, + { id: 'wechat', name: 'WeChat', url: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico', iconDomain: 'weixin.qq.com', siteUrl: 'https://wx.qq.com' }, ]; // Favicon-fetch targets for the committed scripts/fetch-favicons.js @@ -59,7 +71,8 @@ const builtinMember = (id) => { // Every list is fronted by an emoji (flag emoji for country lists) — one // uniform icon system, no icon-font dependency in this data. // Member shape: { id, name, url } (+ optional `iconDomain` when the test -// URL's host would give the favicon fetcher a poor or missing icon). +// URL's host would give the favicon fetcher a poor or missing icon, and +// optional `siteUrl` when that host isn't the right page to open either). export const IMPORT_LISTS = [ { id: 'iran', @@ -261,15 +274,15 @@ export const IMPORT_LISTS = [ emoji: '👨‍💻', members: [ builtinMember('github'), - { id: 'npm', name: 'npm', url: 'https://registry.npmjs.org/favicon.ico', iconDomain: 'www.npmjs.com' }, + { id: 'npm', name: 'npm', url: 'https://registry.npmjs.org/favicon.ico', iconDomain: 'www.npmjs.com', siteUrl: 'https://www.npmjs.com' }, { id: 'dockerhub', name: 'Docker Hub', url: 'https://hub.docker.com/favicon.ico', iconDomain: 'www.docker.com' }, { id: 'stackoverflow', name: 'Stack Overflow', url: 'https://stackoverflow.com/favicon.ico' }, { id: 'pypi', name: 'PyPI', url: 'https://pypi.org/favicon.ico' }, { id: 'gitlab', name: 'GitLab', url: 'https://gitlab.com/favicon.ico' }, { id: 'bitbucket', name: 'Bitbucket', url: 'https://bitbucket.org/favicon.ico' }, { id: 'crates', name: 'crates.io', url: 'https://crates.io/favicon.ico' }, - { id: 'maven', name: 'Maven Central', url: 'https://repo.maven.apache.org/favicon.ico', iconDomain: 'maven.apache.org' }, - { id: 'goproxy', name: 'Go Proxy', url: 'https://proxy.golang.org/favicon.ico', iconDomain: 'go.dev' }, + { id: 'maven', name: 'Maven Central', url: 'https://repo.maven.apache.org/favicon.ico', iconDomain: 'maven.apache.org', siteUrl: 'https://maven.apache.org' }, + { id: 'goproxy', name: 'Go Proxy', url: 'https://proxy.golang.org/favicon.ico', iconDomain: 'go.dev', siteUrl: 'https://go.dev' }, { id: 'homebrew', name: 'Homebrew', url: 'https://brew.sh/favicon.ico' }, { id: 'jetbrains', name: 'JetBrains', url: 'https://www.jetbrains.com/favicon.ico' }, { id: 'vscode-marketplace', name: 'VS Code Marketplace', url: 'https://marketplace.visualstudio.com/favicon.ico', iconDomain: 'code.visualstudio.com' }, @@ -289,7 +302,7 @@ export const IMPORT_LISTS = [ { id: 'oraclecloud', name: 'Oracle Cloud', url: 'https://www.oracle.com/favicon.ico' }, { id: 'akamai', name: 'Akamai', url: 'https://www.akamai.com/favicon.ico' }, { id: 'fastly', name: 'Fastly', url: 'https://www.fastly.com/favicon.ico' }, - { id: 'jsdelivr', name: 'jsDelivr', url: 'https://cdn.jsdelivr.net/favicon.ico', iconDomain: 'www.jsdelivr.com' }, + { id: 'jsdelivr', name: 'jsDelivr', url: 'https://cdn.jsdelivr.net/favicon.ico', iconDomain: 'www.jsdelivr.com', siteUrl: 'https://www.jsdelivr.com' }, { id: 'unpkg', name: 'unpkg', url: 'https://unpkg.com/favicon.ico' }, { id: 'heroku', name: 'Heroku', url: 'https://www.heroku.com/favicon.ico' }, ], diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index 82b35da2b..cd20d14b9 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -27,12 +27,17 @@ export const DEFAULT_PREFERENCES = Object.freeze({ ipHistoryEnabled: true, ipHistoryDays: 90, lang: 'auto', // auto | zh | en | fr | ru - // The full Connectivity target set (defaults included). null until first - // load — store.loadPreferences() runs it through sanitizeTargets(), which - // also rebuilds a hand-emptied or corrupted stored value. + // Multi-list Connectivity model: { schemaVersion, lists }. null until + // first load — store.loadPreferences() runs it through sanitizeLists(). + connectivityLists: null, + // Which list the Connectivity section opens on; a stale id falls back + // to "Mine". + connectivityDefaultListId: 'mine', + // On: card titles open the site in a new tab; off: plain text. + connectivityCardTitleOpensSite: false, + // Legacy pre-connectivityLists keys, oldest last. Read once by the + // migration; kept for rollback, never written. connectivityTargets: null, - // Legacy pre-connectivityTargets key. Read once by the build above, kept - // for rollback; never written. customConnectivityTargets: [], }); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index bf9144db3..6ee60d216 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -331,10 +331,16 @@ "autoRunTips": "IP info is always checked when the page loads. Choose which other modules run automatically on startup.", "simpleMode": "Simple Mode", "simpleModeTips": "Hide the description text of each module, suitable for users who are already familiar with the application.", - "connectivityMultipleTests": "Multiple Connectivity Refreshes", - "connectivityMultipleTestsTips": "When enabled, the app runs multiple checks at startup and displays the lowest latency.", - "popupConnectivityNotifications": "Pop-up Connectivity Alerts", - "popupConnectivityNotificationsTips": "When enabled, the initial check's results are shown as a pop-up alert.", + "connectivity": { + "defaultList": "Default List", + "defaultListTips": "The list the section opens on when the page loads.", + "multipleTests": "Multiple Refreshes", + "multipleTestsTips": "Run several checks per pass and show the lowest latency.", + "notifications": "Pop-up Alerts", + "notificationsTips": "Show the initial check's results as a pop-up alert.", + "titleOpensSite": "Card Title Opens Website", + "titleOpensSiteTips": "Clicking a card's title opens the site in a new tab; off leaves the title as plain text." + }, "ipHistory": "IP History", "ipHistoryEnabled": "Record IP History", "ipHistoryDays": "Retention Days", @@ -1211,6 +1217,7 @@ "checking": "Checking...", "minTestTime": "Min Test Time: ", "RefreshThisTest": "Refresh This Test", + "OpenSite": "Open website", "RoundCount": "Round {n}: ", "addCustom": { "AddCard": "Add Test", @@ -1235,8 +1242,26 @@ "ImportOption": "Import a List", "SiteCount": "{n} sites", "Import": "Import", - "Capacity": "{used}/{limit} used", - "NotEnoughCapacity": "Not enough capacity — this list needs {need} slots, only {free} left." + "NotEnoughCapacity": "Not enough capacity — this list needs {need} slots, only {free} left.", + "Destination": "Add to", + "ImportAsNew": "Import as a new list" + }, + "lists": { + "Tab": "Lists", + "Mine": "My Sites", + "Manage": "Manage lists", + "NewListPlaceholder": "New list name", + "Create": "Create", + "LimitReached": "You can keep up to {limit} lists.", + "Rename": "Rename", + "Delete": "Delete", + "MoveUp": "Move up", + "MoveDown": "Move down", + "DeleteNotEmpty": "Remove all its sites before deleting this list.", + "MineUndeletable": "The default list can't be deleted.", + "Save": "Save", + "Cancel": "Cancel", + "Hint": "A list must be emptied before it can be deleted; \"My Sites\" always stays." }, "importLists": { "defaults": "System Defaults", @@ -1413,7 +1438,7 @@ "HelpNote": "People who use shortcuts are really awesome!", "GoNext": "Next Card", "GoPrevious": "Previous Card", - "OpenHighlightedTool": "Open Highlighted Advanced Tool", + "OpenHighlightedTool": "Open Highlighted Card (Tool or Website)", "RefreshRuleTests": "Refresh Rule Tests", "CensorshipCheck": "Open Censorship Check panel", "Pulse": "Open Earth Online panel", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 7c2f7e346..a7eea7b1d 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -331,10 +331,16 @@ "autoRunTips": "Les infos IP sont toujours vérifiées au chargement de la page. Choisissez quels autres modules s'exécutent automatiquement au démarrage.", "simpleMode": "Mode Simple", "simpleModeTips": "Masque le texte de description de chaque module. Effectif uniquement sur les appareils mobiles.", - "connectivityMultipleTests": "Rafraîchissements Multiples de Connectivité", - "connectivityMultipleTestsTips": "Activé, l'appli effectue plusieurs vérifications au démarrage et affiche le délai le plus bas.", - "popupConnectivityNotifications": "Alertes de Connectivité Pop-up", - "popupConnectivityNotificationsTips": "Activé, les résultats de la première vérification sont affichés sous forme d'alerte pop-up.", + "connectivity": { + "defaultList": "Liste par défaut", + "defaultListTips": "La liste affichée par la section au chargement de la page.", + "multipleTests": "Rafraîchissements multiples", + "multipleTestsTips": "Effectue plusieurs vérifications par passage et affiche le délai le plus bas.", + "notifications": "Alertes pop-up", + "notificationsTips": "Affiche les résultats de la première vérification sous forme d'alerte pop-up.", + "titleOpensSite": "Le titre de la carte ouvre le site", + "titleOpensSiteTips": "Cliquer sur le titre d'une carte ouvre le site dans un nouvel onglet ; désactivé, le titre reste du texte simple." + }, "ipHistory": "Historique des IP", "ipHistoryEnabled": "Enregistrer l'historique des IP", "ipHistoryDays": "Jours de conservation", @@ -1211,6 +1217,7 @@ "checking": "Vérification en cours...", "minTestTime": "Minimal: ", "RefreshThisTest": "Rafraîchir ce test", + "OpenSite": "Ouvrir le site", "RoundCount": "Tour {n} : ", "addCustom": { "AddCard": "Ajouter un test", @@ -1235,8 +1242,26 @@ "ImportOption": "Importer une liste", "SiteCount": "{n} sites", "Import": "Importer", - "Capacity": "{used}/{limit} utilisés", - "NotEnoughCapacity": "Capacité insuffisante — cette liste nécessite {need} emplacements, seulement {free} restants." + "NotEnoughCapacity": "Capacité insuffisante — cette liste nécessite {need} emplacements, seulement {free} restants.", + "Destination": "Ajouter à", + "ImportAsNew": "Importer comme nouvelle liste" + }, + "lists": { + "Tab": "Listes", + "Mine": "Mes sites", + "Manage": "Gérer les listes", + "NewListPlaceholder": "Nom de la nouvelle liste", + "Create": "Créer", + "LimitReached": "Vous pouvez conserver jusqu'à {limit} listes.", + "Rename": "Renommer", + "Delete": "Supprimer", + "MoveUp": "Monter", + "MoveDown": "Descendre", + "DeleteNotEmpty": "Retirez tous ses sites avant de supprimer cette liste.", + "MineUndeletable": "La liste par défaut ne peut pas être supprimée.", + "Save": "Enregistrer", + "Cancel": "Annuler", + "Hint": "Une liste doit être vidée avant de pouvoir être supprimée ; « Mes sites » est permanente." }, "importLists": { "defaults": "Sites par défaut", @@ -1413,7 +1438,7 @@ "HelpNote": "Les personnes qui utilisent des raccourcis clavier sont vraiment géniales !", "GoNext": "Carte suivante", "GoPrevious": "Carte précédente", - "OpenHighlightedTool": "Ouvrir l'outil avancé sélectionné", + "OpenHighlightedTool": "Ouvrir la carte sélectionnée (outil ou site)", "RefreshRuleTests": "Actualiser les tests de règles", "CensorshipCheck": "Ouvrir le Test de censorship", "Pulse": "Ouvrir la Terre en ligne", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 76d5a6d13..059e6396f 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -331,10 +331,16 @@ "autoRunTips": "As informações de IP sempre são verificadas quando a página carrega. Escolha quais outros módulos serão executados automaticamente na inicialização.", "simpleMode": "Modo simples", "simpleModeTips": "Oculta o texto descritivo de cada módulo, adequado para usuários que já conhecem o aplicativo.", - "connectivityMultipleTests": "Múltiplas atualizações de conectividade", - "connectivityMultipleTestsTips": "Quando ativado, o aplicativo executa várias verificações na inicialização e exibe a menor latência.", - "popupConnectivityNotifications": "Alertas pop-up de conectividade", - "popupConnectivityNotificationsTips": "Quando ativado, os resultados da verificação inicial são exibidos como alerta pop-up.", + "connectivity": { + "defaultList": "Lista padrão", + "defaultListTips": "A lista exibida pela seção quando a página carrega.", + "multipleTests": "Múltiplas atualizações", + "multipleTestsTips": "Executa várias verificações por rodada e exibe a menor latência.", + "notifications": "Alertas pop-up", + "notificationsTips": "Mostra os resultados da verificação inicial como alerta pop-up.", + "titleOpensSite": "Título do cartão abre o site", + "titleOpensSiteTips": "Clicar no título de um cartão abre o site em uma nova aba; desativado, o título é apenas texto." + }, "ipHistory": "Histórico de IP", "ipHistoryEnabled": "Registrar histórico de IP", "ipHistoryDays": "Dias de retenção", @@ -1211,6 +1217,7 @@ "checking": "Verificando...", "minTestTime": "Tempo mínimo do teste: ", "RefreshThisTest": "Atualizar este teste", + "OpenSite": "Abrir site", "RoundCount": "Rodada {n}: ", "addCustom": { "AddCard": "Adicionar teste", @@ -1235,8 +1242,26 @@ "ImportOption": "Importar uma lista", "SiteCount": "{n} sites", "Import": "Importar", - "Capacity": "{used}/{limit} usados", - "NotEnoughCapacity": "Capacidade insuficiente — esta lista precisa de {need} vagas, mas só restam {free}." + "NotEnoughCapacity": "Capacidade insuficiente — esta lista precisa de {need} vagas, mas só restam {free}.", + "Destination": "Adicionar a", + "ImportAsNew": "Importar como nova lista" + }, + "lists": { + "Tab": "Listas", + "Mine": "Meus sites", + "Manage": "Gerenciar listas", + "NewListPlaceholder": "Nome da nova lista", + "Create": "Criar", + "LimitReached": "Você pode manter até {limit} listas.", + "Rename": "Renomear", + "Delete": "Excluir", + "MoveUp": "Mover para cima", + "MoveDown": "Mover para baixo", + "DeleteNotEmpty": "Remova todos os sites antes de excluir esta lista.", + "MineUndeletable": "A lista padrão não pode ser excluída.", + "Save": "Salvar", + "Cancel": "Cancelar", + "Hint": "Uma lista precisa estar vazia para ser excluída; \"Meus sites\" permanece sempre." }, "importLists": { "defaults": "Padrões do sistema", @@ -1413,7 +1438,7 @@ "HelpNote": "Pessoas que usam atalhos são realmente incríveis!", "GoNext": "Próximo cartão", "GoPrevious": "Cartão anterior", - "OpenHighlightedTool": "Abrir ferramenta avançada destacada", + "OpenHighlightedTool": "Abrir o cartão destacado (ferramenta ou site)", "RefreshRuleTests": "Atualizar testes de regras", "CensorshipCheck": "Abrir painel de verificação de censura", "Pulse": "Abrir painel Earth Online", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index d5720da95..3aef5cb28 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -331,10 +331,16 @@ "autoRunTips": "Сведения об IP всегда проверяются при загрузке страницы. Выберите другие модули, которые следует запускать автоматически.", "simpleMode": "Простой режим", "simpleModeTips": "Скрывает описание каждого модуля. Подходит пользователям, уже знакомым с приложением.", - "connectivityMultipleTests": "Несколько проверок подключения", - "connectivityMultipleTestsTips": "Если включено, приложение выполняет несколько проверок при запуске и показывает минимальную задержку.", - "popupConnectivityNotifications": "Всплывающие уведомления о подключении", - "popupConnectivityNotificationsTips": "Если включено, результаты первой проверки показываются во всплывающем уведомлении.", + "connectivity": { + "defaultList": "Список по умолчанию", + "defaultListTips": "Список, который раздел показывает при загрузке страницы.", + "multipleTests": "Несколько проверок", + "multipleTestsTips": "Выполнять несколько проверок за прогон и показывать минимальную задержку.", + "notifications": "Всплывающие уведомления", + "notificationsTips": "Показывать результаты первой проверки во всплывающем уведомлении.", + "titleOpensSite": "Заголовок карточки открывает сайт", + "titleOpensSiteTips": "Клик по заголовку карточки открывает сайт в новой вкладке; если выключено, заголовок — просто текст." + }, "ipHistory": "История IP", "ipHistoryEnabled": "Записывать историю IP", "ipHistoryDays": "Срок хранения в днях", @@ -1211,6 +1217,7 @@ "checking": "Проверка...", "minTestTime": "Минимальное время: ", "RefreshThisTest": "Обновить этот тест", + "OpenSite": "Открыть сайт", "RoundCount": "Раунд {n}: ", "addCustom": { "AddCard": "Добавить тест", @@ -1235,8 +1242,26 @@ "ImportOption": "Импорт списка", "SiteCount": "Сайтов: {n}", "Import": "Импорт", - "Capacity": "Использовано {used}/{limit}", - "NotEnoughCapacity": "Недостаточно места — для списка нужно слотов: {need}, свободно: {free}." + "NotEnoughCapacity": "Недостаточно места — для списка нужно слотов: {need}, свободно: {free}.", + "Destination": "Добавить в", + "ImportAsNew": "Импортировать как новый список" + }, + "lists": { + "Tab": "Списки", + "Mine": "Мои сайты", + "Manage": "Управление списками", + "NewListPlaceholder": "Название нового списка", + "Create": "Создать", + "LimitReached": "Можно создать не более {limit} списков.", + "Rename": "Переименовать", + "Delete": "Удалить", + "MoveUp": "Выше", + "MoveDown": "Ниже", + "DeleteNotEmpty": "Сначала удалите все сайты из этого списка.", + "MineUndeletable": "Список по умолчанию нельзя удалить.", + "Save": "Сохранить", + "Cancel": "Отмена", + "Hint": "Список можно удалить только после его очистки; список «Мои сайты» сохраняется всегда." }, "importLists": { "defaults": "Системный набор", @@ -1413,7 +1438,7 @@ "HelpNote": "Пользователи сочетаний клавиш — просто супер!", "GoNext": "Следующая карточка", "GoPrevious": "Предыдущая карточка", - "OpenHighlightedTool": "Открыть выделенный расширенный инструмент", + "OpenHighlightedTool": "Открыть выделенную карточку (инструмент или сайт)", "RefreshRuleTests": "Обновить проверки правил", "CensorshipCheck": "Открыть панель проверки цензуры", "Pulse": "Открыть Землю онлайн", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 7ae143c66..768278746 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -331,10 +331,16 @@ "autoRunTips": "頁面載入時一定會檢查 IP 資訊。你可以選擇其他模組是否在啟動時自動執行。", "simpleMode": "簡潔模式", "simpleModeTips": "隱藏每個模組的說明文字,適合已熟悉本應用程式的使用者。", - "connectivityMultipleTests": "多次重新整理連線測試", - "connectivityMultipleTestsTips": "啟用後,應用程式會在啟動時執行多次檢查,並顯示最低延遲。", - "popupConnectivityNotifications": "彈出連線通知", - "popupConnectivityNotificationsTips": "啟用後,初次檢查的結果會以彈出通知顯示。", + "connectivity": { + "defaultList": "預設清單", + "defaultListTips": "開啟頁面時,連線測試預設顯示的清單。", + "multipleTests": "多次重新整理", + "multipleTestsTips": "每輪檢測執行多次,並顯示最低延遲。", + "notifications": "彈出通知", + "notificationsTips": "初次檢查完成後,以彈出通知顯示結果。", + "titleOpensSite": "點擊標題開啟網站", + "titleOpensSiteTips": "開啟後,點擊卡片標題會在新視窗開啟對應網站;關閉時標題為純文字。" + }, "ipHistory": "IP 歷史記錄", "ipHistoryEnabled": "記錄 IP 歷史", "ipHistoryDays": "保留天數", @@ -1211,6 +1217,7 @@ "checking": "檢查中...", "minTestTime": "最小測試時間:", "RefreshThisTest": "重新整理此測試", + "OpenSite": "開啟網站", "RoundCount": "第 {n} 次:", "addCustom": { "AddCard": "新增測試", @@ -1235,8 +1242,26 @@ "ImportOption": "匯入清單", "SiteCount": "{n} 個網站", "Import": "匯入", - "Capacity": "已用 {used}/{limit}", - "NotEnoughCapacity": "容量不足——該清單需要 {need} 個空位,僅剩 {free} 個" + "NotEnoughCapacity": "容量不足——該清單需要 {need} 個空位,僅剩 {free} 個", + "Destination": "加入到", + "ImportAsNew": "匯入為新清單" + }, + "lists": { + "Tab": "清單", + "Mine": "我的", + "Manage": "管理清單", + "NewListPlaceholder": "新清單名稱", + "Create": "建立", + "LimitReached": "最多可建立 {limit} 個清單", + "Rename": "重新命名", + "Delete": "刪除", + "MoveUp": "上移", + "MoveDown": "下移", + "DeleteNotEmpty": "刪除清單前,請先移除其中的所有網站", + "MineUndeletable": "「我的」清單無法刪除", + "Save": "儲存", + "Cancel": "取消", + "Hint": "清單需要清空後才能刪除;「我的」清單會一直保留。" }, "importLists": { "defaults": "系統預設", @@ -1413,7 +1438,7 @@ "HelpNote": "使用快捷鍵的人,是喜歡效率的人,你真棒!", "GoNext": "下一張卡片", "GoPrevious": "上一張卡片", - "OpenHighlightedTool": "開啟選中的進階工具", + "OpenHighlightedTool": "開啟選取的卡片(工具或網站)", "RefreshRuleTests": "重新整理規則測試", "CensorshipCheck": "開啟封鎖測試面板", "Pulse": "開啟地球線上面板", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 4eec0bb64..5efb94bdc 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -331,10 +331,16 @@ "autoRunTips": "打开页面时始终会检测 IP 信息。你可以选择其它模块是否在启动时自动运行。", "simpleMode": "简洁模式", "simpleModeTips": "隐藏每个模块的说明文字,适合已经了解本应用的用户。", - "connectivityMultipleTests": "多次刷新网络连通性检测", - "connectivityMultipleTestsTips": "开启多次刷新后,程序在启动时将运行多次检测,并显示最小延迟值。", - "popupConnectivityNotifications": "显示网络连通性检测气泡", - "popupConnectivityNotificationsTips": "开启后,将会在首次检测时以气泡形式提示网络连通性结果。", + "connectivity": { + "defaultList": "默认列表", + "defaultListTips": "打开页面时,连通性测试默认显示的列表。", + "multipleTests": "多次刷新", + "multipleTestsTips": "每轮检测运行多次,并显示最小延迟值。", + "notifications": "气泡提示", + "notificationsTips": "首次检测完成后,以气泡形式提示结果。", + "titleOpensSite": "点击标题打开网站", + "titleOpensSiteTips": "开启后,点击卡片标题会在新窗口打开对应网站;关闭时标题为纯文本。" + }, "ipHistory": "IP 历史记录", "ipHistoryEnabled": "记录 IP 历史", "ipHistoryDays": "保留天数", @@ -1211,6 +1217,7 @@ "checking": "检查中...", "minTestTime": "最小测试时间:", "RefreshThisTest": "刷新此测试", + "OpenSite": "打开网站", "RoundCount": "第 {n} 次:", "addCustom": { "AddCard": "添加测试", @@ -1235,8 +1242,26 @@ "ImportOption": "导入列表", "SiteCount": "{n} 个网站", "Import": "导入", - "Capacity": "已用 {used}/{limit}", - "NotEnoughCapacity": "容量不足——该列表需要 {need} 个空位,仅剩 {free} 个" + "NotEnoughCapacity": "容量不足——该列表需要 {need} 个空位,仅剩 {free} 个", + "Destination": "添加到", + "ImportAsNew": "导入为新列表" + }, + "lists": { + "Tab": "列表", + "Mine": "我的", + "Manage": "管理列表", + "NewListPlaceholder": "新列表名称", + "Create": "创建", + "LimitReached": "最多可创建 {limit} 个列表", + "Rename": "重命名", + "Delete": "删除", + "MoveUp": "上移", + "MoveDown": "下移", + "DeleteNotEmpty": "删除列表前,请先移除其中的所有网站", + "MineUndeletable": "「我的」列表不可删除", + "Save": "保存", + "Cancel": "取消", + "Hint": "列表需要清空后才能删除;「我的」列表始终保留。" }, "importLists": { "defaults": "系统预设", @@ -1413,7 +1438,7 @@ "HelpNote": "使用快捷键的人,是喜欢效率的人,你真棒!", "GoNext": "下一张卡片", "GoPrevious": "上一张卡片", - "OpenHighlightedTool": "打开选中的高级工具", + "OpenHighlightedTool": "打开选中的卡片(工具或网站)", "RefreshRuleTests": "刷新规则测试", "CensorshipCheck": "打开封锁测试面板", "Pulse": "打开地球在线面板", diff --git a/frontend/store.js b/frontend/store.js index 1b2ab93b6..bb966f65c 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -6,7 +6,7 @@ import i18n from './locales/i18n.js'; import { createInitialAchievementsState } from './data/achievements.js'; import { createInitialIpDBs, buildDbUrl, applyConfigAvailability, nearestEnabledId } from './data/ip-databases.js'; import { createDefaultPreferences, PREFS_STORAGE_KEY } from './data/default-preferences.js'; -import { sanitizeTargets } from './utils/connectivity-import.js'; +import { sanitizeLists } from './utils/connectivity-lists.js'; import { createMountingStatus, createLoadingStatus, DEFAULT_SECTION } from './data/sections.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; const { t } = i18n.global; @@ -213,12 +213,12 @@ export const useMainStore = defineStore('main', { const storedPreferences = localStorage.getItem(PREFS_STORAGE_KEY); const currentPreferences = storedPreferences ? JSON.parse(storedPreferences) : {}; const merged = { ...defaultPreferences, ...currentPreferences }; - // Missing keys get their defaults from the spread above; the - // Connectivity target set additionally guards its structure — junk - // entries drop, and an absent/emptied set rebuilds from the defaults - // + legacy flat customs (the legacy key stays for rollback, never - // written). Persisted right back via setPreferences. - merged.connectivityTargets = sanitizeTargets(merged.connectivityTargets, merged.customConnectivityTargets); + // The Connectivity multi-list model guards its structure: junk drops, + // an absent/corrupted model migrates from the legacy flat keys (kept + // for rollback, never written). + merged.connectivityLists = sanitizeLists( + merged.connectivityLists, merged.connectivityTargets, merged.customConnectivityTargets, + ); this.setPreferences(merged); }, // fetch configs from server diff --git a/frontend/utils/connectivity-import.js b/frontend/utils/connectivity-import.js deleted file mode 100644 index dcebc3db7..000000000 --- a/frontend/utils/connectivity-import.js +++ /dev/null @@ -1,80 +0,0 @@ -// Pure logic for the connectivityTargets preference: one-time build, -// import planning (hostname dedupe + all-or-nothing cap), the dialog's -// "fully present" check, and removal. Immutable — callers persist results. - -import { CONNECTIVITY_TARGET_LIMIT, DEFAULT_LIST_MEMBERS, faviconPath } from '../data/connectivity-import-lists.js'; - -const hostnameOf = (url) => { - try { - return new URL(url).hostname.toLowerCase(); - } catch { - return null; - } -}; - -// Stored target shape: { id, name, url, favicon? }. -const toStoredTarget = (m) => ({ - id: m.id, - name: m.name, - url: m.url, - ...(m.favicon ? { favicon: m.favicon } : {}), -}); - -// Defaults plus the legacy customConnectivityTargets entries (listId tags -// dropped). No cap here — migration must never drop sites; an over-cap -// set just can't grow. -export const buildInitialTargets = (legacyTargets = []) => [ - ...DEFAULT_LIST_MEMBERS.map((m) => toStoredTarget({ ...m, favicon: faviconPath(m.id) })), - ...legacyTargets.map(toStoredTarget), -]; - -// localStorage is user-editable, so a stored target can be arbitrary junk. -const isValidTarget = (t) => Boolean(t) - && typeof t.id === 'string' - && typeof t.name === 'string' - && typeof t.url === 'string'; - -// Boot-time guard for the stored preference: keeps the well-formed entries, -// and when none remain (missing key, emptied array, all junk) rebuilds from -// the defaults plus any legacy flat targets — the grid must never come up -// empty. Idempotent; runs on every load. -export const sanitizeTargets = (stored, legacy) => { - const valid = (list) => (Array.isArray(list) ? list.filter(isValidTarget) : []); - const targets = valid(stored); - if (targets.length > 0) return targets; - return buildInitialTargets(valid(legacy)); -}; - -// What importing a curated list would add. Dedupe is by hostname, so lists -// may overlap each other and the defaults. All-or-nothing: if the fresh -// members don't all fit under the cap, nothing is added and overflowCount -// reports the shortfall — a partial import would sit behind a full ✓. -export const planImport = (list, targets = [], { limit = CONNECTIVITY_TARGET_LIMIT } = {}) => { - const existingHosts = new Set(targets.map((t) => hostnameOf(t.url)).filter(Boolean)); - const fresh = list.members.filter((m) => !existingHosts.has(hostnameOf(m.url))); - const skippedCount = list.members.length - fresh.length; - const capacity = Math.max(0, limit - targets.length); - if (fresh.length > capacity) { - return { additions: [], skippedCount, overflowCount: fresh.length - capacity, freshCount: fresh.length, capacity }; - } - const additions = fresh.map((m) => toStoredTarget({ - ...m, - id: `import-${m.id}`, - favicon: faviconPath(m.id), - })); - return { additions, skippedCount, overflowCount: 0, freshCount: fresh.length, capacity }; -}; - -// True when every member of the curated list is already present (by -// hostname) — the dialog shows a check instead of the import button then. -export const isListFullyPresent = (list, targets = []) => { - const existingHosts = new Set(targets.map((t) => hostnameOf(t.url)).filter(Boolean)); - return list.members.every((m) => existingHosts.has(hostnameOf(m.url))); -}; - -// Remove one target; the set always keeps at least one site (the UI -// toasts on the last one — this guard holds for any caller). -export const removeTarget = (targets = [], id) => { - if (targets.length <= 1) return { error: 'last-target' }; - return { targets: targets.filter((t) => t.id !== id) }; -}; diff --git a/frontend/utils/connectivity-lists.js b/frontend/utils/connectivity-lists.js new file mode 100644 index 000000000..9d456e94d --- /dev/null +++ b/frontend/utils/connectivity-lists.js @@ -0,0 +1,206 @@ +// Pure logic for the multi-list Connectivity model stored in the +// `connectivityLists` preference: boot-time sanitize/migration, list CRUD +// guards, member add/remove, curated-list import planning, and the card's +// open-website link. Immutable — callers persist the returned `lists`. + +import { + CONNECTIVITY_TARGET_LIMIT, + CONNECTIVITY_LIST_LIMIT, + CONNECTIVITY_LIST_NAME_LIMIT, + MINE_LIST_ID, + DEFAULT_LIST_MEMBERS, + faviconPath, +} from '../data/connectivity-import-lists.js'; + +export const CONNECTIVITY_LISTS_SCHEMA_VERSION = 1; + +const hostnameOf = (url) => { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +}; + +// The card's open-website link: explicit override, else the test URL's origin. +export const siteUrlOf = (target) => { + if (target?.siteUrl) return target.siteUrl; + try { + return new URL(target.url).origin; + } catch { + return null; + } +}; + +// Stored member shape: { id, name, url, siteUrl?, favicon? }. +const toStoredMember = (m) => ({ + id: m.id, + name: m.name, + url: m.url, + ...(m.siteUrl ? { siteUrl: m.siteUrl } : {}), + ...(m.favicon ? { favicon: m.favicon } : {}), +}); + +// localStorage is user-editable, so stored entries can be arbitrary junk. +const isValidMember = (m) => Boolean(m) + && typeof m.id === 'string' + && typeof m.name === 'string' + && typeof m.url === 'string'; + +const validMembers = (list) => (Array.isArray(list) ? list.filter(isValidMember) : []); + +// Defaults plus legacy entries (provenance tags dropped). No cap here — +// migration must never drop sites; an over-cap set just can't grow. +const buildInitialMembers = (legacyTargets = []) => [ + ...DEFAULT_LIST_MEMBERS.map((m) => toStoredMember({ ...m, favicon: faviconPath(m.id) })), + ...legacyTargets.map(toStoredMember), +]; + +// Boot-time guard, idempotent. A missing/corrupted model rebuilds around +// one "Mine" list seeded from the legacy keys (connectivityTargets, then +// customConnectivityTargets + defaults); junk entries drop, and a missing +// or hand-emptied Mine is re-seeded with the defaults. +export const sanitizeLists = (stored, legacyFlatTargets, legacyCustomTargets) => { + const legacyMembers = () => { + const flat = validMembers(legacyFlatTargets); + if (flat.length > 0) return flat.map(toStoredMember); + return buildInitialMembers(validMembers(legacyCustomTargets)); + }; + + const isValidList = (l) => Boolean(l) + && typeof l.id === 'string' + && (l.id === MINE_LIST_ID || (typeof l.name === 'string' && l.name.trim().length > 0)) + && Array.isArray(l.members); + + const storedLists = Array.isArray(stored?.lists) ? stored.lists.filter(isValidList) : []; + const lists = storedLists.map((l) => ({ + id: l.id, + // Mine's display name is localized, never stored. + name: l.id === MINE_LIST_ID ? null : l.name, + members: l.members.filter(isValidMember).map(toStoredMember), + })); + + const mine = lists.find((l) => l.id === MINE_LIST_ID); + if (!mine) { + lists.unshift({ id: MINE_LIST_ID, name: null, members: legacyMembers() }); + } else if (mine.members.length === 0) { + mine.members = buildInitialMembers(); + } + return { schemaVersion: CONNECTIVITY_LISTS_SCHEMA_VERSION, lists }; +}; + +const listById = (lists, id) => lists.find((l) => l.id === id); + +const replaceList = (lists, updated) => lists.map((l) => (l.id === updated.id ? updated : l)); + +// ── List CRUD ────────────────────────────────────────────────────────────── +// Each op returns { lists } or { error: '' }; guards live here, not +// in the UI, so the rules hold for every caller. + +export const createList = (lists, name) => { + const trimmed = (name || '').trim().slice(0, CONNECTIVITY_LIST_NAME_LIMIT); + if (!trimmed) return { error: 'name-required' }; + if (lists.length >= CONNECTIVITY_LIST_LIMIT) return { error: 'list-limit' }; + return { lists: [...lists, { id: `list-${Date.now()}`, name: trimmed, members: [] }] }; +}; + +export const renameList = (lists, listId, name) => { + if (listId === MINE_LIST_ID) return { error: 'immutable' }; + const trimmed = (name || '').trim().slice(0, CONNECTIVITY_LIST_NAME_LIMIT); + if (!trimmed) return { error: 'name-required' }; + const list = listById(lists, listId); + if (!list) return { error: 'not-found' }; + return { lists: replaceList(lists, { ...list, name: trimmed }) }; +}; + +// direction: -1 moves toward the front, +1 toward the back. Out-of-range +// moves are no-ops (the UI disables the buttons at the edges anyway). +export const moveList = (lists, listId, direction) => { + const from = lists.findIndex((l) => l.id === listId); + const to = from + direction; + if (from === -1 || to < 0 || to >= lists.length) return { lists }; + const next = [...lists]; + [next[from], next[to]] = [next[to], next[from]]; + return { lists: next }; +}; + +export const deleteList = (lists, listId) => { + if (listId === MINE_LIST_ID) return { error: 'immutable' }; + const list = listById(lists, listId); + if (!list) return { error: 'not-found' }; + if (list.members.length > 0) return { error: 'not-empty' }; + return { lists: lists.filter((l) => l.id !== listId) }; +}; + +// ── Members ──────────────────────────────────────────────────────────────── +// Hand-adds don't hostname-dedupe: probing two endpoints on one host is a +// legitimate custom setup. Only curated imports dedupe (below). + +export const addMember = (lists, listId, member) => { + const list = listById(lists, listId); + if (!list) return { error: 'not-found' }; + if (list.members.length >= CONNECTIVITY_TARGET_LIMIT) return { error: 'list-full' }; + return { lists: replaceList(lists, { ...list, members: [...list.members, toStoredMember(member)] }) }; +}; + +export const removeMember = (lists, listId, memberId) => { + const list = listById(lists, listId); + if (!list) return { error: 'not-found' }; + // Mine — the grid's fallback list — always keeps at least one site. + if (listId === MINE_LIST_ID && list.members.length <= 1) return { error: 'last-of-mine' }; + return { lists: replaceList(lists, { ...list, members: list.members.filter((m) => m.id !== memberId) }) }; +}; + +// ── Curated-list import ──────────────────────────────────────────────────── + +// What importing a curated list into `targetList` would add. Dedupe is by +// hostname within the target list only. All-or-nothing: when the fresh +// members don't all fit under the cap, nothing is added and `overflowCount` +// reports the shortfall. +export const planImport = (curatedList, targetList, { limit = CONNECTIVITY_TARGET_LIMIT } = {}) => { + const existingHosts = new Set(targetList.members.map((m) => hostnameOf(m.url)).filter(Boolean)); + const fresh = curatedList.members.filter((m) => !existingHosts.has(hostnameOf(m.url))); + const skippedCount = curatedList.members.length - fresh.length; + const capacity = Math.max(0, limit - targetList.members.length); + if (fresh.length > capacity) { + return { additions: [], skippedCount, overflowCount: fresh.length - capacity, freshCount: fresh.length, capacity }; + } + const additions = fresh.map((m) => toStoredMember({ + ...m, + id: `import-${m.id}`, + favicon: faviconPath(m.id), + })); + return { additions, skippedCount, overflowCount: 0, freshCount: fresh.length, capacity }; +}; + +// True when every member of the curated list is already present (by +// hostname) in the target list — its menu entry goes inert then. +export const isListFullyPresent = (curatedList, targetList) => { + const existingHosts = new Set(targetList.members.map((m) => hostnameOf(m.url)).filter(Boolean)); + return curatedList.members.every((m) => existingHosts.has(hostnameOf(m.url))); +}; + +// Import a curated list into an existing list. `capacity` failures carry +// the plan for the error message. +export const importIntoList = (lists, listId, curatedList) => { + const list = listById(lists, listId); + if (!list) return { error: 'not-found' }; + const plan = planImport(curatedList, list); + if (plan.overflowCount) return { error: 'capacity', plan }; + return { lists: replaceList(lists, { ...list, members: [...list.members, ...plan.additions] }), plan }; +}; + +// Create a fresh list named after the curated one and import into it. +// Curated lists all fit the per-list cap, so only the list-count guard +// can fail here. +export const importAsNewList = (lists, curatedList, name) => { + const created = createList(lists, name); + if (created.error) return created; + const newList = created.lists[created.lists.length - 1]; + const plan = planImport(curatedList, newList); + return { + lists: replaceList(created.lists, { ...newList, members: plan.additions }), + listId: newList.id, + plan, + }; +}; diff --git a/tests/composable-shortcuts.test.js b/tests/composable-shortcuts.test.js index 7f17d5f81..d1ec0c7b0 100644 --- a/tests/composable-shortcuts.test.js +++ b/tests/composable-shortcuts.test.js @@ -304,6 +304,7 @@ describe('useShortcuts()', () => { if (sel === '.keyboard-shortcut-card[data-keyboard-hover="true"]') { return { getAttribute: (name) => name === 'data-adv-slug' ? 'mtrtest' : null, + querySelector: () => null, }; } return null; @@ -314,9 +315,28 @@ describe('useShortcuts()', () => { assert.deepEqual(calls.advancedNavigate, ['mtrtest']); }); - it('"o" is a no-op when the highlighted card has no data-adv-slug (e.g. IP card)', () => { + it('"o" clicks the highlighted Connectivity card\'s site link (a[data-card-open])', () => { const { keyMap, calls } = loadAndGetKeyMap(); - querySelectorImpl = () => ({ getAttribute: () => null }); + let clicked = 0; + querySelectorImpl = (sel) => { + if (sel === '.keyboard-shortcut-card[data-keyboard-hover="true"]') { + return { + getAttribute: () => null, + querySelector: (inner) => (inner === 'a[data-card-open][href]' ? { click: () => { clicked += 1; } } : null), + }; + } + return null; + }; + const entry = keyMap.findLast((e) => e.keys === 'o'); + entry.action(); + querySelectorImpl = () => null; + assert.equal(clicked, 1); + assert.deepEqual(calls.advancedNavigate, []); + }); + + it('"o" is a no-op when the highlighted card has no slug and no site link (e.g. IP card)', () => { + const { keyMap, calls } = loadAndGetKeyMap(); + querySelectorImpl = () => ({ getAttribute: () => null, querySelector: () => null }); const entry = keyMap.findLast((e) => e.keys === 'o'); entry.action(); querySelectorImpl = () => null; diff --git a/tests/connectivity-import-lists.test.js b/tests/connectivity-import-lists.test.js index 4d41a402f..a22cba62f 100644 --- a/tests/connectivity-import-lists.test.js +++ b/tests/connectivity-import-lists.test.js @@ -117,6 +117,13 @@ describe('import lists data integrity', () => { } }); + it('every siteUrl override (members + defaults) is HTTPS and parseable', () => { + for (const m of [...allMembers, ...DEFAULT_LIST_MEMBERS]) { + if (m.siteUrl === undefined) continue; + assert.equal(new URL(m.siteUrl).protocol, 'https:', m.id); + } + }); + it('every member and built-in target has a committed favicon PNG', () => { const builtinIds = BUILTIN_FAVICONS.map((b) => b.id); for (const id of [...allMembers.map((m) => m.id), ...builtinIds]) { diff --git a/tests/connectivity-import.test.js b/tests/connectivity-import.test.js deleted file mode 100644 index 1a5858dba..000000000 --- a/tests/connectivity-import.test.js +++ /dev/null @@ -1,151 +0,0 @@ -// Tests for frontend/utils/connectivity-import.js: initial build, import -// planning, the "fully present" check, and removal with its guard. - -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - buildInitialTargets, - sanitizeTargets, - planImport, - isListFullyPresent, - removeTarget, -} from '../frontend/utils/connectivity-import.js'; -import { - CONNECTIVITY_TARGET_LIMIT, - DEFAULT_LIST_MEMBERS, -} from '../frontend/data/connectivity-import-lists.js'; - -const target = (id, host = `${id}.example`) => ({ id, name: id, url: `https://${host}/favicon.ico` }); - -const sampleList = { - id: 'sample', - members: [ - { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/favicon.ico' }, - { id: 'beta', name: 'Beta', url: 'https://www.beta.example/favicon.ico' }, - { id: 'gamma', name: 'Gamma', url: 'https://gamma.example/favicon.ico' }, - ], -}; - -describe('buildInitialTargets', () => { - it('seeds the defaults plus legacy targets, dropping provenance tags', () => { - const legacy = [ - { id: 'custom-1', name: 'Probe', url: 'https://probe.example/health' }, - { id: 'import-ozon', name: 'Ozon', url: 'https://www.ozon.ru/favicon.ico', listId: 'russia', favicon: '/favicons/ozon.png' }, - ]; - const targets = buildInitialTargets(legacy); - assert.equal(targets.length, DEFAULT_LIST_MEMBERS.length + 2); - // Defaults carry their committed favicon. - const github = targets.find((m) => m.id === 'github'); - assert.equal(github.favicon, '/favicons/github.png'); - // Legacy entries keep their favicon but lose the listId tag. - const ozon = targets.find((m) => m.id === 'import-ozon'); - assert.equal(ozon.favicon, '/favicons/ozon.png'); - assert.equal('listId' in ozon, false); - }); - - it('never drops legacy sites, even past the cap', () => { - const legacy = Array.from({ length: CONNECTIVITY_TARGET_LIMIT }, (_, i) => target(`custom-${i}`)); - const targets = buildInitialTargets(legacy); - assert.equal(targets.length, DEFAULT_LIST_MEMBERS.length + CONNECTIVITY_TARGET_LIMIT); - }); -}); - -describe('sanitizeTargets', () => { - it('passes a well-formed stored set through untouched', () => { - const stored = [target('a'), target('b')]; - assert.deepEqual(sanitizeTargets(stored, []), stored); - }); - - it('drops junk entries but keeps the valid rest', () => { - const good = target('a'); - const result = sanitizeTargets([good, null, { id: 42 }, { name: 'no-url' }], []); - assert.deepEqual(result, [good]); - }); - - it('reseeds the defaults when both stored and legacy are emptied', () => { - const result = sanitizeTargets([], []); - assert.deepEqual(result.map((m) => m.id), DEFAULT_LIST_MEMBERS.map((m) => m.id)); - }); - - it('reseeds defaults + legacy when only the stored set is emptied', () => { - const legacy = [target('custom-1')]; - const result = sanitizeTargets([], legacy); - assert.equal(result.length, DEFAULT_LIST_MEMBERS.length + 1); - assert.equal(result.at(-1).id, 'custom-1'); - }); - - it('treats non-array values (missing key, corruption) as empty', () => { - assert.equal(sanitizeTargets(null, undefined).length, DEFAULT_LIST_MEMBERS.length); - assert.equal(sanitizeTargets('junk', { not: 'an array' }).length, DEFAULT_LIST_MEMBERS.length); - }); -}); - -describe('planImport', () => { - it('materializes members with import ids and favicons', () => { - const { additions, skippedCount, overflowCount } = planImport(sampleList, []); - assert.equal(additions.length, 3); - assert.equal(skippedCount, 0); - assert.equal(overflowCount, 0); - assert.deepEqual(additions[0], { - id: 'import-alpha', - name: 'Alpha', - url: 'https://alpha.example/favicon.ico', - favicon: '/favicons/alpha.png', - }); - }); - - it('skips members whose hostname is already present', () => { - const { additions, skippedCount } = planImport(sampleList, [ - target('x', 'www.beta.example'), - target('y', 'unrelated.example'), - ]); - assert.deepEqual(additions.map((a) => a.id), ['import-alpha', 'import-gamma']); - assert.equal(skippedCount, 1); - }); - - it('refuses a partial import when the list does not fully fit (all-or-nothing)', () => { - const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 1 }, (_, i) => target(`m${i}`)); - const { additions, overflowCount, freshCount, capacity } = planImport(sampleList, existing); - assert.equal(additions.length, 0); - assert.equal(overflowCount, 2); - assert.equal(freshCount, 3); - assert.equal(capacity, 1); - }); - - it('imports in full when the list exactly fits the remaining capacity', () => { - const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 3 }, (_, i) => target(`m${i}`)); - const { additions, overflowCount } = planImport(sampleList, existing); - assert.equal(additions.length, 3); - assert.equal(overflowCount, 0); - }); - - it('an over-cap set (legacy migration) can only shed, never gain', () => { - const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT + 5 }, (_, i) => target(`m${i}`)); - const { additions, overflowCount, capacity } = planImport(sampleList, existing); - assert.equal(additions.length, 0); - assert.equal(capacity, 0); - assert.equal(overflowCount, 3); - }); -}); - -describe('isListFullyPresent', () => { - it('is true when every member hostname exists in the target set', () => { - const targets = [target('x', 'alpha.example'), target('y', 'www.beta.example'), target('z', 'gamma.example')]; - assert.equal(isListFullyPresent(sampleList, targets), true); - }); - - it('is false while any member is missing', () => { - assert.equal(isListFullyPresent(sampleList, [target('x', 'alpha.example')]), false); - }); -}); - -describe('removeTarget', () => { - it('removes the matching target', () => { - const { targets } = removeTarget([target('a'), target('b')], 'a'); - assert.deepEqual(targets.map((m) => m.id), ['b']); - }); - - it('refuses to remove the last remaining target', () => { - assert.equal(removeTarget([target('a')], 'a').error, 'last-target'); - }); -}); diff --git a/tests/connectivity-lists.test.js b/tests/connectivity-lists.test.js new file mode 100644 index 000000000..dcd36d610 --- /dev/null +++ b/tests/connectivity-lists.test.js @@ -0,0 +1,352 @@ +// Tests for the multi-list Connectivity model +// (frontend/utils/connectivity-lists.js): boot-time sanitize/migration from +// both legacy eras, list CRUD guard rules, member add/remove guards, import +// planning (per-list hostname dedupe + all-or-nothing cap), the combined +// import ops, and the card's open-website link derivation. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + sanitizeLists, + createList, + renameList, + moveList, + deleteList, + addMember, + removeMember, + planImport, + isListFullyPresent, + importIntoList, + importAsNewList, + siteUrlOf, +} from '../frontend/utils/connectivity-lists.js'; +import { + CONNECTIVITY_TARGET_LIMIT, + CONNECTIVITY_LIST_LIMIT, + CONNECTIVITY_LIST_NAME_LIMIT, + MINE_LIST_ID, + DEFAULT_LIST_MEMBERS, +} from '../frontend/data/connectivity-import-lists.js'; + +const member = (id, host = `${id}.example`) => ({ id, name: id, url: `https://${host}/favicon.ico` }); + +const makeLists = (...lists) => lists.map((l) => ({ members: [], ...l })); + +const mineWith = (members) => makeLists({ id: MINE_LIST_ID, name: null, members }); + +describe('sanitizeLists', () => { + it('adopts the flat connectivityTargets era as Mine, untouched', () => { + const flat = [member('a'), member('b')]; + const model = sanitizeLists(null, flat, []); + assert.equal(model.schemaVersion, 1); + assert.equal(model.lists.length, 1); + assert.equal(model.lists[0].id, MINE_LIST_ID); + assert.equal(model.lists[0].name, null); + assert.deepEqual(model.lists[0].members.map((m) => m.id), ['a', 'b']); + }); + + it('falls back to defaults + oldest-era customs when the flat set is empty', () => { + const legacy = [ + { id: 'custom-1', name: 'Probe', url: 'https://probe.example/health' }, + { id: 'import-ozon', name: 'Ozon', url: 'https://www.ozon.ru/favicon.ico', listId: 'russia', favicon: '/favicons/ozon.png' }, + ]; + const mine = sanitizeLists(null, [], legacy).lists[0]; + assert.equal(mine.members.length, DEFAULT_LIST_MEMBERS.length + 2); + // Defaults carry their committed favicon and any siteUrl override. + const cloudflare = mine.members.find((m) => m.id === 'cloudflare'); + assert.equal(cloudflare.favicon, '/favicons/cloudflare.png'); + assert.equal(cloudflare.siteUrl, 'https://www.cloudflare.com'); + // Legacy entries keep their favicon but lose the listId tag. + const ozon = mine.members.find((m) => m.id === 'import-ozon'); + assert.equal(ozon.favicon, '/favicons/ozon.png'); + assert.equal('listId' in ozon, false); + }); + + it('never drops migrated sites, even past the per-list cap', () => { + const flat = Array.from({ length: CONNECTIVITY_TARGET_LIMIT + 5 }, (_, i) => member(`m${i}`)); + const mine = sanitizeLists(null, flat, []).lists[0]; + assert.equal(mine.members.length, CONNECTIVITY_TARGET_LIMIT + 5); + }); + + it('passes a well-formed stored model through untouched', () => { + const stored = { + schemaVersion: 1, + lists: [ + { id: MINE_LIST_ID, name: null, members: [member('a')] }, + { id: 'list-1', name: 'Work', members: [] }, + ], + }; + const model = sanitizeLists(stored, [], []); + assert.deepEqual(model.lists.map((l) => l.id), [MINE_LIST_ID, 'list-1']); + assert.deepEqual(model.lists[0].members.map((m) => m.id), ['a']); + }); + + it('drops junk lists and junk members but keeps the valid rest', () => { + const stored = { + lists: [ + { id: MINE_LIST_ID, name: null, members: [member('a'), { id: 42 }, null] }, + { id: 'list-1', name: '', members: [] }, // empty name → junk + { id: 'list-2', name: 'Ok', members: 'nope' }, // members not array → junk + { id: 'list-3', name: 'Kept', members: [member('b')] }, + ], + }; + const model = sanitizeLists(stored, [], []); + assert.deepEqual(model.lists.map((l) => l.id), [MINE_LIST_ID, 'list-3']); + assert.deepEqual(model.lists[0].members.map((m) => m.id), ['a']); + }); + + it('re-seeds a missing or hand-emptied Mine with the defaults', () => { + const noMine = sanitizeLists({ lists: [{ id: 'list-1', name: 'Work', members: [] }] }, [], []); + assert.equal(noMine.lists[0].id, MINE_LIST_ID); + assert.equal(noMine.lists[0].members.length, DEFAULT_LIST_MEMBERS.length); + + const emptyMine = sanitizeLists({ lists: [{ id: MINE_LIST_ID, name: null, members: [] }] }, [], []); + assert.equal(emptyMine.lists[0].members.length, DEFAULT_LIST_MEMBERS.length); + }); + + it("nulls out a stored name on Mine (its display name is localized)", () => { + const model = sanitizeLists({ lists: [{ id: MINE_LIST_ID, name: 'Hacked', members: [member('a')] }] }, [], []); + assert.equal(model.lists[0].name, null); + }); + + it('treats non-object stored values (missing key, corruption) as absent', () => { + for (const stored of [null, undefined, 'junk', 42, { lists: 'nope' }]) { + const model = sanitizeLists(stored, [member('a')], []); + assert.deepEqual(model.lists[0].members.map((m) => m.id), ['a'], String(stored)); + } + }); +}); + +describe('siteUrlOf', () => { + it('prefers the explicit override', () => { + assert.equal(siteUrlOf({ url: 'https://res.wx.qq.com/x.ico', siteUrl: 'https://wx.qq.com' }), 'https://wx.qq.com'); + }); + + it('falls back to the test URL origin', () => { + assert.equal(siteUrlOf({ url: 'https://www.github.com/favicon.ico' }), 'https://www.github.com'); + }); + + it('returns null for an unparseable URL', () => { + assert.equal(siteUrlOf({ url: 'not-a-url' }), null); + }); +}); + +describe('createList', () => { + it('appends a trimmed, capped-length list with a fresh id', () => { + const { lists } = createList(mineWith([member('a')]), ' Work '); + assert.equal(lists.length, 2); + assert.equal(lists[1].name, 'Work'); + assert.match(lists[1].id, /^list-\d+$/); + assert.deepEqual(lists[1].members, []); + }); + + it('rejects an empty name', () => { + assert.equal(createList(mineWith([]), ' ').error, 'name-required'); + }); + + it('caps the name at the length limit', () => { + const { lists } = createList(mineWith([]), 'x'.repeat(CONNECTIVITY_LIST_NAME_LIMIT + 10)); + assert.equal(lists[1].name.length, CONNECTIVITY_LIST_NAME_LIMIT); + }); + + it('rejects creation past the list cap', () => { + const full = makeLists(...Array.from({ length: CONNECTIVITY_LIST_LIMIT }, (_, i) => ({ id: `l${i}`, name: `L${i}` }))); + assert.equal(createList(full, 'One more').error, 'list-limit'); + }); +}); + +describe('renameList', () => { + it('renames a user list', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null }, { id: 'l1', name: 'Old' }); + assert.equal(renameList(lists, 'l1', 'New').lists[1].name, 'New'); + }); + + it('refuses to rename Mine', () => { + assert.equal(renameList(mineWith([]), MINE_LIST_ID, 'Anything').error, 'immutable'); + }); + + it('requires a non-empty name', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null }, { id: 'l1', name: 'Old' }); + assert.equal(renameList(lists, 'l1', ' ').error, 'name-required'); + }); +}); + +describe('moveList', () => { + const lists = makeLists({ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }); + + it('swaps with the neighbor in the given direction', () => { + assert.deepEqual(moveList(lists, 'b', -1).lists.map((l) => l.id), ['b', 'a', 'c']); + assert.deepEqual(moveList(lists, 'b', 1).lists.map((l) => l.id), ['a', 'c', 'b']); + }); + + it('is a no-op at the edges', () => { + assert.deepEqual(moveList(lists, 'a', -1).lists.map((l) => l.id), ['a', 'b', 'c']); + assert.deepEqual(moveList(lists, 'c', 1).lists.map((l) => l.id), ['a', 'b', 'c']); + }); +}); + +describe('deleteList', () => { + it('deletes an empty user list', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null, members: [member('a')] }, { id: 'l1', name: 'Empty' }); + assert.deepEqual(deleteList(lists, 'l1').lists.map((l) => l.id), [MINE_LIST_ID]); + }); + + it('refuses to delete Mine', () => { + assert.equal(deleteList(mineWith([]), MINE_LIST_ID).error, 'immutable'); + }); + + it('refuses to delete a non-empty list', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null }, { id: 'l1', name: 'Full', members: [member('a')] }); + assert.equal(deleteList(lists, 'l1').error, 'not-empty'); + }); +}); + +describe('addMember', () => { + it('appends to the target list only', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null, members: [member('a')] }, { id: 'l1', name: 'Work' }); + const result = addMember(lists, 'l1', member('b')); + assert.equal(result.lists[0].members.length, 1); + assert.deepEqual(result.lists[1].members.map((m) => m.id), ['b']); + }); + + it('allows two endpoints on the same hostname (hand-adds do not dedupe)', () => { + const lists = mineWith([member('a', 'probe.example')]); + const result = addMember(lists, MINE_LIST_ID, member('b', 'probe.example')); + assert.equal(result.lists[0].members.length, 2); + }); + + it('rejects once the per-list cap is reached', () => { + const members = Array.from({ length: CONNECTIVITY_TARGET_LIMIT }, (_, i) => member(`m${i}`)); + assert.equal(addMember(mineWith(members), MINE_LIST_ID, member('extra')).error, 'list-full'); + }); +}); + +describe('removeMember', () => { + it('removes from the target list', () => { + const lists = mineWith([member('a'), member('b')]); + assert.deepEqual(removeMember(lists, MINE_LIST_ID, 'a').lists[0].members.map((m) => m.id), ['b']); + }); + + it("refuses to remove Mine's last member", () => { + assert.equal(removeMember(mineWith([member('a')]), MINE_LIST_ID, 'a').error, 'last-of-mine'); + }); + + it('lets any other list be emptied (deletion requires it)', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null, members: [member('a')] }, { id: 'l1', name: 'Work', members: [member('b')] }); + assert.deepEqual(removeMember(lists, 'l1', 'b').lists[1].members, []); + }); +}); + +describe('planImport', () => { + const curated = { + id: 'sample', + members: [ + { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/favicon.ico' }, + { id: 'beta', name: 'Beta', url: 'https://www.beta.example/favicon.ico', siteUrl: 'https://beta.example' }, + { id: 'gamma', name: 'Gamma', url: 'https://gamma.example/favicon.ico' }, + ], + }; + + it('materializes members with import ids, favicons and siteUrl overrides', () => { + const { additions, skippedCount, overflowCount } = planImport(curated, { members: [] }); + assert.equal(additions.length, 3); + assert.equal(skippedCount, 0); + assert.equal(overflowCount, 0); + assert.deepEqual(additions[0], { + id: 'import-alpha', + name: 'Alpha', + url: 'https://alpha.example/favicon.ico', + favicon: '/favicons/alpha.png', + }); + assert.equal(additions[1].siteUrl, 'https://beta.example'); + }); + + it('dedupes by hostname within the target list only', () => { + const target = { members: [member('x', 'www.beta.example')] }; + const { additions, skippedCount } = planImport(curated, target); + assert.deepEqual(additions.map((a) => a.id), ['import-alpha', 'import-gamma']); + assert.equal(skippedCount, 1); + }); + + it('refuses a partial import when the list does not fully fit (all-or-nothing)', () => { + const target = { members: Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 1 }, (_, i) => member(`m${i}`)) }; + const { additions, overflowCount, freshCount, capacity } = planImport(curated, target); + assert.equal(additions.length, 0); + assert.equal(overflowCount, 2); + assert.equal(freshCount, 3); + assert.equal(capacity, 1); + }); + + it('imports in full when the list exactly fits the remaining capacity', () => { + const target = { members: Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 3 }, (_, i) => member(`m${i}`)) }; + const { additions, overflowCount } = planImport(curated, target); + assert.equal(additions.length, 3); + assert.equal(overflowCount, 0); + }); + + it('an over-cap list (legacy migration) can only shed, never gain', () => { + const target = { members: Array.from({ length: CONNECTIVITY_TARGET_LIMIT + 5 }, (_, i) => member(`m${i}`)) }; + const { additions, overflowCount, capacity } = planImport(curated, target); + assert.equal(additions.length, 0); + assert.equal(capacity, 0); + assert.equal(overflowCount, 3); + }); +}); + +describe('isListFullyPresent', () => { + const curated = { + members: [ + { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/favicon.ico' }, + { id: 'beta', name: 'Beta', url: 'https://beta.example/favicon.ico' }, + ], + }; + + it('is true when every member hostname exists in the target', () => { + const target = { members: [member('x', 'alpha.example'), member('y', 'beta.example'), member('z')] }; + assert.equal(isListFullyPresent(curated, target), true); + }); + + it('is false while any member is missing', () => { + const target = { members: [member('x', 'alpha.example')] }; + assert.equal(isListFullyPresent(curated, target), false); + }); +}); + +describe('importIntoList / importAsNewList', () => { + const curated = { + id: 'sample', + members: [ + { id: 'alpha', name: 'Alpha', url: 'https://alpha.example/favicon.ico' }, + { id: 'beta', name: 'Beta', url: 'https://beta.example/favicon.ico' }, + ], + }; + + it('imports into an existing list, other lists untouched', () => { + const lists = makeLists({ id: MINE_LIST_ID, name: null, members: [member('a')] }, { id: 'l1', name: 'Work' }); + const result = importIntoList(lists, 'l1', curated); + assert.equal(result.lists[0].members.length, 1); + assert.deepEqual(result.lists[1].members.map((m) => m.id), ['import-alpha', 'import-beta']); + }); + + it('surfaces a capacity error with the plan attached, storing nothing', () => { + const members = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 1 }, (_, i) => member(`m${i}`)); + const result = importIntoList(mineWith(members), MINE_LIST_ID, curated); + assert.equal(result.error, 'capacity'); + assert.equal(result.plan.freshCount, 2); + assert.equal('lists' in result, false); + }); + + it('creates a new list named after the curated one and fills it', () => { + const result = importAsNewList(mineWith([member('a')]), curated, 'Sample Pack'); + assert.equal(result.lists.length, 2); + const created = result.lists[1]; + assert.equal(created.id, result.listId); + assert.equal(created.name, 'Sample Pack'); + assert.deepEqual(created.members.map((m) => m.id), ['import-alpha', 'import-beta']); + }); + + it('propagates the list-cap error from creation', () => { + const full = makeLists(...Array.from({ length: CONNECTIVITY_LIST_LIMIT }, (_, i) => ({ id: `l${i}`, name: `L${i}` }))); + assert.equal(importAsNewList(full, curated, 'One more').error, 'list-limit'); + }); +}); diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js index 5f80dd17c..d21d63842 100644 --- a/tests/default-preferences.test.js +++ b/tests/default-preferences.test.js @@ -25,6 +25,9 @@ describe('DEFAULT_PREFERENCES', () => { ipHistoryEnabled: true, ipHistoryDays: 90, lang: 'auto', + connectivityLists: null, + connectivityDefaultListId: 'mine', + connectivityCardTitleOpensSite: false, connectivityTargets: null, customConnectivityTargets: [], }); From da1b81ec493c366092d15c2b025fa8be558f0987 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 27 Aug 2026 10:32:09 +0800 Subject: [PATCH 12/24] Improvements --- frontend/components/widgets/ConnectivityAddDialog.vue | 5 ++++- frontend/data/connectivity-import-lists.js | 2 +- frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/locales/pt-BR.json | 1 + frontend/locales/ru.json | 1 + frontend/locales/zh-TW.json | 1 + frontend/locales/zh.json | 1 + 8 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/components/widgets/ConnectivityAddDialog.vue b/frontend/components/widgets/ConnectivityAddDialog.vue index 2f4d6a9fd..21e7f7d9e 100644 --- a/frontend/components/widgets/ConnectivityAddDialog.vue +++ b/frontend/components/widgets/ConnectivityAddDialog.vue @@ -97,7 +97,10 @@ -

{{ t('connectivity.addCustom.Hint') }}

+
+

{{ t('connectivity.addCustom.Hint') }}

+

{{ t('connectivity.addCustom.ProbeBlockedNote') }}

+

{{ addError }}

diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index 3416e4368..32e9b6fc2 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -1,6 +1,6 @@ // Curated importable target lists for the Connectivity section: country sets // (domestic reachability) and theme sets (global services). Importing -// materializes members into the user's connectivityTargets preference, so +// materializes members into a list in the connectivityLists preference, so // imported cards behave exactly like hand-added ones. Display names live in // the locale packs under `connectivity.importLists.`; member names are // brand names and stay untranslated. Every member ships a committed 64px diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 6ee60d216..22257a7ca 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1223,6 +1223,7 @@ "AddCard": "Add Test", "Title": "Add a custom test", "Hint": "We test by loading the site's favicon over HTTPS. Some sites may not be reachable this way. Custom tests are saved locally in your browser.", + "ProbeBlockedNote": "Some sites' security policies block cross-origin probes — they can show as unreachable even though they open fine.", "NameLabel": "Name", "NamePlaceholder": "e.g. Wikipedia", "UrlLabel": "URL", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index a7eea7b1d..f679bf675 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1223,6 +1223,7 @@ "AddCard": "Ajouter un test", "Title": "Ajouter un test personnalisé", "Hint": "Le test est effectué en chargeant le favicon du site en HTTPS. Certains sites peuvent ne pas être testables de cette manière. Les tests personnalisés sont enregistrés localement dans votre navigateur.", + "ProbeBlockedNote": "La politique de sécurité de certains sites bloque les sondes cross-origin — ils peuvent apparaître comme inaccessibles alors qu'ils s'ouvrent normalement.", "NameLabel": "Nom", "NamePlaceholder": "ex. Wikipedia", "UrlLabel": "URL", diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index 059e6396f..98488b974 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -1223,6 +1223,7 @@ "AddCard": "Adicionar teste", "Title": "Adicionar um teste personalizado", "Hint": "Testamos carregando o favicon do site via HTTPS. Alguns sites podem não ser acessíveis dessa forma. Testes personalizados são salvos localmente no seu navegador.", + "ProbeBlockedNote": "As políticas de segurança de alguns sites bloqueiam sondagens cross-origin — eles podem aparecer como inacessíveis mesmo abrindo normalmente.", "NameLabel": "Nome", "NamePlaceholder": "ex.: Wikipédia", "UrlLabel": "URL", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 3aef5cb28..0fdb0b1b3 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -1223,6 +1223,7 @@ "AddCard": "Добавить тест", "Title": "Добавить свой тест", "Hint": "Проверка выполняется загрузкой значка сайта по HTTPS. Некоторые сайты могут быть недоступны таким способом. Пользовательские тесты сохраняются локально в браузере.", + "ProbeBlockedNote": "Политики безопасности некоторых сайтов блокируют cross-origin-запросы — такие сайты могут отображаться как недоступные, хотя открываются нормально.", "NameLabel": "Название", "NamePlaceholder": "например, Википедия", "UrlLabel": "URL", diff --git a/frontend/locales/zh-TW.json b/frontend/locales/zh-TW.json index 768278746..9f39bc678 100644 --- a/frontend/locales/zh-TW.json +++ b/frontend/locales/zh-TW.json @@ -1223,6 +1223,7 @@ "AddCard": "新增測試", "Title": "新增自訂測試", "Hint": "透過 HTTPS 載入站點 favicon 進行測試,部分站點可能無法以這種方式測試。自訂測試會儲存在瀏覽器本地。", + "ProbeBlockedNote": "部分網站的安全性原則會攔截跨來源探測——即使網站能正常開啟,也可能顯示為無法連線。", "NameLabel": "名稱", "NamePlaceholder": "例如:維基百科", "UrlLabel": "URL", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 5efb94bdc..1e2eb42d9 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -1223,6 +1223,7 @@ "AddCard": "添加测试", "Title": "添加自定义测试", "Hint": "通过 HTTPS 加载站点 favicon 进行测试,部分站点可能无法以这种方式测试。自定义测试会保存在浏览器本地。", + "ProbeBlockedNote": "部分站点的安全策略会拦截跨域探测——即使网站能正常打开,也可能显示为不可用。", "NameLabel": "名称", "NamePlaceholder": "例如:维基百科", "UrlLabel": "URL", From 21311cc787f723c78adc56b9e6a6241d82ec7b93 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Thu, 27 Aug 2026 10:45:55 +0800 Subject: [PATCH 13/24] Fix(connectivity): replace Genius with Musixmatch in the music list genius.com serves its favicon with Cross-Origin-Resource-Policy: same-origin, so the browser blocks our cross-origin probe and the card always reads as unreachable even though the site is fine. Musixmatch covers the same lyrics ground and probes cleanly. Co-Authored-By: Claude Fable 5 --- frontend/data/connectivity-import-lists.js | 2 +- public/favicons/genius.png | Bin 697 -> 0 bytes public/favicons/musixmatch.png | Bin 0 -> 1157 bytes 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 public/favicons/genius.png create mode 100644 public/favicons/musixmatch.png diff --git a/frontend/data/connectivity-import-lists.js b/frontend/data/connectivity-import-lists.js index f74ff4f10..9f1065f72 100644 --- a/frontend/data/connectivity-import-lists.js +++ b/frontend/data/connectivity-import-lists.js @@ -262,7 +262,7 @@ export const IMPORT_LISTS = [ { id: 'pandora', name: 'Pandora', url: 'https://www.pandora.com/favicon.ico' }, { id: 'lastfm', name: 'Last.fm', url: 'https://www.last.fm/favicon.ico' }, { id: 'audiomack', name: 'Audiomack', url: 'https://audiomack.com/favicon.ico' }, - { id: 'genius', name: 'Genius', url: 'https://genius.com/favicon.ico' }, + { id: 'musixmatch', name: 'Musixmatch', url: 'https://www.musixmatch.com/favicon.ico' }, { id: 'apple-music', name: 'Apple Music', url: 'https://music.apple.com/favicon.ico' }, { id: 'mixcloud', name: 'Mixcloud', url: 'https://www.mixcloud.com/favicon.ico' }, { id: 'qobuz', name: 'Qobuz', url: 'https://www.qobuz.com/favicon.ico' }, diff --git a/public/favicons/genius.png b/public/favicons/genius.png deleted file mode 100644 index 2554bdefc2313a436e357822e9fd03adf188e88e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 697 zcmV;q0!ICbP)03cT z8T9mEtgJ)5y-K&YM|*oPWo0KVEfGCE7?zeh9v%xNB@Q_`7H)1U2?+$Hr9oX?BuGdc zS63qYC25HO00IR`L_t(oh3!_|dV(+vC5Z@#!my&KbDNv*|BNd?C2eVo?rz`5xeM(% zJx$usC|a;!feT0yXvTUt%ipXd@^K#Cuh(lzW4N7Bz#Vj8Mi&-}M!OSvsQq{jwm{j2 z?<&R&4gm#>!7Tu_vLGD=c0p^Oz*>Ev1}zv`kuT}-TW4MKj=@p=RVMn9MkSAQkB*f>XQ>YtEPq6aL|51gC5}# zLJlI5h5rfl9D3Mgu_v5-8D@*dZJjE-asP6RHp5{JbS27tcTJVaxbCi5egk+O1Og^Q z;D(d@jbAH%xV3xvDgoYOG{fhP`~4RK+WMg%gAYhY<_Bx&Sza{E|Csx|+T%!=g9dji z;OSW`-veV)lCOhm^`-N(IRgb;Hiby~cZSIFJs2ov6F}Sc|5RSqkH`897RvaE1Y*+I zJcUQ{Q~{Cd(#fys*#m%hn7F^)1J`s4!tA7J+;&Cu>hY1~uhP4Gvu*OUik>!aYG7Z? fm%J}n@L%x@3Z@WGx=s*Q00000NkvXXu0mjfIG{Z5 diff --git a/public/favicons/musixmatch.png b/public/favicons/musixmatch.png new file mode 100644 index 0000000000000000000000000000000000000000..1745dbca211788edc1f3a7e7d306b8e9de90b600 GIT binary patch literal 1157 zcmV;01bX|4P)~w&p%0h;8`IwBFwtPdv)WKmxsleioRVkI4)Jq^pWQmz*jIt$2q!J|z zMF|p-kZEbCh}f{ZLL`i;RP}@zNTpCtf22_~- za9Gn=x5o5+^srC}g>GK(ePiv*+M*YOBcH;XztPo|mI%__1BX6o*dtIfZKkgp>S$G1 z6MlzvIdp=_VNCL3O_|N!L9;V^!#cZ=kqHL{*eGMiQ)sD(f){M*EYJsaIkm=Kspe2R zyD-U%=<{c8WBxHFIZWjCQ>3%28Q9y0naUzw44s|CjwNBEj2W4zlhXLsc|YC&MHsC8 zKH?PX^BlUoguV3xc6JgzZg=%Ovom5?%CwP$~lDUhyJb`}Ri+C~cVrcGn?A#=5l=o8sXk5SXJ?Ck}oCy<= zqsJOQtgaA@CDG+2=dBk*7w=<#y4C=c!|j;QdPA{W+cse;Zijj){R5cvbVDAc@3cEC z6^QhnM7nnX(%plZ$~J_xei)VGQ2fDL8ZC-by4c#^0HQn9T_Ps>_BMZ$9xwiQAx0sO$^m&fpyRXpf z4AMV9WGd^LL8U%9)Q%lbVaHQ$-&7Xyj)m$JWIbB3tQ1-@)nZ?q2V7T@1m^Zr zCsWBUm6jJ|QbaO$2@5TNMc){J0R9LSxOJ@ttc|r5c(+p?y1Y~a0xqYQPPy+PLxsEn zFsk`biM4+krnPW-F*G}aF5Y)#btBu<9(C<)sLHmeS%?BeY-Ayb*qiM0I*_3>rhfod zSFn?#HBZb&8A)D5GIz1FQ|OCljT`H`lt^A46iTi+EELvN_;JU|%9YeVfb^auawF@2 zr6NHl)et(Hy+d&6lr!M!P0Zy9B!0$qq}7#1sFizAce*v Date: Thu, 27 Aug 2026 10:59:18 +0800 Subject: [PATCH 14/24] Improvements --- frontend/components/widgets/ConnectivityAddDialog.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/components/widgets/ConnectivityAddDialog.vue b/frontend/components/widgets/ConnectivityAddDialog.vue index 21e7f7d9e..d20b5559c 100644 --- a/frontend/components/widgets/ConnectivityAddDialog.vue +++ b/frontend/components/widgets/ConnectivityAddDialog.vue @@ -127,9 +127,8 @@
  • -