@sleep2agi/agent-network-dashboard 0.4.6-preview.6 → 0.4.6-preview.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (854) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build/chunks/{node_modules_13sb.px._.js → 00jm_02mlcew._.js} +167 -167
  4. package/.next/build/chunks/00jm_02mlcew._.js.map +47 -0
  5. package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js → [root-of-the-server]__0tgc-8p._.js} +3 -3
  6. package/.next/build/chunks/{[root-of-the-server]__0d-m0h0._.js → [root-of-the-server]__13z64y.._.js} +7 -7
  7. package/.next/build/chunks/{[root-of-the-server]__0d-m0h0._.js.map → [root-of-the-server]__13z64y.._.js.map} +2 -2
  8. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_0r4x5ys._.js +13 -0
  9. package/.next/build/chunks/[turbopack]_runtime.js +1 -1
  10. package/.next/build/postcss.js +4 -4
  11. package/.next/build-manifest.json +8 -8
  12. package/.next/diagnostics/route-bundle-stats.json +157 -143
  13. package/.next/fallback-build-manifest.json +3 -3
  14. package/.next/next-minimal-server.js.nft.json +1 -1
  15. package/.next/next-server.js.nft.json +1 -1
  16. package/.next/prerender-manifest.json +3 -3
  17. package/.next/required-server-files.js +4 -4
  18. package/.next/required-server-files.json +4 -4
  19. package/.next/routes-manifest.json +6 -0
  20. package/.next/server/app/_global-error/page/build-manifest.json +5 -5
  21. package/.next/server/app/_global-error/page.js +9 -9
  22. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  23. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  24. package/.next/server/app/_global-error.html +1 -1
  25. package/.next/server/app/_global-error.rsc +8 -8
  26. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  27. package/.next/server/app/_global-error.segments/_full.segment.rsc +8 -8
  28. package/.next/server/app/_global-error.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  30. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/.next/server/app/_not-found/page/build-manifest.json +5 -5
  32. package/.next/server/app/_not-found/page/next-font-manifest.json +1 -1
  33. package/.next/server/app/_not-found/page.js +12 -12
  34. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  35. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  36. package/.next/server/app/_not-found.html +2 -2
  37. package/.next/server/app/_not-found.rsc +12 -12
  38. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  39. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  40. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  41. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  42. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  43. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  44. package/.next/server/app/admin/page/build-manifest.json +5 -5
  45. package/.next/server/app/admin/page/next-font-manifest.json +1 -1
  46. package/.next/server/app/admin/page.js +13 -13
  47. package/.next/server/app/admin/page.js.nft.json +1 -1
  48. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  49. package/.next/server/app/admin.html +2 -2
  50. package/.next/server/app/admin.rsc +14 -14
  51. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  52. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  53. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  54. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  55. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  56. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  57. package/.next/server/app/api/anet/config/route.js +6 -6
  58. package/.next/server/app/api/anet/config/route.js.nft.json +1 -1
  59. package/.next/server/app/api/auth/login/route.js +7 -7
  60. package/.next/server/app/api/auth/login/route.js.nft.json +1 -1
  61. package/.next/server/app/api/auth/logout/route.js +7 -7
  62. package/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  63. package/.next/server/app/api/auth/v3/route.js +7 -7
  64. package/.next/server/app/api/auth/v3/route.js.nft.json +1 -1
  65. package/.next/server/app/api/hub/audit-log/route.js +6 -6
  66. package/.next/server/app/api/hub/audit-log/route.js.nft.json +1 -1
  67. package/.next/server/app/api/hub/auth/route.js +7 -7
  68. package/.next/server/app/api/hub/auth/route.js.nft.json +1 -1
  69. package/.next/server/app/api/hub/broadcast/route.js +6 -6
  70. package/.next/server/app/api/hub/broadcast/route.js.nft.json +1 -1
  71. package/.next/server/app/api/hub/events/route.js +6 -6
  72. package/.next/server/app/api/hub/events/route.js.nft.json +1 -1
  73. package/.next/server/app/api/hub/health/route.js +6 -6
  74. package/.next/server/app/api/hub/health/route.js.nft.json +1 -1
  75. package/.next/server/app/api/hub/inbox/route.js +6 -6
  76. package/.next/server/app/api/hub/inbox/route.js.nft.json +1 -1
  77. package/.next/server/app/api/hub/license/route.js +6 -6
  78. package/.next/server/app/api/hub/license/route.js.nft.json +1 -1
  79. package/.next/server/app/api/hub/messages/route.js +6 -6
  80. package/.next/server/app/api/hub/messages/route.js.nft.json +1 -1
  81. package/.next/server/app/api/hub/networks/route.js +6 -6
  82. package/.next/server/app/api/hub/networks/route.js.nft.json +1 -1
  83. package/.next/server/app/api/hub/nodes/route.js +6 -6
  84. package/.next/server/app/api/hub/nodes/route.js.nft.json +1 -1
  85. package/.next/server/app/api/hub/register/route.js +6 -6
  86. package/.next/server/app/api/hub/register/route.js.nft.json +1 -1
  87. package/.next/server/app/api/hub/send/route.js +6 -6
  88. package/.next/server/app/api/hub/send/route.js.nft.json +1 -1
  89. package/.next/server/app/api/hub/server-logs/route.js +6 -6
  90. package/.next/server/app/api/hub/server-logs/route.js.nft.json +1 -1
  91. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  92. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  93. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  94. package/.next/server/app/api/hub/servers/route.js +7 -0
  95. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  96. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  97. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  98. package/.next/server/app/api/hub/session/route.js +6 -6
  99. package/.next/server/app/api/hub/session/route.js.nft.json +1 -1
  100. package/.next/server/app/api/hub/stats/route.js +6 -6
  101. package/.next/server/app/api/hub/stats/route.js.nft.json +1 -1
  102. package/.next/server/app/api/hub/status/route.js +6 -6
  103. package/.next/server/app/api/hub/status/route.js.nft.json +1 -1
  104. package/.next/server/app/api/hub/task-events/route.js +6 -6
  105. package/.next/server/app/api/hub/task-events/route.js.nft.json +1 -1
  106. package/.next/server/app/api/hub/tasks/route.js +6 -6
  107. package/.next/server/app/api/hub/tasks/route.js.nft.json +1 -1
  108. package/.next/server/app/api/hub/tmux/route.js +6 -6
  109. package/.next/server/app/api/hub/tmux/route.js.nft.json +1 -1
  110. package/.next/server/app/api/hub/tokens/route.js +6 -6
  111. package/.next/server/app/api/hub/tokens/route.js.nft.json +1 -1
  112. package/.next/server/app/favicon.ico/route.js +6 -6
  113. package/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  114. package/.next/server/app/index.html +2 -2
  115. package/.next/server/app/index.rsc +14 -14
  116. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  117. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  118. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  119. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  120. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  121. package/.next/server/app/login/page/build-manifest.json +5 -5
  122. package/.next/server/app/login/page/next-font-manifest.json +1 -1
  123. package/.next/server/app/login/page.js +13 -13
  124. package/.next/server/app/login/page.js.nft.json +1 -1
  125. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  126. package/.next/server/app/login.html +2 -2
  127. package/.next/server/app/login.rsc +14 -14
  128. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  129. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  130. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  131. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  132. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  133. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  134. package/.next/server/app/logs/page/build-manifest.json +5 -5
  135. package/.next/server/app/logs/page/next-font-manifest.json +1 -1
  136. package/.next/server/app/logs/page.js +13 -13
  137. package/.next/server/app/logs/page.js.nft.json +1 -1
  138. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  139. package/.next/server/app/logs.html +2 -2
  140. package/.next/server/app/logs.rsc +14 -14
  141. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  142. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  143. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  144. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  145. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  146. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  147. package/.next/server/app/messages/page/build-manifest.json +5 -5
  148. package/.next/server/app/messages/page/next-font-manifest.json +1 -1
  149. package/.next/server/app/messages/page.js +13 -13
  150. package/.next/server/app/messages/page.js.nft.json +1 -1
  151. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  152. package/.next/server/app/messages.html +2 -2
  153. package/.next/server/app/messages.rsc +14 -14
  154. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  155. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  156. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  157. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  158. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  159. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  160. package/.next/server/app/node/page/build-manifest.json +5 -5
  161. package/.next/server/app/node/page/next-font-manifest.json +1 -1
  162. package/.next/server/app/node/page.js +13 -13
  163. package/.next/server/app/node/page.js.nft.json +1 -1
  164. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  165. package/.next/server/app/node.html +2 -2
  166. package/.next/server/app/node.rsc +14 -14
  167. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  168. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  169. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  170. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  171. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  172. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  173. package/.next/server/app/nodes/page/build-manifest.json +5 -5
  174. package/.next/server/app/nodes/page/next-font-manifest.json +1 -1
  175. package/.next/server/app/nodes/page.js +13 -13
  176. package/.next/server/app/nodes/page.js.nft.json +1 -1
  177. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  178. package/.next/server/app/nodes.html +2 -2
  179. package/.next/server/app/nodes.rsc +14 -14
  180. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  181. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  182. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  183. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  184. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  185. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  186. package/.next/server/app/page/build-manifest.json +5 -5
  187. package/.next/server/app/page/next-font-manifest.json +1 -1
  188. package/.next/server/app/page.js +13 -13
  189. package/.next/server/app/page.js.nft.json +1 -1
  190. package/.next/server/app/page_client-reference-manifest.js +1 -1
  191. package/.next/server/app/server-logs/page/build-manifest.json +5 -5
  192. package/.next/server/app/server-logs/page/next-font-manifest.json +1 -1
  193. package/.next/server/app/server-logs/page.js +13 -13
  194. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  195. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  196. package/.next/server/app/server-logs.html +2 -2
  197. package/.next/server/app/server-logs.rsc +14 -14
  198. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  199. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  200. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  201. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  202. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  203. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  204. package/.next/server/app/settings/networks/page/build-manifest.json +5 -5
  205. package/.next/server/app/settings/networks/page/next-font-manifest.json +1 -1
  206. package/.next/server/app/settings/networks/page.js +13 -13
  207. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  208. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  209. package/.next/server/app/settings/networks.html +2 -2
  210. package/.next/server/app/settings/networks.rsc +14 -14
  211. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  212. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  213. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  214. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  215. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  216. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  217. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  218. package/.next/server/app/settings/page/build-manifest.json +5 -5
  219. package/.next/server/app/settings/page/next-font-manifest.json +1 -1
  220. package/.next/server/app/settings/page.js +13 -13
  221. package/.next/server/app/settings/page.js.nft.json +1 -1
  222. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  223. package/.next/server/app/settings/tokens/page/build-manifest.json +5 -5
  224. package/.next/server/app/settings/tokens/page/next-font-manifest.json +1 -1
  225. package/.next/server/app/settings/tokens/page.js +13 -13
  226. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  227. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  228. package/.next/server/app/settings/tokens.html +2 -2
  229. package/.next/server/app/settings/tokens.rsc +14 -14
  230. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  231. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  232. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  233. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  234. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  235. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  236. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  237. package/.next/server/app/settings.html +2 -2
  238. package/.next/server/app/settings.rsc +14 -14
  239. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  240. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  241. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  242. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  243. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  244. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  245. package/.next/server/app/tasks/[id]/page/build-manifest.json +5 -5
  246. package/.next/server/app/tasks/[id]/page/next-font-manifest.json +1 -1
  247. package/.next/server/app/tasks/[id]/page.js +13 -13
  248. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  249. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  250. package/.next/server/app/tasks/page/build-manifest.json +5 -5
  251. package/.next/server/app/tasks/page/next-font-manifest.json +1 -1
  252. package/.next/server/app/tasks/page.js +13 -13
  253. package/.next/server/app/tasks/page.js.nft.json +1 -1
  254. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  255. package/.next/server/app/tasks.html +2 -2
  256. package/.next/server/app/tasks.rsc +14 -14
  257. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  258. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  259. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  260. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  261. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  262. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  263. package/.next/server/app-paths-manifest.json +1 -0
  264. package/.next/server/chunks/00jm_next_04f3gac._.js +10 -0
  265. package/.next/server/chunks/00jm_next_04f3gac._.js.map +1 -0
  266. package/.next/server/chunks/00jm_next_dist_esm_build_templates_app-route_02f430o.js +3 -0
  267. package/.next/server/chunks/00jm_next_dist_esm_build_templates_app-route_02f430o.js.map +1 -0
  268. package/.next/server/chunks/0ci9_nt-network-dashboard__next-internal_server_app_api_auth_v3_route_actions_0f5nbzr.js +3 -0
  269. package/.next/server/chunks/0ci9_nt-network-dashboard__next-internal_server_app_favicon_ico_route_actions_0t0eu9r.js +3 -0
  270. package/.next/server/chunks/0ykm__next-internal_server_app_api_anet_config_route_actions_0a~v55_.js +3 -0
  271. package/.next/server/chunks/0ykm__next-internal_server_app_api_auth_login_route_actions_0xnxfs4.js +3 -0
  272. package/.next/server/chunks/0ykm__next-internal_server_app_api_auth_logout_route_actions_0p8p0rn.js +3 -0
  273. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_audit-log_route_actions_06uquwi.js +3 -0
  274. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_auth_route_actions_0f_y1ey.js +3 -0
  275. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_broadcast_route_actions_0ao3j43.js +3 -0
  276. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_events_route_actions_0_5ndld.js +3 -0
  277. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_health_route_actions_0mpzl74.js +3 -0
  278. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_inbox_route_actions_0stoyto.js +3 -0
  279. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_license_route_actions_0a4.fuh.js +3 -0
  280. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_messages_route_actions_059rw1o.js +3 -0
  281. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_networks_route_actions_03sehpn.js +3 -0
  282. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_nodes_route_actions_11fbp28.js +3 -0
  283. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_register_route_actions_0l.d7~n.js +3 -0
  284. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_send_route_actions_0.p82vy.js +3 -0
  285. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_server-logs_route_actions_05~c5lb.js +3 -0
  286. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  287. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_session_route_actions_0b9ef2i.js +3 -0
  288. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_stats_route_actions_03u65u0.js +3 -0
  289. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_status_route_actions_0_71ear.js +3 -0
  290. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_task-events_route_actions_07md_ee.js +3 -0
  291. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_tasks_route_actions_0er3uvk.js +3 -0
  292. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_tmux_route_actions_0o_1-8u.js +3 -0
  293. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_tokens_route_actions_09doluz.js +3 -0
  294. package/.next/server/chunks/[root-of-the-server]__0-2le8-._.js +3 -0
  295. package/.next/server/chunks/[root-of-the-server]__0-2le8-._.js.map +1 -0
  296. package/.next/server/chunks/[root-of-the-server]__0-g~15e._.js +3 -0
  297. package/.next/server/chunks/[root-of-the-server]__0-g~15e._.js.map +1 -0
  298. package/.next/server/chunks/[root-of-the-server]__00194.t._.js +3 -0
  299. package/.next/server/chunks/{[root-of-the-server]__03ueci3._.js.map → [root-of-the-server]__00194.t._.js.map} +1 -1
  300. package/.next/server/chunks/[root-of-the-server]__02p2n3n._.js +3 -0
  301. package/.next/server/chunks/[root-of-the-server]__02p2n3n._.js.map +1 -0
  302. package/.next/server/chunks/[root-of-the-server]__02y-i-p._.js +6 -0
  303. package/.next/server/chunks/[root-of-the-server]__02y-i-p._.js.map +1 -0
  304. package/.next/server/chunks/[root-of-the-server]__043tnx2._.js +3 -0
  305. package/.next/server/chunks/{[root-of-the-server]__0o4qfn9._.js.map → [root-of-the-server]__043tnx2._.js.map} +1 -1
  306. package/.next/server/chunks/[root-of-the-server]__04z62zk._.js +3 -0
  307. package/.next/server/chunks/[root-of-the-server]__04z62zk._.js.map +1 -0
  308. package/.next/server/chunks/[root-of-the-server]__06p6kkp._.js +3 -0
  309. package/.next/server/chunks/[root-of-the-server]__06p6kkp._.js.map +1 -0
  310. package/.next/server/chunks/[root-of-the-server]__07ztet2._.js +3 -0
  311. package/.next/server/chunks/[root-of-the-server]__07ztet2._.js.map +1 -0
  312. package/.next/server/chunks/[root-of-the-server]__08uyvdq._.js +20 -0
  313. package/.next/server/chunks/[root-of-the-server]__08uyvdq._.js.map +1 -0
  314. package/.next/server/chunks/[root-of-the-server]__0b7v51y._.js +3 -0
  315. package/.next/server/chunks/[root-of-the-server]__0b7v51y._.js.map +1 -0
  316. package/.next/server/chunks/[root-of-the-server]__0e3ucl1._.js +3 -0
  317. package/.next/server/chunks/{[root-of-the-server]__0rg9ifx._.js.map → [root-of-the-server]__0e3ucl1._.js.map} +1 -1
  318. package/.next/server/chunks/[root-of-the-server]__0e6yail._.js +3 -0
  319. package/.next/server/chunks/[root-of-the-server]__0e6yail._.js.map +1 -0
  320. package/.next/server/chunks/[root-of-the-server]__0gx2_6a._.js +3 -0
  321. package/.next/server/chunks/[root-of-the-server]__0gx2_6a._.js.map +1 -0
  322. package/.next/server/chunks/[root-of-the-server]__0hcnyp.._.js +3 -0
  323. package/.next/server/chunks/{[root-of-the-server]__0o29v8c._.js.map → [root-of-the-server]__0hcnyp.._.js.map} +1 -1
  324. package/.next/server/chunks/[root-of-the-server]__0hg3_8r._.js +3 -0
  325. package/.next/server/chunks/[root-of-the-server]__0hg3_8r._.js.map +1 -0
  326. package/.next/server/chunks/[root-of-the-server]__0mxw4vb._.js +3 -0
  327. package/.next/server/chunks/[root-of-the-server]__0mxw4vb._.js.map +1 -0
  328. package/.next/server/chunks/[root-of-the-server]__0mzopco._.js +3 -0
  329. package/.next/server/chunks/[root-of-the-server]__0mzopco._.js.map +1 -0
  330. package/.next/server/chunks/[root-of-the-server]__0o45a4q._.js +3 -0
  331. package/.next/server/chunks/{[root-of-the-server]__0p6tutt._.js.map → [root-of-the-server]__0o45a4q._.js.map} +1 -1
  332. package/.next/server/chunks/[root-of-the-server]__0q-teo6._.js +3 -0
  333. package/.next/server/chunks/[root-of-the-server]__0q-teo6._.js.map +1 -0
  334. package/.next/server/chunks/[root-of-the-server]__0qcxxzf._.js +3 -0
  335. package/.next/server/chunks/[root-of-the-server]__0qcxxzf._.js.map +1 -0
  336. package/.next/server/chunks/[root-of-the-server]__0rovr5-._.js +3 -0
  337. package/.next/server/chunks/[root-of-the-server]__0rovr5-._.js.map +1 -0
  338. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  339. package/.next/server/chunks/{[root-of-the-server]__0~h2uqi._.js.map → [root-of-the-server]__0tx8s8i._.js.map} +1 -1
  340. package/.next/server/chunks/[root-of-the-server]__0xtp-et._.js +3 -0
  341. package/.next/server/chunks/[root-of-the-server]__0xtp-et._.js.map +1 -0
  342. package/.next/server/chunks/[root-of-the-server]__10pvabe._.js +3 -0
  343. package/.next/server/chunks/[root-of-the-server]__10pvabe._.js.map +1 -0
  344. package/.next/server/chunks/[root-of-the-server]__10t11zq._.js +3 -0
  345. package/.next/server/chunks/[root-of-the-server]__10t11zq._.js.map +1 -0
  346. package/.next/server/chunks/[root-of-the-server]__12r~omy._.js +3 -0
  347. package/.next/server/chunks/[root-of-the-server]__12r~omy._.js.map +1 -0
  348. package/.next/server/chunks/[root-of-the-server]__1389~cs._.js +3 -0
  349. package/.next/server/chunks/[root-of-the-server]__1389~cs._.js.map +1 -0
  350. package/.next/server/chunks/[root-of-the-server]__13n8t..._.js +3 -0
  351. package/.next/server/chunks/[root-of-the-server]__13n8t..._.js.map +1 -0
  352. package/.next/server/chunks/[turbopack]_runtime.js +1 -1
  353. package/.next/server/chunks/ssr/{node_modules_0r_.s40._.js → 00jm_03rn9vn._.js} +2 -2
  354. package/.next/server/chunks/ssr/00jm_03rn9vn._.js.map +1 -0
  355. package/.next/server/chunks/ssr/{node_modules_09w7yel._.js → 00jm_059ns68._.js} +3 -3
  356. package/.next/server/chunks/ssr/00jm_059ns68._.js.map +1 -0
  357. package/.next/server/chunks/ssr/00jm_09ae-q0._.js +3 -0
  358. package/.next/server/chunks/ssr/00jm_09ae-q0._.js.map +1 -0
  359. package/.next/server/chunks/ssr/00jm_0j7__z.._.js +3 -0
  360. package/.next/server/chunks/ssr/00jm_0j7__z.._.js.map +1 -0
  361. package/.next/server/chunks/ssr/00jm_next_01-0urn._.js +6 -0
  362. package/.next/server/chunks/ssr/00jm_next_01-0urn._.js.map +1 -0
  363. package/.next/server/chunks/ssr/00jm_next_dist_05rlh5d._.js +6 -0
  364. package/.next/server/chunks/ssr/00jm_next_dist_05rlh5d._.js.map +1 -0
  365. package/.next/server/chunks/ssr/00jm_next_dist_client_components_0gkudcq._.js +3 -0
  366. package/.next/server/chunks/ssr/00jm_next_dist_client_components_0gkudcq._.js.map +1 -0
  367. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_forbidden_0ecus3e.js +3 -0
  368. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_forbidden_0ecus3e.js.map +1 -0
  369. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_global-error_07n8pg2.js +3 -0
  370. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_global-error_07n8pg2.js.map +1 -0
  371. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_unauthorized_0r0uq4j.js +3 -0
  372. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_unauthorized_0r0uq4j.js.map +1 -0
  373. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_002l7yi.js → 00jm_next_dist_esm_build_templates_app-page_0..ddwm.js} +3 -3
  374. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0..ddwm.js.map +1 -0
  375. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js → 00jm_next_dist_esm_build_templates_app-page_02bxi6j.js} +3 -3
  376. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_02bxi6j.js.map +1 -0
  377. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_02suzhc.js → 00jm_next_dist_esm_build_templates_app-page_05oassd.js} +3 -3
  378. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_05oassd.js.map +1 -0
  379. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js → 00jm_next_dist_esm_build_templates_app-page_05vxtby.js} +3 -3
  380. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_05vxtby.js.map +1 -0
  381. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js → 00jm_next_dist_esm_build_templates_app-page_05~eml4.js} +3 -3
  382. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_05~eml4.js.map +1 -0
  383. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js → 00jm_next_dist_esm_build_templates_app-page_0_-g_q2.js} +3 -3
  384. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0_-g_q2.js.map +1 -0
  385. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js → 00jm_next_dist_esm_build_templates_app-page_0_ec0xu.js} +3 -3
  386. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0_ec0xu.js.map +1 -0
  387. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0czb6wz.js +4 -0
  388. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0czb6wz.js.map +1 -0
  389. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_13s07li.js → 00jm_next_dist_esm_build_templates_app-page_0e-cn.p.js} +3 -3
  390. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0e-cn.p.js.map +1 -0
  391. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js → 00jm_next_dist_esm_build_templates_app-page_0k8_sr8.js} +3 -3
  392. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0k8_sr8.js.map +1 -0
  393. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js → 00jm_next_dist_esm_build_templates_app-page_0lt_3bw.js} +3 -3
  394. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0lt_3bw.js.map +1 -0
  395. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js → 00jm_next_dist_esm_build_templates_app-page_0uyh3yt.js} +3 -3
  396. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0uyh3yt.js.map +1 -0
  397. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0foejvu.js → 00jm_next_dist_esm_build_templates_app-page_0v-a2ps.js} +3 -3
  398. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0v-a2ps.js.map +1 -0
  399. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_12.4r9~.js +4 -0
  400. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_12.4r9~.js.map +1 -0
  401. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js → 00jm_next_dist_esm_build_templates_app-page_13mqkay.js} +3 -3
  402. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_13mqkay.js.map +1 -0
  403. package/.next/server/chunks/ssr/0dwh_ent-network-dashboard__next-internal_server_app_server-logs_page_actions_0hkk815.js +3 -0
  404. package/.next/server/chunks/ssr/0yas_gent-network-dashboard__next-internal_server_app__not-found_page_actions_0u.f-rz.js +3 -0
  405. package/.next/server/chunks/ssr/0yas_gent-network-dashboard__next-internal_server_app_tasks_[id]_page_actions_0r3~lho.js +3 -0
  406. package/.next/server/chunks/ssr/0ykm__next-internal_server_app__global-error_page_actions_100fxls.js +3 -0
  407. package/.next/server/chunks/ssr/0ykm__next-internal_server_app_settings_networks_page_actions_0jgy57m.js +3 -0
  408. package/.next/server/chunks/ssr/0ykm__next-internal_server_app_settings_tokens_page_actions_0l5h.k~.js +3 -0
  409. package/.next/server/chunks/ssr/[root-of-the-server]__030vg4n._.js +3 -0
  410. package/.next/server/chunks/ssr/[root-of-the-server]__030vg4n._.js.map +1 -0
  411. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +3 -0
  412. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -0
  413. package/.next/server/chunks/ssr/[root-of-the-server]__063~dul._.js +3 -0
  414. package/.next/server/chunks/ssr/[root-of-the-server]__063~dul._.js.map +1 -0
  415. package/.next/server/chunks/ssr/[root-of-the-server]__07io.d8._.js +3 -0
  416. package/.next/server/chunks/ssr/[root-of-the-server]__07io.d8._.js.map +1 -0
  417. package/.next/server/chunks/ssr/[root-of-the-server]__08d6lp4._.js +3 -0
  418. package/.next/server/chunks/ssr/[root-of-the-server]__08d6lp4._.js.map +1 -0
  419. package/.next/server/chunks/ssr/[root-of-the-server]__09f1wn6._.js +3 -0
  420. package/.next/server/chunks/ssr/[root-of-the-server]__09f1wn6._.js.map +1 -0
  421. package/.next/server/chunks/ssr/[root-of-the-server]__0c4xtkg._.js +3 -0
  422. package/.next/server/chunks/ssr/[root-of-the-server]__0c4xtkg._.js.map +1 -0
  423. package/.next/server/chunks/ssr/[root-of-the-server]__0erfzjn._.js +3 -0
  424. package/.next/server/chunks/ssr/[root-of-the-server]__0erfzjn._.js.map +1 -0
  425. package/.next/server/chunks/ssr/[root-of-the-server]__0ezjwje._.js +3 -0
  426. package/.next/server/chunks/ssr/[root-of-the-server]__0ezjwje._.js.map +1 -0
  427. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +3 -0
  428. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -0
  429. package/.next/server/chunks/ssr/{[root-of-the-server]__08qr2ji._.js → [root-of-the-server]__0gc.4uk._.js} +3 -3
  430. package/.next/server/chunks/ssr/[root-of-the-server]__0gc.4uk._.js.map +1 -0
  431. package/.next/server/chunks/ssr/[root-of-the-server]__0h_p~-4._.js +19 -0
  432. package/.next/server/chunks/ssr/[root-of-the-server]__0h_p~-4._.js.map +1 -0
  433. package/.next/server/chunks/ssr/[root-of-the-server]__0jdr1e5._.js +3 -0
  434. package/.next/server/chunks/ssr/[root-of-the-server]__0jdr1e5._.js.map +1 -0
  435. package/.next/server/chunks/ssr/[root-of-the-server]__0lw16mu._.js +3 -0
  436. package/.next/server/chunks/ssr/[root-of-the-server]__0lw16mu._.js.map +1 -0
  437. package/.next/server/chunks/ssr/[root-of-the-server]__0mjly~-._.js +33 -0
  438. package/.next/server/chunks/ssr/[root-of-the-server]__0mjly~-._.js.map +1 -0
  439. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +3 -0
  440. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -0
  441. package/.next/server/chunks/ssr/[root-of-the-server]__0p-95r_._.js +3 -0
  442. package/.next/server/chunks/ssr/[root-of-the-server]__0p-95r_._.js.map +1 -0
  443. package/.next/server/chunks/ssr/[root-of-the-server]__0u5aqkk._.js +8 -0
  444. package/.next/server/chunks/ssr/[root-of-the-server]__0u5aqkk._.js.map +1 -0
  445. package/.next/server/chunks/ssr/[root-of-the-server]__0ue5.fy._.js +3 -0
  446. package/.next/server/chunks/ssr/[root-of-the-server]__0ue5.fy._.js.map +1 -0
  447. package/.next/server/chunks/ssr/[root-of-the-server]__0uggxvx._.js +3 -0
  448. package/.next/server/chunks/ssr/[root-of-the-server]__0uggxvx._.js.map +1 -0
  449. package/.next/server/chunks/ssr/[root-of-the-server]__0zjk5ro._.js +3 -0
  450. package/.next/server/chunks/ssr/[root-of-the-server]__0zjk5ro._.js.map +1 -0
  451. package/.next/server/chunks/ssr/[root-of-the-server]__0zz3v7a._.js +3 -0
  452. package/.next/server/chunks/ssr/[root-of-the-server]__0zz3v7a._.js.map +1 -0
  453. package/.next/server/chunks/ssr/[root-of-the-server]__0~y12ef._.js +3 -0
  454. package/.next/server/chunks/ssr/[root-of-the-server]__0~y12ef._.js.map +1 -0
  455. package/.next/server/chunks/ssr/[root-of-the-server]__129sc_9._.js +3 -0
  456. package/.next/server/chunks/ssr/[root-of-the-server]__129sc_9._.js.map +1 -0
  457. package/.next/server/chunks/ssr/[turbopack]_runtime.js +1 -1
  458. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +4 -0
  459. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -0
  460. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_admin_page_actions_0i~.x~j.js +3 -0
  461. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_login_page_actions_0frwl15.js +3 -0
  462. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_logs_page_actions_0xa2ox_.js +3 -0
  463. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_messages_page_actions_10j3tg4.js +3 -0
  464. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_node_page_actions_0sl4ntd.js +3 -0
  465. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_nodes_page_actions_0cpgkfs.js +3 -0
  466. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_page_actions_08nuo36.js +3 -0
  467. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_settings_page_actions_0iv.lmk.js +3 -0
  468. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_tasks_page_actions_08c7qlx.js +3 -0
  469. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_tasks_page_actions_08c7qlx.js.map +1 -0
  470. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +3 -0
  471. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -0
  472. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +3 -0
  473. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -0
  474. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +3 -0
  475. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -0
  476. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +3 -0
  477. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -0
  478. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +3 -0
  479. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -0
  480. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +3 -0
  481. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -0
  482. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +3 -0
  483. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -0
  484. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +9 -0
  485. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -0
  486. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +3 -0
  487. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -0
  488. package/.next/server/chunks/ssr/{app_components_08ys5y3._.js → agent-network-dashboard_app_components_0mvyi-4._.js} +3 -3
  489. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -0
  490. package/.next/server/chunks/ssr/{app_server-logs_page_tsx_0_kpp1p._.js → agent-network-dashboard_app_server-logs_page_tsx_0dg.l_8._.js} +2 -2
  491. package/.next/server/chunks/ssr/agent-network-dashboard_app_server-logs_page_tsx_0dg.l_8._.js.map +1 -0
  492. package/.next/server/chunks/ssr/agent-network-dashboard_app_tasks_page_tsx_0mwxy4z._.js +3 -0
  493. package/.next/server/chunks/ssr/agent-network-dashboard_app_tasks_page_tsx_0mwxy4z._.js.map +1 -0
  494. package/.next/server/middleware-build-manifest.js +8 -8
  495. package/.next/server/middleware.js +3 -3
  496. package/.next/server/middleware.js.nft.json +1 -1
  497. package/.next/server/next-font-manifest.js +1 -1
  498. package/.next/server/next-font-manifest.json +14 -14
  499. package/.next/server/pages/404.html +2 -2
  500. package/.next/server/pages/500.html +1 -1
  501. package/.next/server/server-reference-manifest.js +1 -1
  502. package/.next/server/server-reference-manifest.json +1 -1
  503. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  504. package/.next/static/chunks/{0gtbwcilfu6b7.js → 0.66f3.rtcybb.js} +1 -1
  505. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  506. package/.next/static/chunks/039-b.v_9-_rm.js +1 -0
  507. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  508. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  509. package/.next/static/chunks/05oi25hh7b81r.js +31 -0
  510. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  511. package/.next/static/chunks/07~8sx3_5uiox.js +1 -0
  512. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  513. package/.next/static/chunks/{0pqt~8bl3ukh4.js → 0_pxg.4pebw__.js} +2 -2
  514. package/.next/static/chunks/0b8wnt4put-5n.js +2 -0
  515. package/.next/static/chunks/{01xlw8hd842-c.js → 0eov~nkwaf-qa.js} +1 -1
  516. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  517. package/.next/static/chunks/{0w1zwt8kq_7nb.js → 0ku0fjqlm9mca.js} +1 -1
  518. package/.next/static/chunks/0mqwn-l7wtcdo.js +7 -0
  519. package/.next/static/chunks/0nnhdu12bgu0r.js +1 -0
  520. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  521. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  522. package/.next/static/chunks/0u3b93reo9xci.js +1 -0
  523. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  524. package/.next/static/chunks/127kw7~wsnyu7.css +2 -0
  525. package/.next/static/chunks/12_~0el6i1t-1.js +1 -0
  526. package/.next/static/chunks/153p2.3._7t7u.js +2 -0
  527. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  528. package/.next/static/chunks/{turbopack-01f1xs~cq_~si.js → turbopack-0.7dvsf9rllt0.js} +1 -1
  529. package/.next/trace +2 -2
  530. package/.next/trace-build +1 -1
  531. package/.next/types/routes.d.ts +2 -1
  532. package/.next/types/validator.ts +9 -0
  533. package/app/api/hub/servers/route.ts +39 -0
  534. package/app/components/AppShell.tsx +2 -0
  535. package/app/components/ChatPopover.tsx +38 -2
  536. package/app/components/HelpOverlay.tsx +18 -0
  537. package/app/components/ServersDrawer.tsx +206 -0
  538. package/app/components/TopoGraph.tsx +1166 -159
  539. package/app/components/types.ts +3 -0
  540. package/app/components/utils.ts +8 -7
  541. package/app/lib/time.ts +36 -0
  542. package/package.json +1 -1
  543. package/scripts/chat-popover-test.mjs +2 -0
  544. package/scripts/chat-theme-test.mjs +1 -0
  545. package/scripts/help-overlay-topo-test.mjs +44 -0
  546. package/scripts/servers-drawer-live-test.mjs +96 -0
  547. package/scripts/topo-active-links-chip-test.mjs +73 -0
  548. package/scripts/topo-arrow-tier-test.mjs +61 -0
  549. package/scripts/topo-auto-fit-test.mjs +73 -0
  550. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  551. package/scripts/topo-click-ripple-test.mjs +66 -0
  552. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  553. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  554. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  555. package/scripts/topo-edge-focus-test.mjs +113 -0
  556. package/scripts/topo-edge-hover-test.mjs +93 -0
  557. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  558. package/scripts/topo-fit-key-test.mjs +106 -0
  559. package/scripts/topo-freshness-chip-test.mjs +74 -0
  560. package/scripts/topo-group-count-test.mjs +63 -0
  561. package/scripts/topo-group-hover-test.mjs +82 -0
  562. package/scripts/topo-groupbox-test.mjs +118 -0
  563. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  564. package/scripts/topo-hub-title-test.mjs +93 -0
  565. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  566. package/scripts/topo-label-lift-test.mjs +119 -0
  567. package/scripts/topo-label-scale-test.mjs +78 -0
  568. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  569. package/scripts/topo-layout-key-test.mjs +91 -0
  570. package/scripts/topo-layout-test.mjs +1 -0
  571. package/scripts/topo-minimap-test.mjs +68 -0
  572. package/scripts/topo-mobile-chips-test.mjs +103 -0
  573. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  574. package/scripts/topo-offline-dim-test.mjs +70 -0
  575. package/scripts/topo-overlap-test.mjs +164 -0
  576. package/scripts/topo-p0round-test.mjs +166 -0
  577. package/scripts/topo-pan-cursor-test.mjs +62 -0
  578. package/scripts/topo-popover-meta-test.mjs +78 -0
  579. package/scripts/topo-pressure-bar-test.mjs +69 -0
  580. package/scripts/topo-pulse-rate-test.mjs +71 -0
  581. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  582. package/scripts/topo-reduced-motion-test.mjs +77 -0
  583. package/scripts/topo-rename-test.mjs +1 -0
  584. package/scripts/topo-tz-ghost-test.mjs +69 -0
  585. package/scripts/topo-vendor-test.mjs +1 -0
  586. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  587. package/scripts/topo-zoom-keys-test.mjs +89 -0
  588. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_06e.r3r._.js +0 -13
  589. package/.next/build/chunks/node_modules_13sb.px._.js.map +0 -47
  590. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js +0 -3
  591. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js.map +0 -1
  592. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js +0 -3
  593. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js.map +0 -1
  594. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js +0 -3
  595. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js.map +0 -1
  596. package/.next/server/chunks/[root-of-the-server]__03ueci3._.js +0 -3
  597. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js +0 -3
  598. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js.map +0 -1
  599. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js +0 -3
  600. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js.map +0 -1
  601. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js +0 -20
  602. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js.map +0 -1
  603. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js +0 -3
  604. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js.map +0 -1
  605. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js +0 -3
  606. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js.map +0 -1
  607. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js +0 -3
  608. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js.map +0 -1
  609. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js +0 -3
  610. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js.map +0 -1
  611. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js +0 -3
  612. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js.map +0 -1
  613. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js +0 -3
  614. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js.map +0 -1
  615. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js +0 -3
  616. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js.map +0 -1
  617. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js +0 -3
  618. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js.map +0 -1
  619. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js +0 -3
  620. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js.map +0 -1
  621. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js +0 -3
  622. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js.map +0 -1
  623. package/.next/server/chunks/[root-of-the-server]__0o29v8c._.js +0 -3
  624. package/.next/server/chunks/[root-of-the-server]__0o4qfn9._.js +0 -3
  625. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js +0 -3
  626. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js.map +0 -1
  627. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js +0 -6
  628. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js.map +0 -1
  629. package/.next/server/chunks/[root-of-the-server]__0p6tutt._.js +0 -3
  630. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js +0 -3
  631. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js.map +0 -1
  632. package/.next/server/chunks/[root-of-the-server]__0rg9ifx._.js +0 -3
  633. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js +0 -3
  634. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js.map +0 -1
  635. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js +0 -3
  636. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js.map +0 -1
  637. package/.next/server/chunks/[root-of-the-server]__0~h2uqi._.js +0 -3
  638. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js +0 -3
  639. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js.map +0 -1
  640. package/.next/server/chunks/_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js +0 -3
  641. package/.next/server/chunks/_next-internal_server_app_api_auth_login_route_actions_0zukc38.js +0 -3
  642. package/.next/server/chunks/_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js +0 -3
  643. package/.next/server/chunks/_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js +0 -3
  644. package/.next/server/chunks/_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js +0 -3
  645. package/.next/server/chunks/_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js +0 -3
  646. package/.next/server/chunks/_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js +0 -3
  647. package/.next/server/chunks/_next-internal_server_app_api_hub_events_route_actions_0opktby.js +0 -3
  648. package/.next/server/chunks/_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js +0 -3
  649. package/.next/server/chunks/_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js +0 -3
  650. package/.next/server/chunks/_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js +0 -3
  651. package/.next/server/chunks/_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js +0 -3
  652. package/.next/server/chunks/_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js +0 -3
  653. package/.next/server/chunks/_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js +0 -3
  654. package/.next/server/chunks/_next-internal_server_app_api_hub_register_route_actions_086wi_3.js +0 -3
  655. package/.next/server/chunks/_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js +0 -3
  656. package/.next/server/chunks/_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js +0 -3
  657. package/.next/server/chunks/_next-internal_server_app_api_hub_session_route_actions_041wa62.js +0 -3
  658. package/.next/server/chunks/_next-internal_server_app_api_hub_stats_route_actions_1242~od.js +0 -3
  659. package/.next/server/chunks/_next-internal_server_app_api_hub_status_route_actions_0aml09a.js +0 -3
  660. package/.next/server/chunks/_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js +0 -3
  661. package/.next/server/chunks/_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js +0 -3
  662. package/.next/server/chunks/_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js +0 -3
  663. package/.next/server/chunks/_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js +0 -3
  664. package/.next/server/chunks/_next-internal_server_app_favicon_ico_route_actions_095lj93.js +0 -3
  665. package/.next/server/chunks/node_modules_next_11synfn._.js +0 -10
  666. package/.next/server/chunks/node_modules_next_11synfn._.js.map +0 -1
  667. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js +0 -3
  668. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js.map +0 -1
  669. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js +0 -3
  670. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js.map +0 -1
  671. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js +0 -3
  672. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js.map +0 -1
  673. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js +0 -3
  674. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js.map +0 -1
  675. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js +0 -3
  676. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js.map +0 -1
  677. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js +0 -3
  678. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js.map +0 -1
  679. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js +0 -33
  680. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js.map +0 -1
  681. package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
  682. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js +0 -3
  683. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js.map +0 -1
  684. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js +0 -19
  685. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js.map +0 -1
  686. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js +0 -3
  687. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js.map +0 -1
  688. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js +0 -3
  689. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js.map +0 -1
  690. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js +0 -3
  691. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js.map +0 -1
  692. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js +0 -3
  693. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js.map +0 -1
  694. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js +0 -3
  695. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js.map +0 -1
  696. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js +0 -3
  697. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js.map +0 -1
  698. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js +0 -3
  699. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js.map +0 -1
  700. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js +0 -3
  701. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js.map +0 -1
  702. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js +0 -3
  703. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js.map +0 -1
  704. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js +0 -3
  705. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js.map +0 -1
  706. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js +0 -3
  707. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js.map +0 -1
  708. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js +0 -3
  709. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js.map +0 -1
  710. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js +0 -3
  711. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js.map +0 -1
  712. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js +0 -8
  713. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js.map +0 -1
  714. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js +0 -3
  715. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js.map +0 -1
  716. package/.next/server/chunks/ssr/_0nne7mz._.js +0 -3
  717. package/.next/server/chunks/ssr/_0nne7mz._.js.map +0 -1
  718. package/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  719. package/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  720. package/.next/server/chunks/ssr/_next-internal_server_app_admin_page_actions_0ycnu8f.js +0 -3
  721. package/.next/server/chunks/ssr/_next-internal_server_app_login_page_actions_02kefem.js +0 -3
  722. package/.next/server/chunks/ssr/_next-internal_server_app_logs_page_actions_0rvsl~d.js +0 -3
  723. package/.next/server/chunks/ssr/_next-internal_server_app_messages_page_actions_0gsqep_.js +0 -3
  724. package/.next/server/chunks/ssr/_next-internal_server_app_node_page_actions_0wd8cgl.js +0 -3
  725. package/.next/server/chunks/ssr/_next-internal_server_app_nodes_page_actions_0-.rv67.js +0 -3
  726. package/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  727. package/.next/server/chunks/ssr/_next-internal_server_app_server-logs_page_actions_0g1gb5c.js +0 -3
  728. package/.next/server/chunks/ssr/_next-internal_server_app_settings_networks_page_actions_09uw.sw.js +0 -3
  729. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +0 -3
  730. package/.next/server/chunks/ssr/_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js +0 -3
  731. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js +0 -3
  732. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_page_actions_0_2o_cr.js +0 -3
  733. package/.next/server/chunks/ssr/app_0.zbtn5._.js +0 -9
  734. package/.next/server/chunks/ssr/app_0.zbtn5._.js.map +0 -1
  735. package/.next/server/chunks/ssr/app_01y-~mp._.js +0 -3
  736. package/.next/server/chunks/ssr/app_01y-~mp._.js.map +0 -1
  737. package/.next/server/chunks/ssr/app_04n78pr._.js +0 -3
  738. package/.next/server/chunks/ssr/app_04n78pr._.js.map +0 -1
  739. package/.next/server/chunks/ssr/app_0_agk0r._.js +0 -3
  740. package/.next/server/chunks/ssr/app_0_agk0r._.js.map +0 -1
  741. package/.next/server/chunks/ssr/app_0aenxef._.js +0 -3
  742. package/.next/server/chunks/ssr/app_0aenxef._.js.map +0 -1
  743. package/.next/server/chunks/ssr/app_0aum_g5._.js +0 -3
  744. package/.next/server/chunks/ssr/app_0aum_g5._.js.map +0 -1
  745. package/.next/server/chunks/ssr/app_0emvkn2._.js +0 -3
  746. package/.next/server/chunks/ssr/app_0emvkn2._.js.map +0 -1
  747. package/.next/server/chunks/ssr/app_0esuljp._.js +0 -3
  748. package/.next/server/chunks/ssr/app_0esuljp._.js.map +0 -1
  749. package/.next/server/chunks/ssr/app_0izi28j._.js +0 -3
  750. package/.next/server/chunks/ssr/app_0izi28j._.js.map +0 -1
  751. package/.next/server/chunks/ssr/app_components_08ys5y3._.js.map +0 -1
  752. package/.next/server/chunks/ssr/app_server-logs_page_tsx_0_kpp1p._.js.map +0 -1
  753. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js +0 -3
  754. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js.map +0 -1
  755. package/.next/server/chunks/ssr/node_modules_09w7yel._.js.map +0 -1
  756. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
  757. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
  758. package/.next/server/chunks/ssr/node_modules_0r_.s40._.js.map +0 -1
  759. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js +0 -3
  760. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js.map +0 -1
  761. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js +0 -6
  762. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js.map +0 -1
  763. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js +0 -6
  764. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
  765. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js +0 -3
  766. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js.map +0 -1
  767. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js +0 -3
  768. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js.map +0 -1
  769. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js +0 -3
  770. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js.map +0 -1
  771. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js +0 -3
  772. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js.map +0 -1
  773. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_002l7yi.js.map +0 -1
  774. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_02suzhc.js.map +0 -1
  775. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js.map +0 -1
  776. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js.map +0 -1
  777. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js.map +0 -1
  778. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0foejvu.js.map +0 -1
  779. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js.map +0 -1
  780. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js.map +0 -1
  781. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js.map +0 -1
  782. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js.map +0 -1
  783. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js.map +0 -1
  784. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js.map +0 -1
  785. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js +0 -4
  786. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js.map +0 -1
  787. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js +0 -4
  788. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js.map +0 -1
  789. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13s07li.js.map +0 -1
  790. package/.next/static/chunks/0-hlcuoemhe7w.js +0 -7
  791. package/.next/static/chunks/0257pdz1-imal.js +0 -2
  792. package/.next/static/chunks/02_zpo7taex9o.js +0 -7
  793. package/.next/static/chunks/06n3d~fbbtagk.js +0 -1
  794. package/.next/static/chunks/07eba1lz2u3p4.js +0 -1
  795. package/.next/static/chunks/09vpkiy8u1fd9.js +0 -1
  796. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  797. package/.next/static/chunks/0av2~0.96~d~g.js +0 -1
  798. package/.next/static/chunks/0cduea_g_p_y..js +0 -1
  799. package/.next/static/chunks/0e-.zgw81gela.js +0 -31
  800. package/.next/static/chunks/0g9aof0ob_fcj.js +0 -1
  801. package/.next/static/chunks/0j6t.m1a6._pk.js +0 -1
  802. package/.next/static/chunks/0rtpz4a-9r6ab.js +0 -1
  803. package/.next/static/chunks/0s8jo6jypzkx5.js +0 -1
  804. package/.next/static/chunks/0u359llqm1_01.js +0 -1
  805. package/.next/static/chunks/0ubqqhhpsaez4.js +0 -1
  806. package/.next/static/chunks/0~qw9kxksr1v2.js +0 -1
  807. package/.next/static/chunks/12gprw3l0kvvf.js +0 -1
  808. package/.next/static/chunks/12yuo353giomx.js +0 -1
  809. package/.next/static/chunks/14bsg3_-taiw4.js +0 -1
  810. /package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js.map → [root-of-the-server]__0tgc-8p._.js.map} +0 -0
  811. /package/.next/build/chunks/{[turbopack-node]_transforms_postcss_ts_06e.r3r._.js.map → [turbopack-node]_transforms_postcss_ts_0r4x5ys._.js.map} +0 -0
  812. /package/.next/server/chunks/{_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js.map → 0ci9_nt-network-dashboard__next-internal_server_app_api_auth_v3_route_actions_0f5nbzr.js.map} +0 -0
  813. /package/.next/server/chunks/{_next-internal_server_app_api_auth_login_route_actions_0zukc38.js.map → 0ci9_nt-network-dashboard__next-internal_server_app_favicon_ico_route_actions_0t0eu9r.js.map} +0 -0
  814. /package/.next/server/chunks/{_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js.map → 0ykm__next-internal_server_app_api_anet_config_route_actions_0a~v55_.js.map} +0 -0
  815. /package/.next/server/chunks/{_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js.map → 0ykm__next-internal_server_app_api_auth_login_route_actions_0xnxfs4.js.map} +0 -0
  816. /package/.next/server/chunks/{_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js.map → 0ykm__next-internal_server_app_api_auth_logout_route_actions_0p8p0rn.js.map} +0 -0
  817. /package/.next/server/chunks/{_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js.map → 0ykm__next-internal_server_app_api_hub_audit-log_route_actions_06uquwi.js.map} +0 -0
  818. /package/.next/server/chunks/{_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js.map → 0ykm__next-internal_server_app_api_hub_auth_route_actions_0f_y1ey.js.map} +0 -0
  819. /package/.next/server/chunks/{_next-internal_server_app_api_hub_events_route_actions_0opktby.js.map → 0ykm__next-internal_server_app_api_hub_broadcast_route_actions_0ao3j43.js.map} +0 -0
  820. /package/.next/server/chunks/{_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js.map → 0ykm__next-internal_server_app_api_hub_events_route_actions_0_5ndld.js.map} +0 -0
  821. /package/.next/server/chunks/{_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js.map → 0ykm__next-internal_server_app_api_hub_health_route_actions_0mpzl74.js.map} +0 -0
  822. /package/.next/server/chunks/{_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js.map → 0ykm__next-internal_server_app_api_hub_inbox_route_actions_0stoyto.js.map} +0 -0
  823. /package/.next/server/chunks/{_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js.map → 0ykm__next-internal_server_app_api_hub_license_route_actions_0a4.fuh.js.map} +0 -0
  824. /package/.next/server/chunks/{_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js.map → 0ykm__next-internal_server_app_api_hub_messages_route_actions_059rw1o.js.map} +0 -0
  825. /package/.next/server/chunks/{_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js.map → 0ykm__next-internal_server_app_api_hub_networks_route_actions_03sehpn.js.map} +0 -0
  826. /package/.next/server/chunks/{_next-internal_server_app_api_hub_register_route_actions_086wi_3.js.map → 0ykm__next-internal_server_app_api_hub_nodes_route_actions_11fbp28.js.map} +0 -0
  827. /package/.next/server/chunks/{_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js.map → 0ykm__next-internal_server_app_api_hub_register_route_actions_0l.d7~n.js.map} +0 -0
  828. /package/.next/server/chunks/{_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js.map → 0ykm__next-internal_server_app_api_hub_send_route_actions_0.p82vy.js.map} +0 -0
  829. /package/.next/server/chunks/{_next-internal_server_app_api_hub_session_route_actions_041wa62.js.map → 0ykm__next-internal_server_app_api_hub_server-logs_route_actions_05~c5lb.js.map} +0 -0
  830. /package/.next/server/chunks/{_next-internal_server_app_api_hub_stats_route_actions_1242~od.js.map → 0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map} +0 -0
  831. /package/.next/server/chunks/{_next-internal_server_app_api_hub_status_route_actions_0aml09a.js.map → 0ykm__next-internal_server_app_api_hub_session_route_actions_0b9ef2i.js.map} +0 -0
  832. /package/.next/server/chunks/{_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js.map → 0ykm__next-internal_server_app_api_hub_stats_route_actions_03u65u0.js.map} +0 -0
  833. /package/.next/server/chunks/{_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js.map → 0ykm__next-internal_server_app_api_hub_status_route_actions_0_71ear.js.map} +0 -0
  834. /package/.next/server/chunks/{_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js.map → 0ykm__next-internal_server_app_api_hub_task-events_route_actions_07md_ee.js.map} +0 -0
  835. /package/.next/server/chunks/{_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js.map → 0ykm__next-internal_server_app_api_hub_tasks_route_actions_0er3uvk.js.map} +0 -0
  836. /package/.next/server/chunks/{_next-internal_server_app_favicon_ico_route_actions_095lj93.js.map → 0ykm__next-internal_server_app_api_hub_tmux_route_actions_0o_1-8u.js.map} +0 -0
  837. /package/.next/server/chunks/{ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js.map → 0ykm__next-internal_server_app_api_hub_tokens_route_actions_09doluz.js.map} +0 -0
  838. /package/.next/server/chunks/ssr/{_next-internal_server_app__not-found_page_actions_0eq97pa.js.map → 0dwh_ent-network-dashboard__next-internal_server_app_server-logs_page_actions_0hkk815.js.map} +0 -0
  839. /package/.next/server/chunks/ssr/{_next-internal_server_app_admin_page_actions_0ycnu8f.js.map → 0yas_gent-network-dashboard__next-internal_server_app__not-found_page_actions_0u.f-rz.js.map} +0 -0
  840. /package/.next/server/chunks/ssr/{_next-internal_server_app_login_page_actions_02kefem.js.map → 0yas_gent-network-dashboard__next-internal_server_app_tasks_[id]_page_actions_0r3~lho.js.map} +0 -0
  841. /package/.next/server/chunks/ssr/{_next-internal_server_app_logs_page_actions_0rvsl~d.js.map → 0ykm__next-internal_server_app__global-error_page_actions_100fxls.js.map} +0 -0
  842. /package/.next/server/chunks/ssr/{_next-internal_server_app_messages_page_actions_0gsqep_.js.map → 0ykm__next-internal_server_app_settings_networks_page_actions_0jgy57m.js.map} +0 -0
  843. /package/.next/server/chunks/ssr/{_next-internal_server_app_node_page_actions_0wd8cgl.js.map → 0ykm__next-internal_server_app_settings_tokens_page_actions_0l5h.k~.js.map} +0 -0
  844. /package/.next/server/chunks/ssr/{_next-internal_server_app_nodes_page_actions_0-.rv67.js.map → agent-network-dashboard__next-internal_server_app_admin_page_actions_0i~.x~j.js.map} +0 -0
  845. /package/.next/server/chunks/ssr/{_next-internal_server_app_page_actions_09-gtaw.js.map → agent-network-dashboard__next-internal_server_app_login_page_actions_0frwl15.js.map} +0 -0
  846. /package/.next/server/chunks/ssr/{_next-internal_server_app_server-logs_page_actions_0g1gb5c.js.map → agent-network-dashboard__next-internal_server_app_logs_page_actions_0xa2ox_.js.map} +0 -0
  847. /package/.next/server/chunks/ssr/{_next-internal_server_app_settings_networks_page_actions_09uw.sw.js.map → agent-network-dashboard__next-internal_server_app_messages_page_actions_10j3tg4.js.map} +0 -0
  848. /package/.next/server/chunks/ssr/{_next-internal_server_app_settings_page_actions_0mr68ai.js.map → agent-network-dashboard__next-internal_server_app_node_page_actions_0sl4ntd.js.map} +0 -0
  849. /package/.next/server/chunks/ssr/{_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js.map → agent-network-dashboard__next-internal_server_app_nodes_page_actions_0cpgkfs.js.map} +0 -0
  850. /package/.next/server/chunks/ssr/{_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js.map → agent-network-dashboard__next-internal_server_app_page_actions_08nuo36.js.map} +0 -0
  851. /package/.next/server/chunks/ssr/{_next-internal_server_app_tasks_page_actions_0_2o_cr.js.map → agent-network-dashboard__next-internal_server_app_settings_page_actions_0iv.lmk.js.map} +0 -0
  852. /package/.next/static/{5BgbN6YWZJU6fk3Z0cE8n → gwmSWqAvfFzCuUlrBtcJ4}/_buildManifest.js +0 -0
  853. /package/.next/static/{5BgbN6YWZJU6fk3Z0cE8n → gwmSWqAvfFzCuUlrBtcJ4}/_clientMiddlewareManifest.js +0 -0
  854. /package/.next/static/{5BgbN6YWZJU6fk3Z0cE8n → gwmSWqAvfFzCuUlrBtcJ4}/_ssgManifest.js +0 -0
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Session } from './types';
5
5
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
6
  import { ChatPopover } from './ChatPopover';
