feat: port network topology visualization to network page

This commit is contained in:
Memory 2026-04-03 17:25:46 +08:00 committed by GitHub
parent e4110f65c2
commit 0f1cd352db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1225 additions and 10 deletions

View File

@ -34,12 +34,14 @@
},
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@types/d3": "^7.4.3",
"@types/plist": "^3.0.5",
"adm-zip": "^0.5.16",
"axios": "^1.14.0",
"chokidar": "^5.0.0",
"croner": "^9.1.0",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"express": "^5.2.1",
"file-icon": "^6.0.0",
"file-icon-info": "^1.1.1",

529
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@37.10.0)
'@types/d3':
specifier: ^7.4.3
version: 7.4.3
'@types/plist':
specifier: ^3.0.5
version: 3.0.5
@ -29,6 +32,9 @@ importers:
crypto-js:
specifier: ^4.2.0
version: 4.2.0
d3:
specifier: ^7.9.0
version: 7.9.0
express:
specifier: ^5.2.1
version: 5.2.1
@ -2173,6 +2179,99 @@ packages:
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-axis@3.0.6':
resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
'@types/d3-brush@3.0.6':
resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
'@types/d3-chord@3.0.6':
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-contour@3.0.6':
resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
'@types/d3-delaunay@6.0.4':
resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
'@types/d3-dispatch@3.0.7':
resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-dsv@3.0.7':
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-fetch@3.0.7':
resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/d3-format@3.0.4':
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
'@types/d3-geo@3.1.0':
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
'@types/d3-hierarchy@3.1.7':
resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-polygon@3.0.2':
resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
'@types/d3-quadtree@3.0.6':
resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
'@types/d3-random@3.0.3':
resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
'@types/d3-scale-chromatic@3.1.0':
resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time-format@4.0.3':
resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@ -2191,6 +2290,9 @@ packages:
'@types/fs-extra@9.0.13':
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@ -2691,6 +2793,10 @@ packages:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'}
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
@ -2760,6 +2866,133 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-axis@3.0.0:
resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
engines: {node: '>=12'}
d3-brush@3.0.0:
resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
engines: {node: '>=12'}
d3-chord@3.0.1:
resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-contour@4.0.2:
resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
engines: {node: '>=12'}
d3-delaunay@6.0.4:
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-dsv@3.0.1:
resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
engines: {node: '>=12'}
hasBin: true
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-fetch@3.0.1:
resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-hierarchy@3.1.2:
resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-polygon@3.0.1:
resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
engines: {node: '>=12'}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-random@3.0.1:
resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
engines: {node: '>=12'}
d3-scale-chromatic@3.1.0:
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
d3@7.9.0:
resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
engines: {node: '>=12'}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@ -2828,6 +3061,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -3536,6 +3772,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
@ -4598,6 +4838,9 @@ packages:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
rollup@4.60.0:
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -4607,6 +4850,9 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
safe-array-concat@1.1.3:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
@ -7888,6 +8134,123 @@ snapshots:
'@types/crypto-js@4.2.2': {}
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-brush@3.0.6':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-chord@3.0.6': {}
'@types/d3-color@3.1.3': {}
'@types/d3-contour@3.0.6':
dependencies:
'@types/d3-array': 3.2.2
'@types/geojson': 7946.0.16
'@types/d3-delaunay@6.0.4': {}
'@types/d3-dispatch@3.0.7': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-dsv@3.0.7': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-fetch@3.0.7':
dependencies:
'@types/d3-dsv': 3.0.7
'@types/d3-force@3.0.10': {}
'@types/d3-format@3.0.4': {}
'@types/d3-geo@3.1.0':
dependencies:
'@types/geojson': 7946.0.16
'@types/d3-hierarchy@3.1.7': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-polygon@3.0.2': {}
'@types/d3-quadtree@3.0.6': {}
'@types/d3-random@3.0.3': {}
'@types/d3-scale-chromatic@3.1.0': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.11': {}
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time-format@4.0.3': {}
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3@7.4.3':
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-axis': 3.0.6
'@types/d3-brush': 3.0.6
'@types/d3-chord': 3.0.6
'@types/d3-color': 3.1.3
'@types/d3-contour': 3.0.6
'@types/d3-delaunay': 6.0.4
'@types/d3-dispatch': 3.0.7
'@types/d3-drag': 3.0.7
'@types/d3-dsv': 3.0.7
'@types/d3-ease': 3.0.2
'@types/d3-fetch': 3.0.7
'@types/d3-force': 3.0.10
'@types/d3-format': 3.0.4
'@types/d3-geo': 3.1.0
'@types/d3-hierarchy': 3.1.7
'@types/d3-interpolate': 3.0.4
'@types/d3-path': 3.1.1
'@types/d3-polygon': 3.0.2
'@types/d3-quadtree': 3.0.6
'@types/d3-random': 3.0.3
'@types/d3-scale': 4.0.9
'@types/d3-scale-chromatic': 3.1.0
'@types/d3-selection': 3.0.11
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-time-format': 4.0.3
'@types/d3-timer': 3.0.2
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@ -7915,6 +8278,8 @@ snapshots:
dependencies:
'@types/node': 25.5.0
'@types/geojson@7946.0.16': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
@ -8540,6 +8905,8 @@ snapshots:
commander@5.1.0: {}
commander@7.2.0: {}
commander@9.5.0:
optional: true
@ -8593,6 +8960,158 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-axis@3.0.0: {}
d3-brush@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-chord@3.0.1:
dependencies:
d3-path: 3.1.0
d3-color@3.1.0: {}
d3-contour@4.0.2:
dependencies:
d3-array: 3.2.4
d3-delaunay@6.0.4:
dependencies:
delaunator: 5.1.0
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-dsv@3.0.1:
dependencies:
commander: 7.2.0
iconv-lite: 0.6.3
rw: 1.3.3
d3-ease@3.0.1: {}
d3-fetch@3.0.1:
dependencies:
d3-dsv: 3.0.1
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-format@3.1.2: {}
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-hierarchy@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-polygon@3.0.1: {}
d3-quadtree@3.0.1: {}
d3-random@3.0.1: {}
d3-scale-chromatic@3.1.0:
dependencies:
d3-color: 3.1.0
d3-interpolate: 3.0.1
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
d3@7.9.0:
dependencies:
d3-array: 3.2.4
d3-axis: 3.0.0
d3-brush: 3.0.0
d3-chord: 3.0.1
d3-color: 3.1.0
d3-contour: 4.0.2
d3-delaunay: 6.0.4
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-dsv: 3.0.1
d3-ease: 3.0.1
d3-fetch: 3.0.1
d3-force: 3.0.0
d3-format: 3.1.2
d3-geo: 3.1.1
d3-hierarchy: 3.1.2
d3-interpolate: 3.0.1
d3-path: 3.1.0
d3-polygon: 3.0.1
d3-quadtree: 3.0.1
d3-random: 3.0.1
d3-scale: 4.0.2
d3-scale-chromatic: 3.1.0
d3-selection: 3.0.0
d3-shape: 3.2.0
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-timer: 3.0.1
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-zoom: 3.0.0
data-uri-to-buffer@4.0.1: {}
data-view-buffer@1.0.2:
@ -8655,6 +9174,10 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
delaunator@5.1.0:
dependencies:
robust-predicates: 3.0.3
delayed-stream@1.0.0: {}
depd@2.0.0: {}
@ -9654,6 +10177,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
internmap@2.0.3: {}
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
@ -10833,6 +11358,8 @@ snapshots:
sprintf-js: 1.1.3
optional: true
robust-predicates@3.0.3: {}
rollup@4.60.0:
dependencies:
'@types/estree': 1.0.8
@ -10874,6 +11401,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
rw@1.3.3: {}
safe-array-concat@1.1.3:
dependencies:
call-bind: 1.0.8