7
7
  import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
8
+ import { parseHubTime, relativeAgo } from '../lib/time';
8
9
 
9
10
  interface MessageFlow {
10
11
  from_alias: string;
@@ -32,6 +33,9 @@ interface FlowLink {
32
33
  to: string;
33
34
  count: number;
34
35
  content: string;
36
+ /** ISO timestamp of the most recent message on this edge — drives
37
+ * the Round 10 freshness fade so dormant links recede visually. */
38
+ last_at: string;
35
39
  }
36
40
 
37
41
  const cx = 500;
@@ -85,36 +89,118 @@ function truncate(value: string, max: number) {
85
89
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
86
90
  }
87
91
 
92
+ /** Round 46 / Loop: SWR-freshness chip.
93
+ *
94
+ * TopoGraph's data refreshes every 5 s via SWR but the user has no
95
+ * visible signal that the canvas they're looking at is current. The
96
+ * flow particles and chips (R42 active-links, R43 hub) tell you when
97
+ * the FLEET last did something, not when the DATA last syncing. This
98
+ * chip ticks `live · Ns` against a 1-second interval so the operator
99
+ * can trust freshness without doing internal math.
100
+ *
101
+ * Owns its own setInterval so the parent topology doesn't re-render
102
+ * every second (only this small chip does). lastSyncRef is updated
103
+ * whenever the `sessions` prop reference changes — that's the SWR
104
+ * refresh signal. */
105
+ function FreshnessChip({ sessions }: { sessions: unknown }) {
106
+ const lastSyncRef = useRef<number>(Date.now());
107
+ const [now, setNow] = useState(() => Date.now());
108
+ useEffect(() => {
109
+ // sessions reference changed → SWR just delivered a new payload.
110
+ lastSyncRef.current = Date.now();
111
+ }, [sessions]);
112
+ useEffect(() => {
113
+ const id = setInterval(() => setNow(Date.now()), 1000);
114
+ return () => clearInterval(id);
115
+ }, []);
116
+ const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
117
+ // Tint the chip warmer when the data goes stale (>10s since last sync).
118
+ // SWR's default refreshInterval here is 5s, so anything past ~10s is
119
+ // either a poll miss or a network hiccup.
120
+ const stale = sec > 10;
121
+ return (
122
+ <span
123
+ className={stale
124
+ ? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
125
+ : "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
126
+ title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
127
+ data-freshness-chip
128
+ >
129
+ live · {sec}s
130
+ </span>
131
+ );
132
+ }
133
+
134
+ /** Round 36 / Loop: prefers-reduced-motion hook.
135
+ *
136
+ * Round 29's a11y sweep zeroed CSS animations via media query, but SVG
137
+ * SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
138
+ * — they need JS to opt out. This hook reads the media query and listens
139
+ * for changes so the topology's flow particles, pulses, click ripple
140
+ * and hub breath honour the OS-level preference. */
141
+ function useReducedMotion(): boolean {
142
+ const [reduced, setReduced] = useState(false);
143
+ useEffect(() => {
144
+ if (typeof window === 'undefined' || !window.matchMedia) return;
145
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
146
+ setReduced(mq.matches);
147
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
148
+ mq.addEventListener('change', onChange);
149
+ return () => mq.removeEventListener('change', onChange);
150
+ }, []);
151
+ return reduced;
152
+ }
153
+
154
+ // Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
155
+ // — the Round 35 fix lives there now alongside the Round 37 mirror so
156
+ // any future TZ-safe parse update happens in one place.
157
+
158
+ /** Round 12 / Loop: status trio audit.
159
+ *
160
+ * Each (status × theme) cell returns a {primary, halo, text} trio. The trio
161
+ * invariant — keep it when adding states or tweaking shades:
162
+ *
163
+ * light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
164
+ * dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
165
+ *
166
+ * * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
167
+ * slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
168
+ * halo would vanish. The other three rows keep the 100-shade.
169
+ *
170
+ * Audit caught one cross-family drift before this round: online-other halo
171
+ * light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
172
+ * (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
173
+ * difference; large hygiene win — every trio is now mono-hue. */
88
174
  function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
89
175
  if (!isOnline) {
90
176
  return {
91
177
  label: 'offline',
92
- primary: isLight ? '#94a3b8' : '#6b7280',
93
- halo: isLight ? '#e2e8f0' : '#111827',
94
- text: isLight ? '#475569' : '#9ca3af',
178
+ primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
179
+ halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
180
+ text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
95
181
  };
96
182
  }
97
183
  if (session.status === 'working') {
98
184
  return {
99
185
  label: 'working',
100
- primary: isLight ? '#059669' : '#22c55e',
101
- halo: isLight ? '#d1fae5' : '#14532d',
102
- text: isLight ? '#065f46' : '#dcfce7',
186
+ primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
187
+ halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
188
+ text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
103
189
  };
104
190
  }
105
191
  if (session.status === 'idle') {
106
192
  return {
107
193
  label: 'idle',
108
- primary: isLight ? '#0d9488' : '#2dd4bf',
109
- halo: isLight ? '#ccfbf1' : '#134e4a',
110
- text: isLight ? '#115e59' : '#ccfbf1',
194
+ primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
195
+ halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
196
+ text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
111
197
  };
112
198
  }
113
199
  return {
114
200
  label: session.status || 'online',
115
- primary: isLight ? '#0284c7' : '#38bdf8',
116
- halo: isLight ? '#dbeafe' : '#0c4a6e',
117
- text: isLight ? '#0c4a6e' : '#e0f2fe',
201
+ primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
202
+ halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
203
+ text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
118
204
  };
119
205
  }
120
206
 
@@ -240,22 +326,59 @@ function commonPrefix(a: string, b: string): string {
240
326
  return a.slice(0, i);
241
327
  }
242
328
 
243
- function computeGroupKeys(aliases: string[]): Record<string, string> {
244
- const sorted = [...aliases].sort((a, b) => a.localeCompare(b));
329
+ /** #83 + #111: group nodes that share a ≥2-char alias prefix OR a project_dir
330
+ * ("either criterion → same group", Vincent 4724). Union-find over the
331
+ * sessions; the component label prefers the shared project_dir's basename,
332
+ * else the common alias prefix. Returns alias → groupKey. */
333
+ function computeGroups(sessions: { alias: string; project_dir?: string | null }[]): Record<string, string> {
334
+ const n = sessions.length;
335
+ const parent = sessions.map((_, i) => i);
336
+ const find = (i: number): number => {
337
+ while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; }
338
+ return i;
339
+ };
340
+ const union = (a: number, b: number) => {
341
+ const ra = find(a), rb = find(b);
342
+ if (ra !== rb) parent[ra] = rb;
343
+ };
344
+
345
+ // union by shared project_dir
346
+ const byDir: Record<string, number[]> = {};
347
+ sessions.forEach((s, i) => {
348
+ const d = s.project_dir?.trim();
349
+ if (d) (byDir[d] ||= []).push(i);
350
+ });
351
+ for (const idxs of Object.values(byDir)) {
352
+ for (let k = 1; k < idxs.length; k++) union(idxs[0], idxs[k]);
353
+ }
354
+
355
+ // union by shared ≥2-char alias prefix — sort, link adjacent pairs
356
+ const order = sessions.map((_, i) => i).sort((a, b) => sessions[a].alias.localeCompare(sessions[b].alias));
357
+ for (let k = 0; k + 1 < order.length; k++) {
358
+ if (commonPrefix(sessions[order[k]].alias, sessions[order[k + 1]].alias).length >= 2) {
359
+ union(order[k], order[k + 1]);
360
+ }
361
+ }
362
+
363
+ // label each connected component
364
+ const comps: Record<number, number[]> = {};
365
+ for (let i = 0; i < n; i++) (comps[find(i)] ||= []).push(i);
245
366
  const keys: Record<string, string> = {};
246
- let i = 0;
247
- while (i < sorted.length) {
248
- let j = i;
249
- let prefix = sorted[i];
250
- while (j + 1 < sorted.length) {
251
- const lcp = commonPrefix(prefix, sorted[j + 1]);
252
- if (lcp.length < 2) break;
253
- prefix = lcp;
254
- j++;
367
+ for (const members of Object.values(comps)) {
368
+ let label: string;
369
+ if (members.length === 1) {
370
+ label = sessions[members[0]].alias;
371
+ } else {
372
+ const dirs = new Set(members.map(i => sessions[i].project_dir?.trim()).filter(Boolean) as string[]);
373
+ if (dirs.size === 1) {
374
+ const d = [...dirs][0];
375
+ label = d.split('/').filter(Boolean).pop() || d;
376
+ } else {
377
+ label = members.map(i => sessions[i].alias).reduce((a, b) => commonPrefix(a, b));
378
+ if (label.length < 2) label = sessions[members[0]].alias;
379
+ }
255
380
  }
256
- const key = j > i ? prefix : sorted[i];
257
- for (let k = i; k <= j; k++) keys[sorted[k]] = key;
258
- i = j + 1;
381
+ for (const i of members) keys[sessions[i].alias] = label;
259
382
  }
260
383
  return keys;
261
384
  }
@@ -275,12 +398,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
275
398
  const key = `${message.from_alias}->${message.to_alias}`;
276
399
  const current = links.get(key);
277
400
 
401
+ // Keep the most-recent timestamp per pair so the render can fade
402
+ // dormant edges (Round 10 freshness fade).
403
+ const incoming = message.created_at || '';
404
+ const last_at = !current
405
+ ? incoming
406
+ : (incoming > current.last_at ? incoming : current.last_at);
407
+
278
408
  links.set(key, {
279
409
  key,
280
410
  from: message.from_alias,
281
411
  to: message.to_alias,
282
412
  count: (current?.count || 0) + 1,
283
413
  content: current?.content || message.content,
414
+ last_at,
284
415
  });
285
416
  });
286
417
 
@@ -290,6 +421,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
290
421
  export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
291
422
  const theme = useTheme();
292
423
  const isLight = theme === 'light';
424
+ const reducedMotion = useReducedMotion();
293
425
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
294
426
  const brand = useBrand();
295
427
  const isIntern = brand === 'intern';
@@ -310,6 +442,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
310
442
  try { localStorage.setItem('anet-topo-layout', next); } catch {}
311
443
  return next;
312
444
  });
445
+ // Issue #113: node size scale (Vincent 4727 — nodes too big / crowded at
446
+ // ~22 nodes). S/M/L → 0.7/0.84/1.0; default M (one notch down from the old
447
+ // fixed size). Persisted like the layout toggle.
448
+ const [nodeScale, setNodeScale] = useState(0.84);
449
+ useEffect(() => {
450
+ try {
451
+ const saved = parseFloat(localStorage.getItem('anet-topo-nodescale') || '');
452
+ if (saved === 0.7 || saved === 0.84 || saved === 1) setNodeScale(saved);
453
+ } catch {}
454
+ }, []);
455
+ const pickNodeScale = (v: number) => {
456
+ setNodeScale(v);
457
+ try { localStorage.setItem('anet-topo-nodescale', String(v)); } catch {}
458
+ };
313
459
 
314
460
  useEffect(() => {
315
461
  const fetchMessages = async () => {
@@ -336,6 +482,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
336
482
  flowLinks,
337
483
  activeAliases,
338
484
  groupKeys,
485
+ groupBoxes,
486
+ gridContentBottom,
339
487
  } = useMemo(() => {
340
488
  const sseCount = (s: { alias: string; network_id?: string }) =>
341
489
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -345,36 +493,151 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
345
493
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
346
494
  // each team visually clusters. localeCompare keeps CJK ordering sane.
347
495
  const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
496
+ // #112 umbrella: ghost age-out — an offline node not seen recently is
497
+ // almost certainly a deleted node the server `/api/status` still
498
+ // returns (#74 root cause is server-side; this is the dashboard-side
499
+ // fallback so stale ghosts stop cluttering the topology). A missing
500
+ // last_seen_at is kept (conservative — could be a legitimately new node).
501
+ // Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
502
+ // screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
503
+ // healthy agent heartbeats every few seconds; if it's been silent for
504
+ // an hour it's effectively gone. 1 h gives a fresh disconnect time to
505
+ // come back while removing dead nodes well within an operator session.
506
+ const GHOST_MS = 60 * 60 * 1000;
507
+ const now = Date.now();
508
+ const isGhost = (s: Session) => {
509
+ if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
510
+ // Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
511
+ // before parsing so non-UTC browsers don't see a phantom 8-hour skew
512
+ // and ghost freshly-disconnected nodes.
513
+ const t = parseHubTime(s.last_seen_at);
514
+ return t !== null && now - t > GHOST_MS;
515
+ };
348
516
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
349
- const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s)).sort(byAlias);
517
+ const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
350
518
  const positions: Record<string, Point> = {};
351
519
 
352
520
  if (layout === 'grid') {
353
- // Issue #87: N×M grid layout. All nodes share one sorted array (online
354
- // first, then offline) so same-prefix aliases land in adjacent cells
355
- // (#83 prefix-grouping synergy). cols = ⌈√N⌉; the last partial row is
356
- // centred. Grid spans an inset box so node labels don't clip the edge.
521
+ // Issue #87 + #111: group-banded grid. Nodes are sorted by alias so
522
+ // same-prefix aliases are adjacent; each multi-member prefix group then
523
+ // gets its OWN row(s) starting at column 0, while singletons pack into
524
+ // shared rows. Group-banded placement keeps every group's bounding box
525
+ // (#111) a clean rectangle — no box ever overlaps another group's.
357
526
  const all = [...online, ...offline];
358
- const n = all.length;
359
- const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
360
- const rows = Math.max(1, Math.ceil(n / cols));
361
- const gx0 = 150, gx1 = 850, gy0 = 120, gy1 = 560;
527
+ const groupKeys = computeGroups(all);
528
+ const cols = Math.max(1, Math.ceil(Math.sqrt(all.length)));
529
+ // #112: gy0 starts below the (now compact) recent-signal / legend
530
+ // overlay panels — their max bottom edge is y≈112 — so grid row 0 is
531
+ // never occluded; gy1 extends near the canvas bottom for breathing room.
532
+ const gx0 = 150, gx1 = 850, gy0 = 126, gy1 = 652;
362
533
  const cellW = (gx1 - gx0) / cols;
363
- const cellH = (gy1 - gy0) / rows;
364
- all.forEach((s, i) => {
365
- const row = Math.floor(i / cols);
366
- const col = i % cols;
367
- const rowCount = row === rows - 1 ? n - row * cols : cols;
368
- const rowInset = ((cols - rowCount) * cellW) / 2;
369
- positions[s.alias] = {
370
- x: gx0 + rowInset + (col + 0.5) * cellW,
371
- y: gy0 + (row + 0.5) * cellH,
372
- };
373
- });
534
+ // largest node radius at the current size scale — drives the group
535
+ // label band + the row-height floor so nothing ever overlaps.
536
+ const nodeR = Math.round(26 * nodeScale);
537
+
538
+ // ordered runs of consecutive same-group-key nodes (≥2 = real group)
539
+ const runs: { key: string; members: Session[] }[] = [];
540
+ for (const s of all) {
541
+ const gk = groupKeys[s.alias];
542
+ const last = runs[runs.length - 1];
543
+ if (last && last.key === gk) last.members.push(s);
544
+ else runs.push({ key: gk, members: [s] });
545
+ }
546
+
547
+ // Pass 1 — assign each run to a band: a multi-member group owns its
548
+ // rows (left-aligned, so its bounding box is a tidy rect); contiguous
549
+ // singletons pack into shared rows (centred). Collect total row count.
550
+ type Band = { members: Session[]; startRow: number; centred: boolean; isGroup: boolean };
551
+ const bands: Band[] = [];
552
+ let row = 0;
553
+ let i = 0;
554
+ while (i < runs.length) {
555
+ if (runs[i].members.length >= 2) {
556
+ bands.push({ members: runs[i].members, startRow: row, centred: false, isGroup: true });
557
+ row += Math.ceil(runs[i].members.length / cols);
558
+ i++;
559
+ } else {
560
+ const singles: Session[] = [];
561
+ while (i < runs.length && runs[i].members.length < 2) {
562
+ singles.push(runs[i].members[0]);
563
+ i++;
564
+ }
565
+ bands.push({ members: singles, startRow: row, centred: true, isGroup: false });
566
+ row += Math.ceil(singles.length / cols);
567
+ }
568
+ }
569
+ const totalRows = Math.max(1, row);
570
+ // #112: the group label sits in a band ABOVE the topmost node, so the
571
+ // band must clear the node radius — GROUP_TOP is node-relative, never
572
+ // cellH-derived (cellH-derived was the label↔node overlap bug Vincent
573
+ // hit). cellH then floors at GROUP_TOP + 30 so the label band + bottom
574
+ // padding always fit and stacked group boxes never touch; past the
575
+ // floor the grid overflows and zoom/pan handles it.
576
+ const GROUP_TOP = nodeR + 20;
577
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
578
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
579
+ // stroke halo for readability) at the BOTTOM row of band N would
580
+ // paint OVER the start of band N+1's group label, creating the
581
+ // "blueleap → eleap", "agent-network-dashboard → t-network / board"
582
+ // visual chopping. Geometry of the collision:
583
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
584
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
585
+ // = node_N+1.y - GROUP_TOP + 4
586
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
587
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
588
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
589
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
590
+ const cellH = Math.max(
591
+ 2 * nodeR + 22, // node + dense label within band
592
+ GROUP_TOP + 12, // group-label band + box padding
593
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
594
+ Math.min(100, (gy1 - gy0) / totalRows),
595
+ );
596
+
597
+ // Pass 2 — place each band's members.
598
+ for (const band of bands) {
599
+ band.members.forEach((s, idx) => {
600
+ const rowInBand = Math.floor(idx / cols);
601
+ const c = idx % cols;
602
+ const inRow = Math.min(cols, band.members.length - rowInBand * cols);
603
+ const inset = band.centred ? ((cols - inRow) * cellW) / 2 : 0;
604
+ positions[s.alias] = {
605
+ x: gx0 + inset + (c + 0.5) * cellW,
606
+ y: gy0 + (band.startRow + rowInBand + 0.5) * cellH,
607
+ };
608
+ });
609
+ }
610
+
374
611
  const links = buildFlowLinks(messages, positions);
375
612
  const active = new Set<string>();
376
613
  links.forEach(link => { active.add(link.from); active.add(link.to); });
377
- const groupKeys = computeGroupKeys([...online, ...offline].map(s => s.alias));
614
+ // #111: one bounding box per multi-member group (Vincent 4722). Each
615
+ // group owns its rows; GROUP_PAD fills the row space left below the
616
+ // nodes after the label band, so stacked group boxes always have a
617
+ // gap between them (GROUP_TOP is defined above, with cellH).
618
+ const GROUP_PAD = Math.max(8, Math.min(26, cellH - GROUP_TOP - 8)); // side/bottom
619
+ const groupBoxes = bands
620
+ .filter(b => b.isGroup)
621
+ .map(band => {
622
+ const pts = band.members.map(s => positions[s.alias]).filter(Boolean);
623
+ const xs = pts.map(p => p.x);
624
+ const ys = pts.map(p => p.y);
625
+ const minX = Math.min(...xs), minY = Math.min(...ys);
626
+ return {
627
+ key: band.members.length ? groupKeys[band.members[0].alias] : '',
628
+ count: band.members.length,
629
+ x: minX - GROUP_PAD,
630
+ y: minY - GROUP_TOP,
631
+ w: Math.max(...xs) - minX + GROUP_PAD * 2,
632
+ h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
633
+ };
634
+ });
635
+ // Round 28 / Loop: surface the grid's natural content bottom so the
636
+ // mount effect can auto-fit zoom when the layout would overflow the
637
+ // viewBox. = bottom of the last node row + its label drop + a small
638
+ // breathing buffer. Round 27's cellH bump makes this overflow more
639
+ // common (30-node fleets reach ~774 px, viewBox is 680).
640
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
378
641
  return {
379
642
  onlineNodes: online,
380
643
  offlineNodes: offline,
@@ -382,6 +645,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
382
645
  flowLinks: links,
383
646
  activeAliases: active,
384
647
  groupKeys,
648
+ groupBoxes,
649
+ gridContentBottom,
385
650
  };
386
651
  }
387
652
 
@@ -452,7 +717,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
452
717
  });