View File

@ -0,0 +1,620 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'
import * as d3 from 'd3'
import { Button } from '@heroui/react'
import { IoPauseOutline, IoPlayOutline, IoGitNetworkOutline } from 'react-icons/io5'
import {
IoDesktopOutline,
IoServerOutline,
IoFunnelOutline
} from 'react-icons/io5'
import { MdTag } from 'react-icons/md'
import { useTranslation } from 'react-i18next'
import { useTheme } from 'next-themes'
import { calcTraffic } from '@renderer/utils/calc'
// ─── Types ───────────────────────────────────────────────────────────────────
type NodeType = 'root' | 'client' | 'port' | 'rule' | 'group' | 'proxy'
interface TopologyNodeData {
id: string
name: string
type: NodeType
connections: number
traffic: number
children?: TopologyNodeData[]
_children?: TopologyNodeData[]
collapsed?: boolean
}
// ─── Color helpers ────────────────────────────────────────────────────────────
function getNodeColors() {
return {
root: {
fill: 'hsl(var(--heroui-default-500))',
bg: 'hsl(var(--heroui-default-500) / 0.15)'
},
client: {
fill: 'hsl(var(--heroui-primary))',
bg: 'hsl(var(--heroui-primary) / 0.15)'
},
port: {
fill: 'hsl(var(--heroui-warning))',
bg: 'hsl(var(--heroui-warning) / 0.15)'
},
rule: {
fill: 'hsl(var(--heroui-secondary))',
bg: 'hsl(var(--heroui-secondary) / 0.15)'
},
group: {
fill: 'hsl(var(--heroui-success))',
bg: 'hsl(var(--heroui-success) / 0.15)'
},
proxy: {
fill: 'hsl(var(--heroui-danger))',
bg: 'hsl(var(--heroui-danger) / 0.15)'
},
baseContent: 'hsl(var(--heroui-foreground))'
}
}
// ─── Hierarchy builder ────────────────────────────────────────────────────────
function buildHierarchy(
connections: IMihomoConnectionDetail[],
collapsedNodes: Set<string>
): TopologyNodeData {
const groupsMap = new Map<
string,
{
data: TopologyNodeData
proxies: Map<
string,
{
data: TopologyNodeData
rules: Map<
string,
{
data: TopologyNodeData
clients: Map<string, { data: TopologyNodeData; ports: Map<string, TopologyNodeData> }>
}
>
}
>
}
>()
for (const conn of connections) {
const clientIP = conn.metadata.sourceIP || 'Unknown'
const sourcePort = String(conn.metadata.sourcePort || 'Unknown')
const ruleType = conn.rule || 'Direct'
const fullRule = conn.rulePayload ? `${ruleType}: ${conn.rulePayload}` : ruleType
const chains = conn.chains || []
const proxy = chains[0] ?? 'Direct'
const group = chains.length > 1 ? (chains[1] ?? 'Direct') : (chains[0] ?? 'Direct')
const traffic = conn.download + conn.upload
if (!groupsMap.has(group)) {
groupsMap.set(group, {
data: { id: `group-${group}`, name: group, type: 'group', connections: 0, traffic: 0 },
proxies: new Map()
})
}
const groupEntry = groupsMap.get(group)!
groupEntry.data.connections++
groupEntry.data.traffic += traffic
if (!groupEntry.proxies.has(proxy)) {
groupEntry.proxies.set(proxy, {
data: {
id: `proxy-${group}-${proxy}`,
name: proxy,
type: 'proxy',
connections: 0,
traffic: 0
},
rules: new Map()
})
}
const proxyEntry = groupEntry.proxies.get(proxy)!
proxyEntry.data.connections++
proxyEntry.data.traffic += traffic
if (!proxyEntry.rules.has(fullRule)) {
proxyEntry.rules.set(fullRule, {
data: {
id: `rule-${group}-${proxy}-${fullRule}`,
name: fullRule,
type: 'rule',
connections: 0,
traffic: 0
},
clients: new Map()
})
}
const ruleEntry = proxyEntry.rules.get(fullRule)!
ruleEntry.data.connections++
ruleEntry.data.traffic += traffic
if (!ruleEntry.clients.has(clientIP)) {
ruleEntry.clients.set(clientIP, {
data: {
id: `client-${group}-${proxy}-${fullRule}-${clientIP}`,
name: clientIP,
type: 'client',
connections: 0,
traffic: 0
},
ports: new Map()
})
}
const clientEntry = ruleEntry.clients.get(clientIP)!
clientEntry.data.connections++
clientEntry.data.traffic += traffic
if (!clientEntry.ports.has(sourcePort)) {
clientEntry.ports.set(sourcePort, {
id: `port-${group}-${proxy}-${fullRule}-${clientIP}-${sourcePort}`,
name: sourcePort,
type: 'port',
connections: 0,
traffic: 0
})
}
const portNode = clientEntry.ports.get(sourcePort)!
portNode.connections++
portNode.traffic += traffic
}
// Convert to tree with collapse state
const rootChildren: TopologyNodeData[] = []
function applyCollapse(node: TopologyNodeData, defaultCollapsed = false): TopologyNodeData {
const isCollapsed = collapsedNodes.has(node.id) || defaultCollapsed
if (isCollapsed && node.children && node.children.length > 0) {
return { ...node, _children: node.children, children: undefined, collapsed: true }
}
return { ...node, collapsed: false }
}
groupsMap.forEach((groupEntry) => {
const groupNode: TopologyNodeData = { ...groupEntry.data, children: [] }
groupEntry.proxies.forEach((proxyEntry) => {
const proxyNode: TopologyNodeData = { ...proxyEntry.data, children: [] }
proxyEntry.rules.forEach((ruleEntry) => {
const ruleNode: TopologyNodeData = { ...ruleEntry.data, children: [] }
ruleEntry.clients.forEach((clientEntry) => {
const portChildren = Array.from(clientEntry.ports.values())
const isClientCollapsed = !collapsedNodes.has(`expanded-${clientEntry.data.id}`)
const clientNode: TopologyNodeData = isClientCollapsed
? { ...clientEntry.data, _children: portChildren, children: undefined, collapsed: true }
: { ...clientEntry.data, children: portChildren, collapsed: false }
ruleNode.children!.push(clientNode)
})
const isRuleCollapsed = !collapsedNodes.has(`expanded-${ruleEntry.data.id}`)
if (isRuleCollapsed && ruleNode.children!.length > 0) {
ruleNode._children = ruleNode.children
ruleNode.children = undefined
ruleNode.collapsed = true
} else {
ruleNode.collapsed = false
}
proxyNode.children!.push(ruleNode)
})
groupNode.children!.push(applyCollapse(proxyNode))
})
rootChildren.push(applyCollapse(groupNode))
})
return {
id: 'root',
name: 'Connections',
type: 'root',
connections: connections.length,
traffic: connections.reduce((s, c) => s + c.download + c.upload, 0),
children: rootChildren
}
}
// ─── Text measurement ─────────────────────────────────────────────────────────
let measureCanvas: HTMLCanvasElement | null = null
function getTextWidth(text: string, font = '600 11px sans-serif'): number {
if (!measureCanvas) measureCanvas = document.createElement('canvas')
const ctx = measureCanvas.getContext('2d')
if (!ctx) return text.length * 7
ctx.font = font
return ctx.measureText(text).width
}
// ─── Main component ───────────────────────────────────────────────────────────
const NetworkTopologyCard: React.FC = () => {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [connections, setConnections] = useState<IMihomoConnectionDetail[]>([])
const [isPaused, setIsPaused] = useState(false)
const frozenRef = useRef<IMihomoConnectionDetail[] | null>(null)
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set())
// IPC listener
useEffect(() => {
if (isPaused) return
const handler = (_e: unknown, ...args: unknown[]): void => {
const info = args[0] as IMihomoConnectionsInfo
setConnections(info.connections ?? [])
}
window.electron.ipcRenderer.on('mihomoConnections', handler)
return () => {
window.electron.ipcRenderer.removeAllListeners('mihomoConnections')
}
}, [isPaused])
const currentConnections = isPaused && frozenRef.current ? frozenRef.current : connections
// Stats
const stats = useMemo(() => {
const clients = new Set<string>()
const rules = new Set<string>()
const groups = new Set<string>()
const proxies = new Set<string>()
for (const c of currentConnections) {
clients.add(c.metadata.sourceIP || 'Unknown')
rules.add(c.rule || 'Direct')
const ch = c.chains || []
proxies.add(ch[0] ?? 'Direct')
groups.add(ch.length > 1 ? (ch[1] ?? 'Direct') : (ch[0] ?? 'Direct'))
}
return {
clientCount: clients.size,
ruleCount: rules.size,
groupCount: groups.size,
proxyCount: proxies.size,
totalTraffic: currentConnections.reduce((s, c) => s + c.download + c.upload, 0)
}
}, [currentConnections])
// Hierarchy data
const hierarchyData = useMemo(
() => buildHierarchy(currentConnections, collapsedNodes),
[currentConnections, collapsedNodes]
)
// Toggle collapse
const toggleCollapseRef = useRef<(nodeId: string, isCollapsed: boolean) => void>(() => {})
toggleCollapseRef.current = useCallback(
(nodeId: string, isCurrentlyCollapsed: boolean) => {
const expandedKey = `expanded-${nodeId}`
setCollapsedNodes((prev) => {
const next = new Set(prev)
if (isCurrentlyCollapsed) {
if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) {
next.add(expandedKey)
} else {
next.delete(nodeId)
}
} else {
if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) {
next.delete(expandedKey)
} else {
next.add(nodeId)
}
}
return next
})
},
[]
)
// D3 render
useEffect(() => {
const svgEl = svgRef.current
const containerEl = containerRef.current
if (!svgEl || !containerEl) return
if (!hierarchyData.children || hierarchyData.children.length === 0) {
d3.select(svgEl).selectAll('*').remove()
return
}
d3.select(svgEl).selectAll('*').remove()
const nodeHeight = 30
const nodePaddingX = 16
const nodeSpacingY = 50
const levelGap = 40
const topPadding = 25
const root = d3.hierarchy(hierarchyData)
const containerWidth = containerEl.clientWidth
const treeLayout = d3
.tree<TopologyNodeData>()
.nodeSize([nodeSpacingY, 100])
.separation(() => 1)
treeLayout(root)
// Calculate node widths
const nodeWidths = new Map<string, number>()
for (const d of root.descendants()) {
if (d.data.type !== 'root') {
const textWidth = getTextWidth(d.data.name)
const hasChildren =
(d.data.children && d.data.children.length > 0) ||
(d.data._children && d.data._children.length > 0)
nodeWidths.set(d.data.id, textWidth + nodePaddingX * 2 + (hasChildren ? 20 : 0))
}
}
const getNodeWidth = (d: d3.HierarchyNode<TopologyNodeData>) =>
nodeWidths.get(d.data.id) ?? 80
// Max width per depth
const maxWidthPerLevel = new Map<number, number>()
root.descendants().forEach((d) => {
if (d.data.type !== 'root') {
const w = getNodeWidth(d)
maxWidthPerLevel.set(d.depth, Math.max(maxWidthPerLevel.get(d.depth) ?? 0, w))
}
})
// Cumulative x offsets
const levelXOffset = new Map<number, number>()
let cumX = 0
for (let depth = 1; depth <= maxWidthPerLevel.size; depth++) {
const curW = maxWidthPerLevel.get(depth) ?? 100
if (depth === 1) {
cumX = curW / 2
} else {
const prevW = maxWidthPerLevel.get(depth - 1) ?? 100
cumX += prevW / 2 + levelGap + curW / 2
}
levelXOffset.set(depth, cumX)
}
root.descendants().forEach((d) => {
if (d.data.type !== 'root' && d.depth > 0) {
d.y = levelXOffset.get(d.depth) ?? d.y
}
})
// Bounds
let minX = Infinity
let maxX = -Infinity
root.each((d) => {
if ((d.x ?? 0) < minX) minX = d.x ?? 0
if ((d.x ?? 0) > maxX) maxX = d.x ?? 0
})
const treeHeight = maxX - minX + nodeSpacingY
const actualHeight = Math.max(400, treeHeight + topPadding + 40)
let maxY = 0
root.each((d) => {
if (d.data.type !== 'root') {
const rightEdge = (d.y ?? 0) + getNodeWidth(d) / 2
if (rightEdge > maxY) maxY = rightEdge
}
})
const actualWidth = Math.max(containerWidth, maxY + 60 + 40)
const svg = d3.select(svgEl).attr('width', actualWidth).attr('height', actualHeight)
const g = svg
.append('g')
.attr('transform', `translate(60, ${-minX + nodeSpacingY / 2 + topPadding})`)
const colors = getNodeColors()
// Links
const visibleLinks = root.links().filter((l) => l.source.data.type !== 'root')
g.selectAll('.link')
.data(visibleLinks)
.join('path')
.attr('class', 'link')
.attr('d', (d) => {
const src = d.source as d3.HierarchyPointNode<TopologyNodeData>
const tgt = d.target as d3.HierarchyPointNode<TopologyNodeData>
const sx = src.y + getNodeWidth(src) / 2
const sy = src.x
const tx = tgt.y - getNodeWidth(tgt) / 2
const ty = tgt.x
const mx = (sx + tx) / 2
return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}`
})
.attr('fill', 'none')
.style('stroke', colors.baseContent)
.attr('stroke-opacity', 0.3)
.attr('stroke-width', (d) => Math.max(1, Math.min(4, d.target.data.connections / 5)))
// Nodes
const nodes = g
.selectAll('.node')
.data(root.descendants().filter((d) => d.data.type !== 'root'))
.join('g')
.attr('class', 'node')
.attr('transform', (d) => `translate(${d.y ?? 0},${d.x})`)
.style('cursor', (d) => {
const hasChildren =
(d.data.children && d.data.children.length > 0) ||
(d.data._children && d.data._children.length > 0)
return hasChildren ? 'pointer' : 'default'
})
.on('click', (_event, d) => {
const hasChildren =
(d.data.children && d.data.children.length > 0) ||
(d.data._children && d.data._children.length > 0)
if (hasChildren) {
toggleCollapseRef.current(d.data.id, d.data.collapsed ?? false)
}
})
// Connection count badge
nodes
.append('text')
.attr('dy', -nodeHeight / 2 - 4)
.attr('text-anchor', 'middle')
.style('fill', (d) => colors[d.data.type].fill)
.attr('font-size', '10px')
.attr('font-weight', '500')
.text((d) => `${d.data.connections}`)
// Node rect
nodes
.append('rect')
.attr('x', (d) => -getNodeWidth(d) / 2)
.attr('y', -nodeHeight / 2)
.attr('width', (d) => getNodeWidth(d))
.attr('height', nodeHeight)
.attr('rx', 6)
.style('fill', (d) => colors[d.data.type].bg)
.style('stroke', (d) => colors[d.data.type].fill)
.attr('stroke-width', 2)
// Collapse indicator
nodes
.filter((d) =>
Boolean(
(d.data.children && d.data.children.length > 0) ||
(d.data._children && d.data._children.length > 0)
)
)
.append('text')
.attr('x', (d) => getNodeWidth(d) / 2 - 12)
.attr('dy', '0.35em')
.attr('text-anchor', 'middle')
.style('fill', (d) => colors[d.data.type].fill)
.attr('font-size', '14px')
.attr('font-weight', '700')
.text((d) => (d.data.collapsed ? '+' : ''))
// Label
nodes
.append('text')
.attr('dy', '0.31em')
.attr('text-anchor', 'middle')
.style('fill', (d) => colors[d.data.type].fill)
.attr('font-size', '11px')
.attr('font-weight', '600')
.text((d) => d.data.name)
// Tooltip
nodes
.append('title')
.text(
(d) =>
`${d.data.name}\n${d.data.connections} connections\n${calcTraffic(d.data.traffic)}`
)
}, [hierarchyData, resolvedTheme])
// ResizeObserver
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new ResizeObserver(() => {
// Re-trigger render by forcing a state touch isn't ideal;
// instead just re-run the render imperatively
if (svgRef.current && containerRef.current) {
svgRef.current.setAttribute('width', String(containerRef.current.clientWidth))
}
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const togglePause = useCallback(() => {
if (isPaused) {
frozenRef.current = null
} else {
frozenRef.current = [...connections]
}
setIsPaused((p) => !p)
}, [isPaused, connections])
return (
<div className="rounded-xl border border-foreground/10 bg-content1 p-4 shadow-sm">
{/* Header */}
<div className="mb-3.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success/15 text-success">
<IoGitNetworkOutline size={18} />
</div>
<h3 className="text-[15px] font-semibold">{t('network.topology.title')}</h3>
</div>
<div className="flex items-center gap-2">
{/* Stats */}
<div className="hidden flex-wrap gap-x-2 text-[12px] text-foreground/50 sm:flex">
<span>{stats.clientCount} {t('network.topology.clients')}</span>
<span>·</span>
<span>{stats.ruleCount} {t('network.topology.rules')}</span>
<span>·</span>
<span>{stats.groupCount} {t('network.topology.groups')}</span>
<span>·</span>
<span>{stats.proxyCount} {t('network.topology.nodes')}</span>
<span>·</span>
<span>{calcTraffic(stats.totalTraffic)}</span>
</div>
<Button
size="sm"
isIconOnly
variant="light"
onPress={togglePause}
className={`h-7 w-7 min-w-0 ${isPaused ? 'text-warning' : ''}`}
title={isPaused ? t('network.topology.resume') : t('network.topology.pause')}
>
{isPaused ? <IoPlayOutline size={16} /> : <IoPauseOutline size={16} />}
</Button>
</div>
</div>
{/* Legend */}
<div className="mb-3 flex flex-wrap gap-3 text-[12px] text-foreground/60">
<span className="flex items-center gap-1">
<IoGitNetworkOutline className="text-success" size={13} />
{t('network.topology.proxyGroups')}
</span>
<span className="flex items-center gap-1">
<IoServerOutline className="text-danger" size={13} />
{t('network.topology.proxyNodes')}
</span>
<span className="flex items-center gap-1">
<IoFunnelOutline className="text-secondary" size={13} />
{t('network.topology.rules')}
</span>
<span className="flex items-center gap-1">
<IoDesktopOutline className="text-primary" size={13} />
{t('network.topology.sourceIP')}
</span>
<span className="flex items-center gap-1">
<MdTag className="text-warning" size={13} />
{t('network.topology.sourcePort')}
</span>
</div>
{/* Empty state */}
{currentConnections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-foreground/40">
<IoGitNetworkOutline size={32} className="mb-2 animate-pulse" />
<span className="text-sm">{t('network.topology.waiting')}</span>
</div>
) : (
<div ref={containerRef} className="overflow-x-auto touch-pan-x touch-pan-y">
<svg ref={svgRef} style={{ minHeight: '400px' }} />
</div>
)}
</div>
)
}
export default NetworkTopologyCard

View File

@ -695,5 +695,17 @@
"guide.dns.title": "DNS OVRD",
"guide.dns.description": "The software defaults to using the application's DNS settings to override the subscription configuration. If you need to use the DNS settings from the subscription configuration, please disable this feature. The same applies to domain sniffing.",
"guide.end.title": "Tutorial Complete",
"guide.end.description": "Now that you understand the basic usage of the software, import your subscription and start using it. Enjoy!\nYou can also join our official <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram group</a> for the latest news."
}
"guide.end.description": "Now that you understand the basic usage of the software, import your subscription and start using it. Enjoy!\nYou can also join our official <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram group</a> for the latest news.",
"network.topology.title": "Network Topology",
"network.topology.clients": "clients",
"network.topology.rules": "Rules",
"network.topology.groups": "groups",
"network.topology.nodes": "nodes",
"network.topology.proxyGroups": "Proxy Groups",
"network.topology.proxyNodes": "Proxy Nodes",
"network.topology.sourceIP": "Source IP",
"network.topology.sourcePort": "Source Port",
"network.topology.waiting": "Waiting for connections...",
"network.topology.pause": "Pause",
"network.topology.resume": "Resume"
}

View File

@ -659,5 +659,17 @@
"guide.dns.title": "DNS",
"guide.dns.description": "نرم‌افزار به‌طور پیش‌فرض از تنظیمات DNS برنامه برای بازنویسی پیکربندی اشتراک استفاده می‌کند. اگر نیاز به استفاده از تنظیمات DNS موجود در پیکربندی اشتراک دارید، لطفاً این قابلیت را غیرفعال کنید. همین موضوع برای شناسایی دامنه نیز صدق می‌کند.",
"guide.end.title": "پایان آموزش",
"guide.end.description": "اکنون که با استفاده اساسی از نرم‌افزار آشنا شدید، اشتراک خود را وارد کنید و از آن استفاده کنید. لذت ببرید!\nهمچنین می‌توانید برای آخرین اخبار به <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">گروه تلگرام</a> ما بپیوندید."
}
"guide.end.description": "اکنون که با استفاده اساسی از نرم‌افزار آشنا شدید، اشتراک خود را وارد کنید و از آن استفاده کنید. لذت ببرید!\nهمچنین می‌توانید برای آخرین اخبار به <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">گروه تلگرام</a> ما بپیوندید.",
"network.topology.title": "توپولوژی شبکه",
"network.topology.clients": "کلاینت",
"network.topology.rules": "قوانین",
"network.topology.groups": "گروه‌ها",
"network.topology.nodes": "گره‌ها",
"network.topology.proxyGroups": "گروه‌های پروکسی",
"network.topology.proxyNodes": "گره‌های پروکسی",
"network.topology.sourceIP": "IP منبع",
"network.topology.sourcePort": "پورت منبع",
"network.topology.waiting": "در انتظار اتصال...",
"network.topology.pause": "مکث",
"network.topology.resume": "ادامه"
}

View File

@ -661,5 +661,17 @@
"guide.dns.title": "DNS",
"guide.dns.description": "Программное обеспечение по умолчанию использует настройки DNS приложения для переопределения конфигурации подписки. Если вам нужно использовать настройки DNS из конфигурации подписки, пожалуйста, отключите эту функцию. То же самое относится к определению домена.",
"guide.end.title": "Руководство завершено",
"guide.end.description": "Теперь, когда вы понимаете основы использования программы, импортируйте свою подписку и начните использовать ее. Приятного использования!\nВы также можете присоединиться к нашей официальной <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">группе Telegram</a> для получения последних новостей."
}
"guide.end.description": "Теперь, когда вы понимаете основы использования программы, импортируйте свою подписку и начните использовать ее. Приятного использования!\nВы также можете присоединиться к нашей официальной <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">группе Telegram</a> для получения последних новостей.",
"network.topology.title": "Топология сети",
"network.topology.clients": "клиентов",
"network.topology.rules": "правил",
"network.topology.groups": "групп",
"network.topology.nodes": "узлов",
"network.topology.proxyGroups": "Группы прокси",
"network.topology.proxyNodes": "Узлы прокси",
"network.topology.sourceIP": "IP источника",
"network.topology.sourcePort": "Порт источника",
"network.topology.waiting": "Ожидание подключений...",
"network.topology.pause": "Пауза",
"network.topology.resume": "Возобновить"
}

View File

@ -695,5 +695,17 @@
"guide.dns.title": "DNS",
"guide.dns.description": "软件默认使用应用的 DNS 设置覆盖订阅配置,如果您需要使用订阅配置中的 DNS 设置,请关闭此功能,域名嗅探同理",
"guide.end.title": "教程结束",
"guide.end.description": "现在您已经了解了软件的基本用法,导入您的订阅开始使用吧,祝您使用愉快!\n您还可以加入我们的官方 <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram 群组</a> 获取最新资讯"
}
"guide.end.description": "现在您已经了解了软件的基本用法,导入您的订阅开始使用吧,祝您使用愉快!\n您还可以加入我们的官方 <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram 群组</a> 获取最新资讯",
"network.topology.title": "网络拓扑",
"network.topology.clients": "客户端",
"network.topology.rules": "规则",
"network.topology.groups": "代理组",
"network.topology.nodes": "节点",
"network.topology.proxyGroups": "代理组",
"network.topology.proxyNodes": "代理节点",
"network.topology.sourceIP": "来源 IP",
"network.topology.sourcePort": "来源端口",
"network.topology.waiting": "等待连接数据...",
"network.topology.pause": "暂停",
"network.topology.resume": "恢复"
}

View File

@ -695,5 +695,17 @@
"guide.dns.title": "DNS",
"guide.dns.description": "軟件默認使用應用程式的 DNS 設置覆蓋訂閱配置,如果您需要使用訂閱配置中的 DNS 設置,請關閉此功能,域名嗅探同理",
"guide.end.title": "教程結束",
"guide.end.description": "現在您已經瞭解了軟件的基本用法,匯入您的訂閱開始使用吧,祝您使用愉快!\n您還可以加入我們的官方 <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram 群組</a> 獲取最新資訊"
}
"guide.end.description": "現在您已經瞭解了軟件的基本用法,匯入您的訂閱開始使用吧,祝您使用愉快!\n您還可以加入我們的官方 <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram 群組</a> 獲取最新資訊",
"network.topology.title": "網路拓撲",
"network.topology.clients": "客戶端",
"network.topology.rules": "規則",
"network.topology.groups": "代理群組",
"network.topology.nodes": "節點",
"network.topology.proxyGroups": "代理群組",
"network.topology.proxyNodes": "代理節點",
"network.topology.sourceIP": "來源 IP",
"network.topology.sourcePort": "來源連接埠",
"network.topology.waiting": "等待連線資料...",
"network.topology.pause": "暫停",
"network.topology.resume": "繼續"
}

View File

@ -1,4 +1,5 @@
import BasePage from '@renderer/components/base/base-page'
import NetworkTopologyCard from '@renderer/components/network/network-topology'
import React, { useState, useEffect, useCallback } from 'react'
import { Button, Select, SelectItem, Chip, Tooltip } from '@heroui/react'
import {
@ -385,6 +386,9 @@ const IPPage: React.FC = () => {
)}
</div>
{/* 网络拓扑卡片 */}
<NetworkTopologyCard />
{/* 网络延迟卡片 */}
<div className="rounded-xl border border-foreground/10 bg-content1 p-4 shadow-sm">
<div className="mb-3.5 flex items-center justify-between gap-3">