453
718
 
454
719
  // Round 106 (issue #83): group key per alias → shared hue per team.
455
- const groupKeys = computeGroupKeys([...online, ...offline].map(s => s.alias));
720
+ const groupKeys = computeGroups([...online, ...offline]);
456
721
 
457
722
  return {
458
723
  onlineNodes: online,
@@ -461,15 +726,50 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
461
726
  flowLinks: links,
462
727
  activeAliases: active,
463
728
  groupKeys,
729
+ // #111: group boxes are a grid-layout feature only — radially scattered
730
+ // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
731
+ groupBoxes: [] as { key: string; count: number; x: number; y: number; w: number; h: number }[],
732
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
733
+ gridContentBottom: 0,
464
734
  };
465
- }, [messages, sessions, sseSessions, layout]);
735
+ }, [messages, sessions, sseSessions, layout, nodeScale]);
466
736
 
467
737
  const workingCount = onlineNodes.filter(s => s.status === 'working').length;
738
+ // Round 6 / Loop: vendor distribution for the header chip — at a glance
739
+ // "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
740
+ // Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
741
+ const vendorDist = useMemo(() => {
742
+ const tally = new Map<string, { initial: string; count: number; color: string }>();
743
+ for (const s of [...onlineNodes, ...offlineNodes]) {
744
+ const v = vendorForModel(s.model);
745
+ const key = v.id === 'unknown' ? '?' : v.initial;
746
+ const cur = tally.get(key);
747
+ if (cur) cur.count++;
748
+ else tally.set(key, { initial: key, count: 1, color: v.mono.text });
749
+ }
750
+ return [...tally.values()].sort((a, b) => b.count - a.count);
751
+ }, [onlineNodes, offlineNodes]);
468
752
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
469
753
  // so dense fleets show clean avatars instead of a wall of overlapping
470
754
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
471
755
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
472
756
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
757
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
758
+ // group + other group boxes fade so the eye locks onto the team you're
759
+ // pointing at. Singletons use their own alias as the group key.
760
+ const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
761
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
762
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
763
+ // serve as a state trigger. When the user hovers a flow edge, light up its
764
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
765
+ // visible without reading the tooltip. The set is the link's two aliases
766
+ // (null when no edge hovered); node opacity composes this after inFocus.
767
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
768
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
769
+ if (!hoveredEdgeKey) return null;
770
+ const link = flowLinks.find(l => l.key === hoveredEdgeKey);
771
+ return link ? new Set([link.from, link.to]) : null;
772
+ }, [hoveredEdgeKey, flowLinks]);
473
773
 
474
774
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
475
775
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -489,6 +789,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
489
789
  // Issue #100: singleton chat popover. One alias at a time — clicking
490
790
  // another node swaps the target and the conversation switches in place.
491
791
  const [chatAlias, setChatAlias] = useState<string | null>(null);
792
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
793
+ // node so the click registers physically before the popover snaps in.
794
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
795
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
796
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
797
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
798
+ const [clickRipple, setClickRipple] = useState<{
799
+ ts: number; x: number; y: number; r0: number; color: string;
800
+ } | null>(null);
492
801
  // #84: when a node is renamed while its chat popover is open, follow the
493
802
  // rename so the conversation keeps targeting a live alias.
494
803
  useEffect(() => {
@@ -519,6 +828,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
519
828
  } catch {}
520
829
  }, []);
521
830
 
831
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
832
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
833
+ // 100% zoom — a new user sees the topology with its bottom rows
834
+ // clipped and no hint that there's more below. Auto-fit on first
835
+ // paint (and only if the user has no persisted view) sets the
836
+ // initial zoom so all content fits. Subsequent zoom changes persist
837
+ // and override the auto-fit on reload; explicit reset (0 key) still
838
+ // goes back to 100% so the gesture's "reset to natural size" semantic
839
+ // is preserved.
840
+ //
841
+ // Capture pre-mount persistence in a useState initializer — the
842
+ // existing `persist` effect (declared below) runs on first render
843
+ // with the default {1,0,0} view and writes it to localStorage before
844
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
845
+ // Reading localStorage from the effect would see that write and
846
+ // skip the fit. The useState snapshot fires once, before any effects.
847
+ const [hadPersistedViewOnMount] = useState<boolean>(
848
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
849
+ );
850
+ const autoFitDoneRef = useRef(false);
851
+ useEffect(() => {
852
+ if (autoFitDoneRef.current) return;
853
+ if (hadPersistedViewOnMount) {
854
+ autoFitDoneRef.current = true;
855
+ return;
856
+ }
857
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
858
+ if (gridContentBottom <= VIEWBOX_H) {
859
+ autoFitDoneRef.current = true; // no overflow → no fit needed
860
+ return;
861
+ }
862
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
863
+ setView({ zoom: fitZoom, x: 0, y: 0 });
864
+ autoFitDoneRef.current = true;
865
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
866
+
522
867
  // persist view
523
868
  useEffect(() => {
524
869
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -538,6 +883,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
538
883
  const svg = svgRef.current;
539
884
  if (!svg) return;
540
885
  const onWheel = (e: WheelEvent) => {
886
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
887
+ // wheel over the canvas keeps scrolling the page (the topo sits at
888
+ // the top of /; trapping page scroll mid-page is the classic
889
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
890
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
891
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
892
+ // also what we want (zoom the canvas, not the browser).
893
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
541
894
  e.preventDefault();
542
895
  const rect = svg.getBoundingClientRect();
543
896
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -558,8 +911,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
558
911
  };
559
912
  svg.addEventListener('wheel', onWheel, { passive: false });
560
913
  return () => svg.removeEventListener('wheel', onWheel);
561
- }, []);
562
-
914
+ // Re-attach when the fullscreen flag toggles so the handler's
915
+ // closure picks up the new value (capture-by-closure means we'd
916
+ // otherwise read the stale boolean forever).
917
+ }, [isFullscreen]);
918
+
919
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
920
+ // that the canvas was actually being dragged — "grabbing" while
921
+ // dragRef.current.active = true tells the hand it's moving the
922
+ // viewport. Mirroring dragRef.active into state is the only way to
923
+ // re-render the SVG style; the ref itself doesn't trigger renders.
924
+ const [isPanning, setIsPanning] = useState(false);
563
925
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
564
926
  if (e.button !== 0) return;
565
927
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -570,6 +932,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
570
932
  baseX: viewRef.current.x,
571
933
  baseY: viewRef.current.y,
572
934
  };
935
+ setIsPanning(true);
573
936
  };
574
937
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
575
938
  const d = dragRef.current;
@@ -584,6 +947,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
584
947
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
585
948
  if (!dragRef.current.active) return;
586
949
  dragRef.current.active = false;
950
+ setIsPanning(false);
587
951
  try {
588
952
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
589
953
  } catch {}
@@ -601,6 +965,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
601
965
  };
602
966
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
603
967
 
968
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
969
+ // first-paint auto-fit effect and the keyboard handler so the math is
970
+ // in one place. When content already fits at natural zoom, this is
971
+ // effectively a "recenter" — `f` always lands on a known good view.
972
+ const fitView = useCallback(() => {
973
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
974
+ ? 1
975
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
976
+ setView({ zoom, x: 0, y: 0 });
977
+ }, [gridContentBottom]);
978
+
979
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
980
+ // Round 29 / Loop: +f to fit content.
981
+ // Listen on window so the user doesn't need to focus the SVG first,
982
+ // but only act when no text input has focus (otherwise typing "-" in
983
+ // a chat would zoom the topology — surprising and bad). Modifier keys
984
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
985
+ // titles below document these shortcuts so users discover them.
986
+ useEffect(() => {
987
+ const onKey = (e: KeyboardEvent) => {
988
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
989
+ const ae = document.activeElement as HTMLElement | null;
990
+ if (ae) {
991
+ const tag = ae.tagName;
992
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
993
+ }
994
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
995
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
996
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
997
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
998
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
999
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1000
+ // outside that window is free for topology use.
1001
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1002
+ };
1003
+ window.addEventListener('keydown', onKey);
1004
+ return () => window.removeEventListener('keydown', onKey);
1005
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1006
+ // with gridContentBottom so deps list catches it.
1007
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1008
+ }, [fitView]);
1009
+
604
1010
  const toggleFullscreen = () => {
605
1011
  const el = containerRef.current;
606
1012
  if (!el) return;
@@ -628,6 +1034,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
628
1034
  <button
629
1035
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
630
1036
  aria-pressed={layout === 'ring'}
1037
+ title="Ring layout (l to toggle)"
631
1038
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
632
1039
  >
633
1040
  Ring
@@ -635,6 +1042,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
635
1042
  <button
636
1043
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
637
1044
  aria-pressed={layout === 'grid'}
1045
+ title="Grid layout (l to toggle)"
638
1046
  className={`px-2.5 py-1 border-l border-gray-500/25 transition-colors ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
639
1047
  >
640
1048
  Grid
@@ -646,9 +1054,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
646
1054
  <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
647
1055
  {onlineNodes.length} online
648
1056
  </span>
649
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
650
- {flowLinks.length} active links
651
- </span>
1057
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1058
+ Y online" chips already carry the raw counts; the bar lets
1059
+ the eye get the working/idle/offline RATIO in one glance
1060
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1061
+ {(() => {
1062
+ const w = workingCount;
1063
+ const i = onlineNodes.length - workingCount; // idle = online - working
1064
+ const o = offlineNodes.length;
1065
+ const total = w + i + o;
1066
+ if (total === 0) return null;
1067
+ const seg = (n: number, color: string, key: string) => {
1068
+ if (n === 0) return null;
1069
+ return <span key={key} style={{ width: `${(n / total) * 100}%`, background: color, height: '100%' }} />;
1070
+ };
1071
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1072
+ // wraps to multiple lines and crowds the topology header;
1073
+ // pressure ratio is best read with the working+online raw
1074
+ // counts (kept visible) anyway.
1075
+ return (
1076
+ <span
1077
+ className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
1078
+ title={`${w} working · ${i} idle · ${o} offline`}
1079
+ data-fleet-pressure
1080
+ >
1081
+ <span className="text-[10px] tracking-wide">pressure</span>
1082
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1083
+ {seg(w, isLight ? '#059669' : '#22c55e', 'w')}
1084
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'i')}
1085
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'o')}
1086
+ </span>
1087
+ </span>
1088
+ );
1089
+ })()}
1090
+ {vendorDist.length > 1 && (
1091
+ <span
1092
+ className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
1093
+ title="Fleet by model vendor"
1094
+ >
1095
+ {vendorDist.map(v => (
1096
+ <span key={v.initial} className="inline-flex items-baseline gap-0.5">
1097
+ <span style={{ color: v.color }}>{v.initial}</span>
1098
+ <span className="text-gray-500">:{v.count}</span>
1099
+ </span>
1100
+ ))}
1101
+ </span>
1102
+ )}
1103
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1104
+ of the most-recent flow event. Tells the operator at a glance
1105
+ whether the topology is currently humming (last 30s) or has
1106
+ been quiet for a while — the visual flow particles and
1107
+ edge brightness only show that there IS traffic, not when
1108
+ the last one was. Reuses Round 38's relativeAgo. */}
1109
+ {(() => {
1110
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1111
+ if (!l.last_at) return acc;
1112
+ const t = parseHubTime(l.last_at);
1113
+ if (t === null) return acc;
1114
+ return acc === null || t > acc ? t : acc;
1115
+ }, null);
1116
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1117
+ return (
1118
+ <span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
1119
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1120
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1121
+ </span>
1122
+ );
1123
+ })()}
1124
+ <FreshnessChip sessions={sessions} />
652
1125
  </div>
653
1126
  </div>
654
1127
 
@@ -668,7 +1141,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
668
1141
  onPointerMove={onPointerMove}
669
1142
  onPointerUp={onPointerUp}
670
1143
  onPointerLeave={onPointerUp}
671
- style={{ cursor: 'grab', touchAction: 'none' }}
1144
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1145
+ // overlay (R25) both advertise "double-click the canvas to
1146
+ // reset", but the handler was never actually wired. The text
1147
+ // was lying. Wire it here, guarded so dbl-clicking a node
1148
+ // (which would also trigger the SVG-level handler via event
1149
+ // bubbling) doesn't unexpectedly reset the view on the user.
1150
+ onDoubleClick={(e) => {
1151
+ const t = e.target as Element | null;
1152
+ if (t?.closest('g[data-node]')) return;
1153
+ resetView();
1154
+ }}
1155
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
672
1156
  >
673
1157
  <defs>
674
1158
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -690,9 +1174,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
690
1174
  </feMerge>
691
1175
  </filter>
692
1176
  )}
693
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
694
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
695
- </marker>
1177
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1178
+ The single marker had `markerUnits` defaulting to
1179
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1180
+ unit arrowheads — visually dominant, the head outweighed
1181
+ the line. Switching to `userSpaceOnUse` decouples arrow
1182
+ size from stroke; binning by count gives a clearer
1183
+ hierarchy than continuous linear scaling:
1184
+ s (count 1-2) → 12 user units
1185
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1186
+ l (count 5+) → 22 user units
1187
+ `topo-arrow` stays bound to the medium tier so the legend
1188
+ swatch (line ~1500) renders without change. */}
1189
+ {[
1190
+ { id: 'topo-arrow-s', size: 12 },
1191
+ { id: 'topo-arrow', size: 16 },
1192
+ { id: 'topo-arrow-l', size: 22 },
1193
+ ].map(m => (
1194
+ <marker
1195
+ key={m.id}
1196
+ id={m.id}
1197
+ viewBox="0 0 10 10"
1198
+ refX="8"
1199
+ refY="5"
1200
+ markerWidth={m.size}
1201
+ markerHeight={m.size}
1202
+ markerUnits="userSpaceOnUse"
1203
+ orient="auto-start-reverse"
1204
+ >
1205
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1206
+ </marker>
1207
+ ))}
696
1208
  {/* Round 45: radial radar sweep gradient — bright at center
697
1209
  (where it meets the hub) fading to leading edge. */}
698
1210
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -712,13 +1224,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
712
1224
  grid mode drops it so the concentric rings don't sit behind a
713
1225
  rectangular grid. */}
714
1226
  {layout === 'ring' && (<>
715
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1227
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1228
+ the hub <g> under it (and any future inner-disk affordances)
1229
+ can receive clicks. Previously the r=330 disc intercepted
1230
+ the hub click outright. */}
1231
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
716
1232
 
717
1233
  {/* Round 45: subtle star field — 24 deterministic dots scattered
718
1234
  across the canvas give the radar bg some depth. Skipped on
719
1235
  light theme so the white surface stays clean. */}
720
1236
  {!isLight && (
721
- <g opacity="0.5">
1237
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
722
1238
  {Array.from({ length: 28 }).map((_, i) => {
723
1239
  // Deterministic pseudo-random scatter so positions are
724
1240
  // stable between renders (no JS hydration mismatch).
@@ -740,6 +1256,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
740
1256
  style={{
741
1257
  transformOrigin: `${cx}px ${cy}px`,
742
1258
  transformBox: 'view-box',
1259
+ pointerEvents: 'none',
743
1260
  }}
744
1261
  className="anet-topo-sweep"
745
1262
  opacity={isLight ? 0.7 : 1}
@@ -820,27 +1337,173 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
820
1337
  );
821
1338
  })}
822
1339
 
1340
+ {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
1341
+ only — groupBoxes is empty in ring mode. Rendered behind the
1342
+ flow links + nodes; pointer-events off so they never intercept
1343
+ a node click. Restrained dashed container + group-name label. */}
1344
+ {groupBoxes.map(box => {
1345
+ const isHovered = hoveredGroup === box.key;
1346
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1347
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1348
+ // group sat at its baseline appearance — no positive emphasis.
1349
+ // Hovering now upgrades the box to an "accent" treatment:
1350
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1351
+ // text and slightly stronger fill. Label and box read as one
1352
+ // selected unit. Geometry unchanged → overlap test untouched.
1353
+ return (
1354
+ <g
1355
+ key={`grp-${box.key}`}
1356
+ data-group={box.key}
1357
+ className="transition-opacity"
1358
+ style={{ pointerEvents: 'none', opacity: !hoveredGroup || isHovered ? 1 : 0.28 }}
1359
+ >
1360
+ <rect
1361
+ x={box.x}
1362
+ y={box.y}
1363
+ width={box.w}
1364
+ height={box.h}
1365
+ rx="14"
1366
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1367
+ fillOpacity={isHovered ? (isLight ? 0.05 : 0.09) : (isLight ? 0.025 : 0.045)}
1368
+ stroke={isHovered ? pal.legendAccent : pal.ringStroke}
1369
+ strokeWidth={isHovered ? 2 : 1.5}
1370
+ strokeDasharray={isHovered ? 'none' : '6 6'}
1371
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out' }}
1372
+ />
1373
+ <text
1374
+ x={box.x + 12}
1375
+ y={box.y + 14}
1376
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1377
+ fontSize="13"
1378
+ fontFamily="monospace"
1379
+ fontWeight="700"
1380
+ style={{ transition: 'fill 200ms ease-out' }}
1381
+ >
1382
+ {box.key}
1383
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1384
+ in the single <text> bbox the overlap test reads, so
1385
+ the node↔label guard still catches if the chip ever
1386
+ pushes the label far enough right to clip a node.
1387
+ Smaller + lighter weight reads as metadata, not name. */}
1388
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1389
+ </text>
1390
+ </g>
1391
+ );
1392
+ })}
1393
+
823
1394
  {/* directed message flows */}
824
1395
  {flowLinks.map((link, index) => {
825
1396
  const from = nodePositions[link.from];
826
1397
  const to = nodePositions[link.to];
827
1398
  if (!from || !to) return null;
828
1399
 
829
- const lift = index % 2 === 0 ? 36 : -36;
1400
+ // Round 7 / Loop: lift now scales with distance so short links
1401
+ // aren't over-bent (long links keep the ~36px hump), and the
1402
+ // particle period shortens with link.count so busier edges
1403
+ // visibly pulse faster — instant info density on top of the
1404
+ // existing stroke-width-by-count chip.
1405
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1406
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
830
1407
  const path = curvePath(from, to, lift);
831
1408
  const width = Math.min(2 + link.count, 7);
832
- const duration = 2.2 + (index % 5) * 0.32;
833
-
1409
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1410
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1411
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1412
+ // Surfaces "what's happening now" vs background chatter without
1413
+ // hiding old flow entirely (some context still useful). `now`
1414
+ // captured at useMemo-recompute time (every 5s message refresh)
1415
+ // — accuracy is within the poll interval, plenty.
1416
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1417
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1418
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1419
+ // medium tier id so the legend swatch picks it up unchanged.
1420
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1421
+ : link.count <= 4 ? 'topo-arrow'
1422
+ : 'topo-arrow-l';
1423
+
1424
+ // Round 39 / Loop: edge hover tooltip — surface the same
1425
+ // last_at + count info the freshness fade and arrow tier
1426
+ // already encode visually, in plain text. The stroke is the
1427
+ // hover target; SVG `<title>` honours newlines on every
1428
+ // browser the dashboard targets.
1429
+ const lastAt = relativeAgo(link.last_at);
1430
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1431
+ // Round 40 / Loop: edges follow node hover — when an alias is
1432
+ // hovered, every edge touching it brightens, the rest fade.
1433
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1434
+ // node to find "who is this agent talking to" at a glance.
1435
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1436
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
1437
+ // a flow edge directly (R48 widened the hitbox so this is now
1438
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
1439
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
1440
+ // its own ladder. Edge-hover and node-hover are mutually
1441
+ // exclusive in practice — the cursor is over one or the
1442
+ // other — but the order below makes edge-hover win if both
1443
+ // ever read truthy at the same React tick.
1444
+ // Round 53 / Loop: in-group edges follow team focus. R40
1445
+ // brightened only edges touching the exact hovered alias —
1446
+ // but with R8/R18 prefix-clustering, a user hovering one
1447
+ // member is asking about the team. So when BOTH endpoints of
1448
+ // an edge share the hoveredGroup (and neither is the exact
1449
+ // hovered alias — that gets the stronger 1.7×), the edge
1450
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
1451
+ // one out) still dim to 0.35× per R40 since they read as
1452
+ // background to the focus. Singletons fall through unchanged
1453
+ // (their group key is the alias itself, so bothInHoveredGroup
1454
+ // is impossible for a non-self-edge).
1455
+ const isHoveredEdge = hoveredEdgeKey === link.key;
1456
+ const fromGroup = groupKeys[link.from] ?? link.from;
1457
+ const toGroup = groupKeys[link.to] ?? link.to;
1458
+ const bothInHoveredGroup = !!hoveredGroup && fromGroup === hoveredGroup && toGroup === hoveredGroup;
1459
+ const edgeOpacityMul = isHoveredEdge
1460
+ ? 2.0
1461
+ : hoveredEdgeKey
1462
+ ? 0.35
1463
+ : !hoveredAlias
1464
+ ? 1
1465
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1466
+ ? 1.7
1467
+ : bothInHoveredGroup
1468
+ ? 1.3
1469
+ : 0.35;
1470
+ // Round 50: the hovered edge also visibly thickens so the eye
1471
+ // tracks it even at low message counts (width was 3 → 4.5 on
1472
+ // hover). 1.4× is enough to read as "lifted" without
1473
+ // breaching the 16-px hitbox bound.
1474
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
834
1475
  return (
835
1476
  <g key={link.key}>
1477
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
1478
+ path is 3-7 px wide and damn hard to hover precisely.
1479
+ Stack a transparent 16-px-wide stroke behind it so the
1480
+ cursor only needs to be ~8 px from the line for the
1481
+ tooltip to fire. Native <title> moves here; the
1482
+ visible path no longer needs it. pointer-events on
1483
+ the visible path drop to "none" since the hitbox
1484
+ owns the hover surface. */}
1485
+ <path
1486
+ d={path}
1487
+ fill="none"
1488
+ stroke="transparent"
1489
+ strokeWidth={Math.max(width + 10, 16)}
1490
+ style={{ pointerEvents: 'stroke' }}
1491
+ data-edge-hitbox
1492
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
1493
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
1494
+ >
1495
+ <title>{tooltip}</title>
1496
+ </path>
836
1497
  <path
837
1498
  d={path}
838
1499
  fill="none"
839
1500
  stroke={pal.flowEdge}
840
- strokeWidth={width}
841
- opacity={isLight ? 0.22 : 0.28}
1501
+ strokeWidth={renderWidth}
1502
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
842
1503
  filter={isLight ? undefined : 'url(#topo-glow)'}
843
- markerEnd="url(#topo-arrow)"
1504
+ markerEnd={`url(#${arrowId})`}
1505
+ className="transition-opacity duration-300"
1506
+ style={{ pointerEvents: 'none' }}
844
1507
  />
845
1508
  <path
846
1509
  id={`flow-path-${index}`}
@@ -849,37 +1512,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
849
1512
  stroke={pal.flowPath}
850
1513
  strokeWidth="1"
851
1514
  strokeDasharray="2 12"
852
- opacity={isLight ? 0.4 : 0.75}
1515
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1516
+ className="transition-opacity duration-300"
853
1517
  />
854
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
855
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
856
- </circle>
1518
+ {!reducedMotion && (
1519
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1520
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1521
+ </circle>
1522
+ )}
857
1523
  </g>
858
1524
  );
859
1525
  })}
860
1526
 
861
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
862
- core so the "control plane" reads as the network's anchor,
863
- not a stray particle. Static halo ring at r=18 grounds it
864
- visually; two outward pulses keep the "signal source" idea
865
- from r17. Light theme gets a paler core to avoid hot-spotting
866
- the bg. */}
867
- {layout === 'ring' && (<g>
868
- {/* static grounding halo — sits underneath, low opacity */}
1527
+ {/* center hub — round 39 sized + round 13 restraint.
1528
+ The hub is the control-plane anchor. r39 gave it two outward
1529
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
1530
+ node in the room" — wrong semantics: anchors don't emit,
1531
+ they hold. r13 swaps the kinetic pulse for a slow opacity
1532
+ breath on the static halo (4s, ±15% from base), so the hub
1533
+ stays alive without throwing kinetic energy outward. The
1534
+ dual-circle "lit lamp" core is unchanged. */}
1535
+ {layout === 'ring' && (<g
1536
+ data-topo-hub
1537
+ style={{ cursor: 'pointer' }}
1538
+ // Stop pointerdown from reaching the SVG pan handler — same
1539
+ // reason as the node <g>: a captured pointer makes Chromium
1540
+ // fire the follow-up click on the SVG, not this group.
1541
+ onPointerDown={(e) => e.stopPropagation()}
1542
+ // Round 52 / Loop: hub is the visual anchor but had no click
1543
+ // action — users discovered fit-to-content only via the `f`
1544
+ // key or the chrome button. Wire the hub to fitView() so the
1545
+ // most prominent element in the canvas is also the "re-
1546
+ // center" affordance. A click-ripple keyed by ts confirms
1547
+ // the gesture; the hub <title> now ends with a hint.
1548
+ onClick={() => {
1549
+ fitView();
1550
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
1551
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
1552
+ }}
1553
+ >
1554
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
1555
+ central glow now answers "what is this?" with a one-line
1556
+ fleet snapshot. Duplicates the header chips by design;
1557
+ hovering the most visually prominent element is the
1558
+ most natural impulse, so satisfy it where the cursor
1559
+ already is. Falls clean: omits sub-clauses when their
1560
+ counts are zero. R52 appends a "click to fit view" hint.
1561
+ */}
1562
+ <title>{(() => {
1563
+ const total = sessions.length;
1564
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
1565
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
1566
+ if (workingCount > 0) parts.push(`${workingCount} working`);
1567
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
1568
+ return parts.join(' · ') + '\nclick to fit view';
1569
+ })()}</title>
1570
+ {/* grounding halo — now breathes in opacity, no expansion */}
869
1571
  <circle
870
1572
  cx={cx} cy={cy} r="18"
871
1573
  fill={isLight ? '#d1fae5' : '#10b981'}
872
- opacity={isLight ? 0.35 : 0.10}
873
- />
874
- {/* outer pulse 1 */}
875
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
876
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
877
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
878
- </circle>
879
- {/* outer pulse 2 (delayed 1.2s) */}
880
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
881
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
882
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1574
+ opacity={isLight ? 0.42 : 0.12}
1575
+ >
1576
+ {!reducedMotion && (
1577
+ <animate
1578
+ attributeName="opacity"
1579
+ values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
1580
+ dur="4s"
1581
+ repeatCount="indefinite"
1582
+ />
1583
+ )}
883
1584
  </circle>
884
1585
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
885
1586
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
@@ -887,7 +1588,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
887
1588
  </g>)}
888
1589
 
889
1590
  {/* agent nodes */}
890
- {[...onlineNodes, ...offlineNodes].map(session => {
1591
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
891
1592
  const pos = nodePositions[session.alias];
892
1593
  if (!pos) return null;
893
1594
 
@@ -895,7 +1596,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
895
1596
  const isOnline = session.status !== 'offline' || !!sseCountFor;
896
1597
  const status = nodeStatus(session, isOnline, isLight);
897
1598
  const isActive = activeAliases.has(session.alias);
898
- const radius = isOnline ? 26 : 18;
1599
+ // #113: node size scales with the S/M/L control; halos, labels,
1600
+ // badge and avatar all derive from `radius` so they follow.
1601
+ const radius = Math.round((isOnline ? 26 : 18) * nodeScale);
899
1602
  // Round 109/110 (Vincent 4582 + 4583 P0): at high node counts
900
1603
  // the 100px opaque label cards overlapped each other and
901
1604
  // covered neighbouring avatars. But hiding text entirely went
@@ -905,12 +1608,68 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
905
1608
  // occlude an avatar), and the full card appears only for the
906
1609
  // hovered node or once zoomed in past 1.4×.
907
1610
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
1611
+ // Round 8: when a node in another group is hovered, fade this
1612
+ // one. Same-group nodes (incl. singletons matching the hovered
1613
+ // alias) stay full. Pure visual focus, geometry unchanged.
1614
+ const inFocus = !hoveredGroup || (groupKeys[session.alias] ?? session.alias) === hoveredGroup;
908
1615
 
909
1616
  return (
910
1617
  <g
911
1618
  key={session.alias}
912
- className="transition-opacity"
913
- style={{ cursor: 'pointer' }}
1619
+ data-node={session.alias}
1620
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1621
+ // mounts — a new session entering the fleet (or the topology
1622
+ // first rendering) eases in instead of popping. Re-renders of
1623
+ // an existing node don't re-trigger (React preserves the <g>
1624
+ // via the alias key), so status changes don't flicker. The
1625
+ // global prefers-reduced-motion sweep already neutralises it.
1626
+ className="group transition-opacity anet-fade-in"
1627
+ style={{
1628
+ cursor: 'pointer',
1629
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
1630
+ // online nodes pop without losing the offline-as-ghost
1631
+ // information. The dashed stroke + smaller radius already
1632
+ // say "offline"; dimming the whole group strengthens the
1633
+ // online-vs-offline hierarchy at first glance. Exempt the
1634
+ // chat-focused node — if the user explicitly opened a
1635
+ // popover targeting an offline alias, that node stays
1636
+ // full-brightness so the focus ring + popover read as one
1637
+ // selected thing rather than a dimmed selection. Group-
1638
+ // hover fade (Round 8) still wins when inFocus is false.
1639
+ // Round 49 / Loop: edge-hover endpoint highlight composes
1640
+ // OVER the inFocus/online formula — a non-endpoint node
1641
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
1642
+ // stronger "not relevant" signal), endpoints keep their
1643
+ // base opacity. chatAlias still wins to keep the focus
1644
+ // ring legible if the user clicked through.
1645
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
1646
+ ? 0.28
1647
+ : !inFocus
1648
+ ? 0.32
1649
+ : chatAlias === session.alias
1650
+ ? 1
1651
+ : isOnline ? 1 : 0.6,
1652
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
1653
+ // reveals as a wave on first paint instead of one big pop.
1654
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
1655
+ // finish revealing within a beat. CSS animation-delay only
1656
+ // applies during the keyframe — re-renders without a new
1657
+ // mount (same alias key) don't replay, so status changes
1658
+ // never trigger the stagger again.
1659
+ animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
1660
+ // Round 51 / Loop: hover micro-lift. The label already
1661
+ // lifts on group-hover (R26). The node body — circle,
1662
+ // avatar, status ring — stays planted, so the gesture
1663
+ // reads "label moves, body doesn't". Now the whole <g>
1664
+ // translates -2px when hovered: avatar + ring + label
1665
+ // move as one unit. 2 px is well inside the inter-row
1666
+ // gap (cellH headroom ≥22), and CSS transform on SVG
1667
+ // <g> never affects the overlap-test geometry (the
1668
+ // test never simulates hover). useReducedMotion drops
1669
+ // the lift to 0 for prefers-reduced-motion users.
1670
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
1671
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
1672
+ }}
914
1673
  // Stop the pointerdown from reaching the SVG pan handler: the
915
1674
  // SVG calls setPointerCapture, and a captured pointer makes
916
1675
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -918,21 +1677,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
918
1677
  // Side effect (intended): you pan from empty canvas, not by
919
1678
  // grabbing a node.
920
1679
  onPointerDown={(e) => e.stopPropagation()}
921
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
922
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
923
- onClick={() => setChatAlias(session.alias)}
1680
+ // Track hover globally (not just under denseLayout) so the
1681
+ // Round 8 group-focus fade works at any fleet size.
1682
+ onPointerEnter={() => setHoveredAlias(session.alias)}
1683
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1684
+ onClick={() => {
1685
+ setChatAlias(session.alias);
1686
+ // Round 14 ripple — capture position, radius and status
1687
+ // colour at click time so the one-shot circle is self-
1688
+ // contained (no re-render-time recomputation needed).
1689
+ setClickRipple({
1690
+ ts: Date.now(),
1691
+ x: pos.x, y: pos.y, r0: radius,
1692
+ color: status.primary,
1693
+ });
1694
+ setTimeout(() => setClickRipple(prev =>
1695
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
1696
+ }}
924
1697
  >
925
1698
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
926
1699
  Falls back to just the alias when the node reports no
927
- model/runtime. */}
928
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
929
- {isActive && (
1700
+ model/runtime.
1701
+ Round 33 / Loop: cwd line answers "what is this agent on?".
1702
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
1703
+ so the operator knows whether to wait or chase. Online nodes
1704
+ skip the line (Round 27's 1 h ghost age-out means anything
1705
+ still online has heartbeated recently — the line would just
1706
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
1707
+ ("… 06:00:28" assumed UTC) formats. */}
1708
+ {(() => {
1709
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
1710
+ // — same parseHubTime is used by isGhost above (Round 38
1711
+ // factored it to app/lib/time.ts so both paths interpret SQL
1712
+ // bare timestamps as UTC on every browser).
1713
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
1714
+ return (
1715
+ <title>{[
1716
+ identityLine(session.model, session.runtime) || session.alias,
1717
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
1718
+ lastSeen ? `last seen: ${lastSeen}` : null,
1719
+ ].filter(Boolean).join('\n')}</title>
1720
+ );
1721
+ })()}
1722
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1723
+ in when the cursor enters the node, signalling clickability
1724
+ (real-user feedback for the chat-popover open). Pure CSS via
1725
+ Tailwind group-hover, so it costs nothing per frame and
1726
+ respects prefers-reduced-motion via the global media query. */}
1727
+ <circle
1728
+ cx={pos.x}
1729
+ cy={pos.y}
1730
+ r={radius + 12}
1731
+ fill="none"
1732
+ stroke={status.primary}
1733
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
1734
+ // (online status ring) — the overlap test selects by those
1735
+ // exact widths and would mis-count this invisible hover
1736
+ // ring as a node footprint.
1737
+ strokeWidth="2"
1738
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1739
+ style={{ pointerEvents: 'none' }}
1740
+ />
1741
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
1742
+ open targeting this node, anchor a persistent ring around
1743
+ it so the floating popover visibly links back to its source
1744
+ node. Static (not pulsing) so it reads as "selected state"
1745
+ rather than "this node is active". strokeWidth=2.5 stays
1746
+ clear of the overlap-test selectors (1.5 / 3). Sits just
1747
+ outside the halo radius+8 so it never overlaps a neighbour
1748
+ (halos already pack flush in dense grids). */}
1749
+ {chatAlias === session.alias && (
1750
+ <circle
1751
+ cx={pos.x}
1752
+ cy={pos.y}
1753
+ r={radius + 14}
1754
+ fill="none"
1755
+ stroke={status.primary}
1756
+ strokeWidth="2.5"
1757
+ opacity={isLight ? 0.85 : 0.95}
1758
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
1759
+ className="transition-opacity duration-200"
1760
+ style={{ pointerEvents: 'none' }}
1761
+ />
1762
+ )}
1763
+ {isActive && !reducedMotion && (
930
1764
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
931
1765
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
932
1766
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
933
1767
  </circle>
934
1768
  )}
935
- <circle cx={pos.x} cy={pos.y} r={radius + 8} fill={status.halo} opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)} />
1769
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1770
+ status colour changes so idle↔working↔offline doesn't snap
1771
+ — task replies / node-rename / SSE updates ease in. */}
1772
+ <circle
1773
+ cx={pos.x}
1774
+ cy={pos.y}
1775
+ r={radius + 8}
1776
+ fill={status.halo}
1777
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1778
+ className="transition-[fill,opacity] duration-300 ease-out"
1779
+ />
936
1780
  <circle
937
1781
  cx={pos.x}
938
1782
  cy={pos.y}
@@ -942,6 +1786,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
942
1786
  strokeWidth={isOnline ? 3 : 1.5}
943
1787
  strokeDasharray={isOnline ? 'none' : '5 5'}
944
1788
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
1789
+ className="transition-[fill,stroke] duration-300 ease-out"
945
1790
  />
946
1791
  {/* Issue #96: node "avatar" is now driven by the model
947
1792
  vendor. Decision order:
@@ -954,7 +1799,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
954
1799
  4. unknown vendor / null model → the prefix-group
955
1800
  hue-hashed initial (#83/#99 behaviour, unchanged). */}
956
1801
  {(() => {
957
- const ar = isOnline ? 14 : 10;
1802
+ const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
958
1803
  const size = radius * 2;
959
1804
  const vendor = vendorForModel(session.model);
960
1805
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
@@ -979,7 +1824,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
979
1824
  <circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
980
1825
  <text
981
1826
  x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
982
- fill={vendor.mono.text} fontSize={isOnline ? 14 : 10}
1827
+ fill={vendor.mono.text} fontSize={ar}
983
1828
  fontFamily="monospace" fontWeight="700"
984
1829
  >
985
1830
  {vendor.initial}
@@ -999,7 +1844,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
999
1844
  dy="0.34em"
1000
1845
  textAnchor="middle"
1001
1846
  fill={c.text}
1002
- fontSize={isOnline ? 14 : 10}
1847
+ fontSize={ar}
1003
1848
  fontFamily="monospace"
1004
1849
  fontWeight="700"
1005
1850
  >
@@ -1028,78 +1873,218 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1028
1873
  </g>
1029
1874
  );
1030
1875
  })()}
1031
- {session.status === 'working' && (
1032
- // working pulse — small dot just inside the status ring,
1033
- // above the avatar text. Was a 18px horizontal bar at
1034
- // y+11 which now collides with the avatar; the dot reads
1035
- // as the same "active" cue without occluding the initial.
1036
- <circle cx={pos.x} cy={pos.y - (isOnline ? 18 : 13)} r="2.5" fill={pal.flowParticle}>
1037
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1038
- </circle>
1039
- )}
1876
+ {session.status === 'working' && (() => {
1877
+ // Round 24 / Loop: pulse rate ↔ traffic. The dot's dur
1878
+ // was a flat 1.1s — every working node looked equally
1879
+ // busy. Mapping it to sse:N lets the eye land on what's
1880
+ // actually hot. 3 discrete tiers read cleaner than
1881
+ // continuous easing at a glance; thresholds picked so
1882
+ // typical 1-2-SSE Claude/Codex sessions stay in the
1883
+ // "active" tier and only multi-pane orchestrators pop
1884
+ // into the "busy" tier. Geometry unchanged (r=2.5).
1885
+ const sse = sseCountFor ?? 0;
1886
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
1887
+ return (
1888
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
1889
+ {!reducedMotion && (
1890
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
1891
+ )}
1892
+ </circle>
1893
+ );
1894
+ })()}
1040
1895
 
1041
1896
  {/* Round 98 (issue #61): label rect 124px → 100px.
1042
1897
  Round 109/110 (Vincent P0): full opaque card below the
1043
1898
  density threshold / on hover / when zoomed; otherwise a
1044
1899
  lightweight plain-text alias that keeps every node
1045
- labelled without an opaque box covering its neighbours. */}
1046
- {showFullLabel ? (
1047
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1048
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1049
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1050
- {truncate(session.alias, 12)}
1051
- </text>
1052
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1053
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
1900
+ labelled without an opaque box covering its neighbours.
1901
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
1902
+ but the label card was staying full-size, so the label
1903
+ visually outweighed the small node. Tighten the card
1904
+ frame, alias / sub fontSize, drop-offset and truncate
1905
+ length specifically for S; M and L keep their existing
1906
+ sizes (M ≈ L for labels by design — the S user is the
1907
+ one who explicitly asked for a denser view). */}
1908
+ {(() => {
1909
+ const isSmall = nodeScale < 0.8;
1910
+ const cardW = isSmall ? 88 : 100;
1911
+ const cardH = isSmall ? 36 : 42;
1912
+ const cardTopY = isSmall ? -12 : -14;
1913
+ const aliasFs = isSmall ? 11 : 12;
1914
+ const subFs = isSmall ? 8 : 9;
1915
+ const subY = isSmall ? 15 : 17;
1916
+ const dropY = isSmall ? 18 : 22;
1917
+ const fullMax = isSmall ? 11 : 12;
1918
+ const denseFs = isSmall ? 9 : 10;
1919
+ const denseDrop = isSmall ? 12 : 14;
1920
+ // Round 26 / Loop: micro-lift the label group on hover —
1921
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
1922
+ // group-box hover-accent treatment to give the same
1923
+ // "this is the focused element" feedback at the per-
1924
+ // node level. CSS transform stacks onto the SVG
1925
+ // positioning transform attribute (SVG 2 cascade);
1926
+ // bbox is unchanged at rest, so the overlap-test gate
1927
+ // continues to see the geometric layout it expects.
1928
+ return showFullLabel ? (
1929
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
1930
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
1931
+ <rect x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1932
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
1933
+ {truncate(session.alias, fullMax)}
1934
+ </text>
1935
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
1936
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
1937
+ </text>
1938
+ </g>
1939
+ ) : (
1940
+ <text
1941
+ x={pos.x}
1942
+ y={pos.y + radius + denseDrop}
1943
+ textAnchor="middle"
1944
+ fill={status.text}
1945
+ fontSize={denseFs}
1946
+ fontFamily="monospace"
1947
+ fontWeight="700"
1948
+ opacity={0.9}
1949
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
1950
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1951
+ stroke={pal.containerBg}
1952
+ strokeWidth="3"
1953
+ >
1954
+ {truncate(session.alias, isSmall ? 9 : 10)}
1054
1955
  </text>
1055
- </g>
1056
- ) : (
1057
- <text
1058
- x={pos.x}
1059
- y={pos.y + radius + 14}
1060
- textAnchor="middle"
1061
- fill={status.text}
1062
- fontSize="10"
1063
- fontFamily="monospace"
1064
- fontWeight="700"
1065
- opacity={0.9}
1066
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1067
- stroke={pal.containerBg}
1068
- strokeWidth="3"
1069
- >
1070
- {truncate(session.alias, 10)}
1071
- </text>
1072
- )}
1956
+ );
1957
+ })()}
1073
1958
  </g>
1074
1959
  );
1075
1960
  })}
1076
1961
 
1962
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
1963
+ the clicked node, ~500ms, status-coloured. Sits inside the
1964
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
1965
+ ts so a re-click on any node (same or different) remounts the
1966
+ <circle> and the SMIL <animate> elements replay from t=0.
1967
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
1968
+ {clickRipple && !reducedMotion && (
1969
+ <circle
1970
+ key={clickRipple.ts}
1971
+ cx={clickRipple.x}
1972
+ cy={clickRipple.y}
1973
+ r={clickRipple.r0 + 4}
1974
+ fill="none"
1975
+ stroke={clickRipple.color}
1976
+ strokeWidth="2"
1977
+ opacity="0"
1978
+ style={{ pointerEvents: 'none' }}
1979
+ >
1980
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
1981
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
1982
+ </circle>
1983
+ )}
1984
+
1985
+ </g>
1986
+
1987
+ {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
1988
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
1989
+ They're sized + tucked into the top corners so every corner of
1990
+ each panel is >325px from the canvas centre — i.e. fully outside
1991
+ the outermost (offline) ring. No node on any ring can reach the
1992
+ corner triangles, so the panels never overlap a node, in ring
1993
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
1077
1994
  {/* latest flow labels */}
1078
- <g transform="translate(28, 34)">
1079
- <rect x="0" y="0" width="285" height={60 + Math.min(flowLinks.length, 4) * 20} rx="8" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 1 : 0.94} />
1080
- <text x="16" y="26" fill={pal.legendHeadline} fontSize="13" fontFamily="monospace" fontWeight="700">recent signal</text>
1081
- <text x="178" y="26" fill={pal.legendAccent} fontSize="11" fontFamily="monospace">{messages.length} messages</text>
1082
- {flowLinks.slice(0, 4).map((link, index) => (
1083
- <text key={link.key} x="16" y={54 + index * 20} fill={pal.legendText} fontSize="10" fontFamily="monospace">
1084
- {truncate(link.from, 8)} {'->'} {truncate(link.to, 8)} / {link.count} / {truncate(link.content, 18)}
1995
+ <g transform="translate(16, 16)">
1996
+ <rect x="0" y="0" width="230" height="84" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1997
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
1998
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
1999
+ {/* Round 45 / Loop: empty state. The panel used to render
2000
+ "recent signal" + "0 msgs" with three blank slots below
2001
+ when no flow yet — read as "broken" rather than "quiet".
2002
+ A muted centred placeholder makes the empty state
2003
+ deliberate. Messages count CAN diverge from flowLinks
2004
+ count (raw count vs. deduped pairs), so the placeholder
2005
+ fires on flowLinks.length=0 specifically. */}
2006
+ {flowLinks.length === 0 ? (
2007
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2008
+ no flow yet
1085
2009
  </text>
1086
- ))}
2010
+ ) : (
2011
+ flowLinks.slice(0, 3).map((link, index) => (
2012
+ <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
2013
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2014
+ </text>
2015
+ ))
2016
+ )}
1087
2017
  </g>
1088
2018
 
1089
2019
  {/* legend */}
1090
- <g transform="translate(720, 34)">
1091
- <rect x="0" y="0" width="250" height="112" rx="8" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 1 : 0.94} />
1092
- <circle cx="18" cy="26" r="6" fill={isLight ? '#059669' : '#22c55e'} />
1093
- <text x="34" y="30" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1094
- <circle cx="18" cy="52" r="6" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1095
- <text x="34" y="56" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1096
- <circle cx="18" cy="78" r="6" fill={isLight ? '#94a3b8' : '#6b7280'} />
1097
- <text x="34" y="82" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1098
- <path d="M150,78 Q176,52 210,78" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
1099
- </g>
2020
+ <g transform="translate(760, 16)">
2021
+ <rect x="0" y="0" width="224" height="96" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
2022
+ <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
2023
+ <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
2024
+ <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
2025
+ <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
2026
+ <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
2027
+ <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
2028
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
1100
2029
  </g>
1101
2030
  </svg>
1102
2031
 
2032
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2033
+ zoom let users lose their position — the minimap shows the
2034
+ whole topology miniaturised plus a viewport rectangle so the
2035
+ user always knows where they are. Click anywhere to recenter
2036
+ the canvas there. Only mounted when the view is non-default
2037
+ (zoomed or panned) since at 1× centered the minimap and the
2038
+ canvas show the same thing. HTML overlay so it stays fixed
2039
+ while the SVG transforms. */}
2040
+ {(() => {
2041
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2042
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2043
+ const MW = 120, MH = 82;
2044
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2045
+ const rectX = (-view.x / view.zoom) * sx;
2046
+ const rectY = (-view.y / view.zoom) * sy;
2047
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2048
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2049
+ return (
2050
+ <div
2051
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2052
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2053
+ role="img"
2054
+ aria-label="Topology minimap — click to recenter"
2055
+ title="Minimap · click to recenter"
2056
+ onClick={(e) => {
2057
+ const r = e.currentTarget.getBoundingClientRect();
2058
+ const fx = (e.clientX - r.left) / r.width;
2059
+ const fy = (e.clientY - r.top) / r.height;
2060
+ setView(prev => ({
2061
+ ...prev,
2062
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2063
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2064
+ }));
2065
+ }}
2066
+ data-topo-minimap
2067
+ >
2068
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2069
+ {[...onlineNodes, ...offlineNodes].map(s => {
2070
+ const p = nodePositions[s.alias];
2071
+ if (!p) return null;
2072
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2073
+ const isOn = s.status !== 'offline' || !!sseN;
2074
+ const st = nodeStatus(s, isOn, isLight);
2075
+ return <circle key={s.alias} cx={p.x * sx} cy={p.y * sy} r={isOn ? 1.7 : 1.2} fill={st.primary} opacity={isOn ? 0.9 : 0.5} />;
2076
+ })}
2077
+ {/* viewport rectangle */}
2078
+ <rect
2079
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2080
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2081
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2082
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2083
+ />
2084
+ </svg>
2085
+ </div>
2086
+ );
2087
+ })()}
1103
2088
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1104
2089
  overlay so they stay fixed while the SVG content transforms.
1105
2090
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1107,6 +2092,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1107
2092
  Split into a plain % readout + an explicit reset button with
1108
2093
  its own icon + tooltip. */}
1109
2094
  <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
2095
+ {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
2096
+ <div
2097
+ className="flex items-center rounded-md border overflow-hidden"
2098
+ style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
2099
+ role="group"
2100
+ aria-label="Node size"
2101
+ >
2102
+ {([['S', 0.7], ['M', 0.84], ['L', 1]] as const).map(([lbl, v], idx) => (
2103
+ <button
2104
+ key={lbl}
2105
+ onClick={() => pickNodeScale(v)}
2106
+ aria-pressed={nodeScale === v}
2107
+ title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
2108
+ className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
2109
+ style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
2110
+ >
2111
+ {lbl}
2112
+ </button>
2113
+ ))}
2114
+ </div>
1110
2115
  <div
1111
2116
  className="flex items-center rounded-md border overflow-hidden"
1112
2117
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -1116,6 +2121,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1116
2121
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1117
2122
  style={{ color: pal.legendText }}
1118
2123
  aria-label="Zoom out"
2124
+ title="Zoom out (−)"
1119
2125
  >
1120
2126
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M5 12h14" /></svg>
1121
2127
  </button>
@@ -1131,6 +2137,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1131
2137
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1132
2138
  style={{ color: pal.legendText }}
1133
2139
  aria-label="Zoom in"
2140
+ title="Zoom in (+)"
1134
2141
  >
1135
2142
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M12 5v14M5 12h14" /></svg>
1136
2143
  </button>
@@ -1140,7 +2147,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1140
2147
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1141
2148
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1142
2149
  aria-label="Reset view"
1143
- title="Reset zoom + pan (or double-click the canvas)"
2150
+ title="Reset zoom + pan (0, or double-click the canvas)"
1144
2151
  >
1145
2152
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1146
2153
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />