@sleep2agi/agent-network-dashboard 0.6.3-preview.39 → 0.6.3-preview.40

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 (223) hide show
  1. package/.next/BUILD_COMMIT +1 -1
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +3 -3
  5. package/.next/diagnostics/route-bundle-stats.json +17 -17
  6. package/.next/fallback-build-manifest.json +3 -3
  7. package/.next/routes-manifest.json +8 -0
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +3 -3
  27. package/.next/server/app/admin.segments/_full.segment.rsc +3 -3
  28. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  29. package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +2 -2
  32. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  33. package/.next/server/app/api/hub/nodes/[ref]/attrs/route/app-paths-manifest.json +3 -0
  34. package/.next/server/app/api/hub/nodes/[ref]/attrs/route/build-manifest.json +9 -0
  35. package/.next/server/app/api/hub/nodes/[ref]/attrs/route/server-reference-manifest.json +4 -0
  36. package/.next/server/app/api/hub/nodes/[ref]/attrs/route.js +7 -0
  37. package/.next/server/app/api/hub/nodes/[ref]/attrs/route.js.map +5 -0
  38. package/.next/server/app/api/hub/nodes/[ref]/attrs/route.js.nft.json +1 -0
  39. package/.next/server/app/api/hub/nodes/[ref]/attrs/route_client-reference-manifest.js +3 -0
  40. package/.next/server/app/index.html +2 -2
  41. package/.next/server/app/index.rsc +3 -3
  42. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  43. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  44. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  46. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  47. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/login.html +2 -2
  49. package/.next/server/app/login.rsc +3 -3
  50. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  51. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  52. package/.next/server/app/login.segments/_index.segment.rsc +2 -2
  53. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  55. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  56. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/logs.html +2 -2
  58. package/.next/server/app/logs.rsc +2 -2
  59. package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
  60. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  61. package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
  62. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  64. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  65. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/messages.html +2 -2
  67. package/.next/server/app/messages.rsc +3 -3
  68. package/.next/server/app/messages.segments/_full.segment.rsc +3 -3
  69. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  70. package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
  71. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +2 -2
  73. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +3 -3
  77. package/.next/server/app/node.segments/_full.segment.rsc +3 -3
  78. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  79. package/.next/server/app/node.segments/_index.segment.rsc +2 -2
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +2 -2
  82. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  83. package/.next/server/app/nodes/page.js.nft.json +1 -1
  84. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/nodes.html +2 -2
  86. package/.next/server/app/nodes.rsc +3 -3
  87. package/.next/server/app/nodes.segments/_full.segment.rsc +3 -3
  88. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  89. package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
  90. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  91. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +2 -2
  92. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  93. package/.next/server/app/page.js.nft.json +1 -1
  94. package/.next/server/app/page_client-reference-manifest.js +1 -1
  95. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  96. package/.next/server/app/providers.html +2 -2
  97. package/.next/server/app/providers.rsc +2 -2
  98. package/.next/server/app/providers.segments/_full.segment.rsc +2 -2
  99. package/.next/server/app/providers.segments/_head.segment.rsc +1 -1
  100. package/.next/server/app/providers.segments/_index.segment.rsc +2 -2
  101. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  102. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +1 -1
  103. package/.next/server/app/providers.segments/providers.segment.rsc +1 -1
  104. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  105. package/.next/server/app/server-logs.html +2 -2
  106. package/.next/server/app/server-logs.rsc +2 -2
  107. package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
  108. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  109. package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
  110. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  111. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
  112. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  113. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/servers.html +2 -2
  115. package/.next/server/app/servers.rsc +2 -2
  116. package/.next/server/app/servers.segments/_full.segment.rsc +2 -2
  117. package/.next/server/app/servers.segments/_head.segment.rsc +1 -1
  118. package/.next/server/app/servers.segments/_index.segment.rsc +2 -2
  119. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +1 -1
  121. package/.next/server/app/servers.segments/servers.segment.rsc +1 -1
  122. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  123. package/.next/server/app/settings/networks.html +2 -2
  124. package/.next/server/app/settings/networks.rsc +2 -2
  125. package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
  126. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  127. package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
  128. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
  130. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  131. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  132. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  133. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  134. package/.next/server/app/settings/tokens.html +2 -2
  135. package/.next/server/app/settings/tokens.rsc +2 -2
  136. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
  137. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  138. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
  139. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  140. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
  141. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  142. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  143. package/.next/server/app/settings.html +2 -2
  144. package/.next/server/app/settings.rsc +3 -3
  145. package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  146. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  147. package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  148. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  149. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  150. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  151. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  152. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  153. package/.next/server/app/tasks.html +2 -2
  154. package/.next/server/app/tasks.rsc +2 -2
  155. package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
  156. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  157. package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
  158. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  159. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  160. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  161. package/.next/server/app-paths-manifest.json +1 -0
  162. package/.next/server/chunks/0hue__next-internal_server_app_api_hub_nodes_[ref]_attrs_route_actions_0wln_el.js +3 -0
  163. package/.next/server/chunks/0hue__next-internal_server_app_api_hub_nodes_[ref]_attrs_route_actions_0wln_el.js.map +1 -0
  164. package/.next/server/chunks/[root-of-the-server]__0xcnv_3._.js +3 -0
  165. package/.next/server/chunks/[root-of-the-server]__0xcnv_3._.js.map +1 -0
  166. package/.next/server/chunks/ssr/0_97_f0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_nodes_page_tsx_0gbynyr._.js +1 -1
  167. package/.next/server/chunks/ssr/0_97_f0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_nodes_page_tsx_0gbynyr._.js.map +1 -1
  168. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_01~-ebd._.js +1 -1
  169. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_01~-ebd._.js.map +1 -1
  170. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_04lx.2p._.js +0 -0
  171. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_04lx.2p._.js.map +1 -0
  172. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0lzn~cs._.js +1 -1
  173. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0lzn~cs._.js.map +1 -1
  174. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0m5cykd._.js +1 -1
  175. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0m5cykd._.js.map +1 -1
  176. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0mek_e~._.js +1 -1
  177. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0mek_e~._.js.map +1 -1
  178. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0wz8z_e._.js +1 -1
  179. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0wz8z_e._.js.map +1 -1
  180. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0~ejwde._.js +1 -1
  181. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0~ejwde._.js.map +1 -1
  182. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_13qeljx._.js +0 -0
  183. package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_13qeljx._.js.map +1 -0
  184. package/.next/server/chunks/ssr/{[root-of-the-server]__0l~vdgn._.js → [root-of-the-server]__0dg_7v_._.js} +2 -2
  185. package/.next/server/chunks/ssr/[root-of-the-server]__0dg_7v_._.js.map +1 -0
  186. package/.next/server/chunks/ssr/[root-of-the-server]__0i.14-6._.js +3 -0
  187. package/.next/server/chunks/ssr/[root-of-the-server]__0i.14-6._.js.map +1 -0
  188. package/.next/server/middleware-build-manifest.js +3 -3
  189. package/.next/server/pages/404.html +2 -2
  190. package/.next/server/pages/500.html +1 -1
  191. package/.next/static/chunks/03b2p9debsy1a.js +1 -0
  192. package/.next/static/chunks/0e4..kvu5tnv-.css +1 -0
  193. package/.next/static/chunks/0e906ufpl1m9y.js +1 -0
  194. package/.next/static/chunks/0ujziowx3arw9.js +2 -0
  195. package/.next/static/chunks/{0.o~6-f11_j6i.js → 0yz.xuueoguvm.js} +1 -1
  196. package/.next/static/chunks/13ds9xb3_g9e8.js +1 -0
  197. package/.next/static/chunks/{04y_am25nk4g..js → 13ltv2rvrnldj.js} +1 -1
  198. package/.next/static/chunks/16_6kx6mkfwg3.js +1 -0
  199. package/.next/trace +2 -2
  200. package/.next/trace-build +1 -1
  201. package/.next/types/routes.d.ts +2 -1
  202. package/.next/types/validator.ts +9 -0
  203. package/app/api/hub/nodes/[ref]/attrs/route.ts +198 -0
  204. package/app/components/NodeAttrsEditor.tsx +0 -0
  205. package/app/components/NodeLifecycleMenu.tsx +22 -2
  206. package/app/lib/hooks.ts +29 -3
  207. package/app/nodes/page.tsx +4 -1
  208. package/package.json +1 -1
  209. package/tests/e2e-node-attrs-edit/uat.spec.ts +274 -0
  210. package/tests/e2e-node-attrs-edit/verify-summary.md +54 -0
  211. package/tests/e2e-node-attrs-edit/verify.sh +239 -0
  212. package/.next/server/chunks/ssr/[root-of-the-server]__04fj5h4._.js +0 -3
  213. package/.next/server/chunks/ssr/[root-of-the-server]__04fj5h4._.js.map +0 -1
  214. package/.next/server/chunks/ssr/[root-of-the-server]__0l~vdgn._.js.map +0 -1
  215. package/.next/static/chunks/01cd3uiuzejbt.js +0 -1
  216. package/.next/static/chunks/04~ypes~t4khh.js +0 -1
  217. package/.next/static/chunks/0p~997s2myymh.js +0 -1
  218. package/.next/static/chunks/0xjf71a0j_v8e.js +0 -1
  219. package/.next/static/chunks/0xy6sa.x2zvar.css +0 -1
  220. package/.next/static/chunks/14xnrhzhmv37o.js +0 -2
  221. /package/.next/static/{FHSqMAj0yD_AXCKEYrGTW → 0XBMlkY9s6UJakDv5hO8n}/_buildManifest.js +0 -0
  222. /package/.next/static/{FHSqMAj0yD_AXCKEYrGTW → 0XBMlkY9s6UJakDv5hO8n}/_clientMiddlewareManifest.js +0 -0
  223. /package/.next/static/{FHSqMAj0yD_AXCKEYrGTW → 0XBMlkY9s6UJakDv5hO8n}/_ssgManifest.js +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import useSWR from 'swr';
5
+ import { NodeAttrsEditor } from './NodeAttrsEditor';
6
+ import type { NodeAttrsSnapshot } from '../lib/hooks';
5
7
 
6
8
  // RFC-027 PR2 — per-node ⋮ lifecycle menu + stop/delete modals.
7
9
  //
@@ -38,6 +40,10 @@ export interface NodeLifecycleMenuProps {
38
40
  networkId?: string | null;
39
41
  /** Called when a lifecycle op completes successfully (caller refreshes nodes list). */
40
42
  onCompleted?: () => void;
43
+ /** #492 goal-3: current display attrs from parent SWR cache.
44
+ * Optional — when absent, the "编辑属性" menu item hides so we
45
+ * don't render an editor with unknown baseline revision. */
46
+ attrsSnapshot?: NodeAttrsSnapshot;
41
47
  }
42
48
 
43
49
  type LifecycleAction = 'restart' | 'stop' | 'delete';
@@ -99,6 +105,7 @@ export function NodeLifecycleMenu({
99
105
  lifecycleState,
100
106
  networkId,
101
107
  onCompleted,
108
+ attrsSnapshot,
102
109
  }: NodeLifecycleMenuProps) {
103
110
  // All hooks above any early return (avoid hooks-order regression that
104
111
  // bit the mobile app at v0.1.29; 通信龙 explicit call-out for PR2).
@@ -106,6 +113,7 @@ export function NodeLifecycleMenu({
106
113
  const [menuOpen, setMenuOpen] = useState(false);
107
114
  const [stopModal, setStopModal] = useState(false);
108
115
  const [deleteModal, setDeleteModal] = useState(false);
116
+ const [attrsModal, setAttrsModal] = useState(false);
109
117
  const [restartBusy, setRestartBusy] = useState(false);
110
118
  const [lastError, setLastError] = useState<ApiReply | null>(null);
111
119
  const menuRef = useRef<HTMLDivElement | null>(null);
@@ -155,13 +163,16 @@ export function NodeLifecycleMenu({
155
163
  });
156
164
  } else if (isActive) {
157
165
  items.push({ label: restartBusy ? '重启中…' : '重启节点', key: 'restart', disabled: restartBusy, onClick: onRestart });
158
- // Usability-audit gating: hide stop/delete when the hub confirms the
159
- // tool doesn't exist (older hubs) a visible control must work.
166
+ // #492 goal-3: attribute editor available in every non-loading
167
+ // state (you can edit a stopped node's metadata too). Hidden when
168
+ // parent didn't hand us a baseline snapshot (older callers).
169
+ if (attrsSnapshot) items.push({ label: '编辑属性', key: 'edit-attrs', onClick: () => { setMenuOpen(false); setAttrsModal(true); } });
160
170
  if (caps === null || caps.stop) items.push({ label: '停止节点', key: 'stop', onClick: () => { setMenuOpen(false); setStopModal(true); } });
161
171
  if (caps === null || caps.delete) items.push({ label: '删除节点', key: 'delete', danger: true, onClick: () => { setMenuOpen(false); setDeleteModal(true); } });
162
172
  } else if (isStopped) {
163
173
  // stopped + stop_failed: "启动" = restart_node (hub resets lifecycle_state→active on success per PR1.2a)
164
174
  items.push({ label: restartBusy ? '启动中…' : '启动节点', key: 'restart', disabled: restartBusy, onClick: onRestart });
175
+ if (attrsSnapshot) items.push({ label: '编辑属性', key: 'edit-attrs', onClick: () => { setMenuOpen(false); setAttrsModal(true); } });
165
176
  items.push({ label: '删除节点', key: 'delete', danger: true, onClick: () => { setMenuOpen(false); setDeleteModal(true); } });
166
177
  } else {
167
178
  // Unknown state — surface read-only badge, no destructive action.
@@ -245,6 +256,15 @@ export function NodeLifecycleMenu({
245
256
  onCompleted={() => { setDeleteModal(false); onCompleted?.(); }}
246
257
  />
247
258
  )}
259
+ {attrsModal && attrsSnapshot && (
260
+ <NodeAttrsEditor
261
+ alias={alias}
262
+ nodeId={nodeId}
263
+ snapshot={attrsSnapshot}
264
+ onClose={() => setAttrsModal(false)}
265
+ onSaved={() => { onCompleted?.(); }}
266
+ />
267
+ )}
248
268
  </div>
249
269
  );
250
270
  }
package/app/lib/hooks.ts CHANGED
@@ -35,22 +35,48 @@ export function useSessions() {
35
35
  // doesn't carry lifecycle_state; /api/nodes does. Cheap secondary
36
36
  // fetch, same SWR cadence as useSessions; merged client-side by alias.
37
37
  // Absent alias → caller defaults to 'active'.
38
- interface NodesResp { nodes?: Array<{ alias?: string; node_id?: string; lifecycle_state?: string | null }>; }
38
+ interface NodeAttrsSnapshot {
39
+ display_name: string | null;
40
+ team: string | null;
41
+ tags: string[];
42
+ attrs_revision: number;
43
+ }
44
+ interface NodesResp { nodes?: Array<{
45
+ alias?: string;
46
+ node_id?: string;
47
+ lifecycle_state?: string | null;
48
+ // #492 goal-item-3: node display attributes (hub PR #497). Legacy
49
+ // rows read as null/[]/0 respectively — no guard needed by callers.
50
+ display_name?: string | null;
51
+ team?: string | null;
52
+ tags?: string[];
53
+ attrs_revision?: number;
54
+ }>; }
39
55
  export function useNodeLifecycle() {
40
56
  const { networkId } = useNetworkId();
41
- const { data, error } = useSWR<NodesResp>(
57
+ const { data, error, mutate } = useSWR<NodesResp>(
42
58
  withNetwork('/api/hub/nodes', networkId),
43
59
  fetcher,
44
60
  SWR_OPTIONS,
45
61
  );
46
62
  const lifecycleByAlias: Record<string, string> = {};
47
63
  const nodeIdByAlias: Record<string, string> = {};
64
+ const attrsByAlias: Record<string, NodeAttrsSnapshot> = {};
48
65
  for (const n of data?.nodes ?? []) {
49
66
  if (n.alias && typeof n.lifecycle_state === 'string') lifecycleByAlias[n.alias] = n.lifecycle_state;
50
67
  if (n.alias && typeof n.node_id === 'string') nodeIdByAlias[n.alias] = n.node_id;
68
+ if (n.alias) {
69
+ attrsByAlias[n.alias] = {
70
+ display_name: typeof n.display_name === 'string' ? n.display_name : null,
71
+ team: typeof n.team === 'string' ? n.team : null,
72
+ tags: Array.isArray(n.tags) ? n.tags : [],
73
+ attrs_revision: typeof n.attrs_revision === 'number' ? n.attrs_revision : 0,
74
+ };
75
+ }
51
76
  }
52
- return { lifecycleByAlias, nodeIdByAlias, error };
77
+ return { lifecycleByAlias, nodeIdByAlias, attrsByAlias, error, refreshNodes: mutate };
53
78
  }
79
+ export type { NodeAttrsSnapshot };
54
80
 
55
81
  export function useHealth() {
56
82
  const { data, error } = useSWR<Health>('/api/hub/health', fetcher, SWR_OPTIONS);
@@ -40,7 +40,7 @@ function DraftMark({ alias }: { alias: string }) {
40
40
  export default function NodesPage() {
41
41
  const { sessions, isLoading: loading } = useSessions();
42
42
  const { health } = useHealth();
43
- const { lifecycleByAlias, nodeIdByAlias } = useNodeLifecycle();
43
+ const { lifecycleByAlias, nodeIdByAlias, attrsByAlias } = useNodeLifecycle();
44
44
  const { hasUnread, unreadCount, lastActivityAt } = useChatUnread();
45
45
  const { mutate } = useSWRConfig();
46
46
  // RFC-027 PR2 — after a stop/delete/restart dispatch lands, refresh
@@ -271,6 +271,7 @@ export default function NodesPage() {
271
271
  lifecycleState={lifecycleByAlias[s.alias]}
272
272
  networkId={s.network_id || null}
273
273
  onCompleted={refreshNodes}
274
+ attrsSnapshot={attrsByAlias[s.alias]}
274
275
  />
275
276
  </div>
276
277
 
@@ -360,6 +361,7 @@ export default function NodesPage() {
360
361
  lifecycleState={lifecycleByAlias[s.alias]}
361
362
  networkId={s.network_id || null}
362
363
  onCompleted={refreshNodes}
364
+ attrsSnapshot={attrsByAlias[s.alias]}
363
365
  />
364
366
  </div>
365
367
  </div>
@@ -393,6 +395,7 @@ export default function NodesPage() {
393
395
  lifecycleState={lifecycleByAlias[s.alias]}
394
396
  networkId={s.network_id || null}
395
397
  onCompleted={refreshNodes}
398
+ attrsSnapshot={attrsByAlias[s.alias]}
396
399
  />
397
400
  </div>
398
401
  {s.task && <div className="truncate text-xs text-gray-500">{s.task}</div>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/agent-network-dashboard",
3
- "version": "0.6.3-preview.39",
3
+ "version": "0.6.3-preview.40",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "main": "apps/desktop/electron/main.cjs",
6
6
  "scripts": {
@@ -0,0 +1,274 @@
1
+ /**
2
+ * #492 goal-3 manual UAT — automated via Playwright so it's repeatable.
3
+ *
4
+ * 通信龙 requirement: prove the editor actually renders + click paths
5
+ * work end-to-end in a browser, not just that the wire contract is
6
+ * green. Screenshots at each step land under `test-results/uat-*.png`
7
+ * for visual sanity.
8
+ *
9
+ * Runs against a dashboard on :3010 with COMMHUB_URL=127.0.0.1:9210
10
+ * (source-built hub with PR #497). See verify-summary.md for setup.
11
+ *
12
+ * Env:
13
+ * DASH_URL default http://127.0.0.1:3010
14
+ * UTOK V3 user token for the dev hub (required)
15
+ * COMMHUB_DB path to the dev hub SQLite file (required — we
16
+ * seed & reset fixtures directly)
17
+ */
18
+
19
+ import { test, expect, type Page, type BrowserContext } from '@playwright/test';
20
+ import { execFileSync } from 'child_process';
21
+
22
+ const DASH = process.env.DASH_URL || 'http://127.0.0.1:3010';
23
+ const UTOK = process.env.UTOK;
24
+ const DBPATH = process.env.COMMHUB_DB || '/tmp/dev-hub-9210/commhub.db';
25
+
26
+ if (!UTOK) throw new Error('UTOK env is required — see verify-summary.md setup');
27
+
28
+ // Seed via bun-shellout because Playwright runs on Node and `bun:sqlite`
29
+ // isn't available. Any Node native SQLite would also need to install
30
+ // better-sqlite3 in the dashboard repo which we don't want to do just
31
+ // for this UAT harness.
32
+ async function resetFixtures() {
33
+ const script = `
34
+ import { Database } from 'bun:sqlite';
35
+ const db = new Database(${JSON.stringify(DBPATH)});
36
+ const now = new Date().toISOString().replace('T',' ').substring(0,19);
37
+ db.run("DELETE FROM nodes WHERE alias LIKE 'uat-%'");
38
+ // Resolve the dev user's network via network_members. Tokens live in
39
+ // api_tokens (hashed); we can't reverse the hash here, so we take the
40
+ // first membership of the only registered non-seed user — dev hub
41
+ // has exactly one, verified by the register call in setup.
42
+ const netId = db.query("SELECT network_id FROM network_members LIMIT 1").get()?.network_id;
43
+ if (!netId) throw new Error('cannot resolve network_id — is any user registered on the dev hub?');
44
+ const nodes = [
45
+ ['n_uat_alpha', 'uat-alpha', 'α node', 'Team A', '["a","b"]'],
46
+ ['n_uat_beta', 'uat-beta', null, null, '[]'],
47
+ ];
48
+ // Also seed sessions so the /nodes page (which reads from useSessions
49
+ // → /api/status → sessions table) shows the fixtures. Real users
50
+ // would edit nodes that HAVE a live session; that's the flow we're
51
+ // proving out.
52
+ db.run("DELETE FROM sessions WHERE alias LIKE 'uat-%'");
53
+ for (const [id, alias, dn, team, tags] of nodes) {
54
+ db.run(\`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path,
55
+ network_id, created_at, updated_at, lifecycle_state,
56
+ display_name, team, tags, attrs_revision)
57
+ VALUES (?, ?, ?, 'claude-code-cli', 'sonnet-4', '/tmp/x/config.json',
58
+ ?, ?, ?, 'active', ?, ?, ?, 0)\`,
59
+ id, alias, alias, netId, now, now, dn, team, tags);
60
+ db.run(\`INSERT INTO sessions (resume_id, alias, agent, project_dir, version, status,
61
+ network_id, registered_at, updated_at, node_id,
62
+ config_path, last_seen_at)
63
+ VALUES (?, ?, 'claude-code-cli', '/tmp/x', 'dev', 'idle',
64
+ ?, ?, ?, ?, '/tmp/x/config.json', ?)\`,
65
+ 'r_' + id, alias, netId, now, now, id, now);
66
+ }
67
+ db.close();
68
+ console.log('reset ok');
69
+ `;
70
+ execFileSync('bun', ['-e', script], { stdio: 'inherit' });
71
+ }
72
+
73
+ // Log in through the dashboard's own v3 endpoint so Playwright picks
74
+ // up the Set-Cookie the server sends — matches how a real user's
75
+ // session is minted, avoids the "domain match" edge cases you get
76
+ // with hand-set cookies.
77
+ async function setSession(context: BrowserContext) {
78
+ const res = await context.request.post(`${DASH}/api/auth/v3`, {
79
+ data: { action: 'login', username: 'devuser', password: 'DevPass123Aa!' },
80
+ });
81
+ if (!res.ok()) {
82
+ throw new Error(`dev-hub login failed: ${res.status()} — check devuser/DevPass123Aa! exists on hub`);
83
+ }
84
+ // Belt-and-braces: mirror the response cookies onto the browser
85
+ // context explicitly. `context.request.post` SHOULD propagate them
86
+ // automatically but Next dev SSR + our first goto sometimes runs
87
+ // before propagation lands. Explicit set removes the ambiguity.
88
+ const cookies = await context.cookies();
89
+ const has = cookies.some(c => c.name === 'anet_dashboard_session');
90
+ if (!has) {
91
+ throw new Error('login succeeded but no anet_dashboard_session in context cookies');
92
+ }
93
+ }
94
+
95
+ async function openEditor(page: Page, alias: string) {
96
+ // Instrument the page to trace every /api/hub/status hit and its
97
+ // response — this is the exact call useSessions relies on.
98
+ page.on('response', async (r) => {
99
+ const url = r.url();
100
+ if (url.includes('/api/hub/status')) {
101
+ let body = '';
102
+ try { body = (await r.text()).slice(0, 200); } catch {}
103
+ // eslint-disable-next-line no-console
104
+ console.log(`[uat trace] ${r.status()} ${url} — ${body}`);
105
+ }
106
+ });
107
+ // Note: `networkidle` never fires — SWR polls every 5s so the tab
108
+ // is never truly idle. Use `domcontentloaded` + a bounded poll for
109
+ // the target alias.
110
+ await page.goto(`${DASH}/nodes`, { waitUntil: 'domcontentloaded' });
111
+ // Poll: SWR might not have re-rendered by networkidle. Give it up
112
+ // to 8s for the alias text to appear anywhere on the page.
113
+ const found = await page.waitForFunction(
114
+ (a) => document.body.innerText.includes(a),
115
+ alias,
116
+ { timeout: 8000 },
117
+ ).catch(() => null);
118
+ if (!found) {
119
+ const dump = await page.evaluate(async () => {
120
+ const r = await fetch('/api/hub/status', { credentials: 'include' });
121
+ const noFilter = await r.json();
122
+ // Also try with the current network id from sessionStorage
123
+ const netId = sessionStorage.getItem('anet_network_id') || '';
124
+ const filtered = netId
125
+ ? await (await fetch(`/api/hub/status?network_id=${encodeURIComponent(netId)}`, { credentials: 'include' })).json()
126
+ : null;
127
+ return {
128
+ apiStatusUnfiltered: r.status,
129
+ apiCountUnfiltered: noFilter?.sessions?.length,
130
+ sessionStorageNetworkId: netId,
131
+ apiCountFiltered: filtered?.sessions?.length,
132
+ totalText: document.body.innerText.match(/(\d+)\s*total/)?.[1] ?? null,
133
+ };
134
+ });
135
+ // eslint-disable-next-line no-console
136
+ console.log('[uat] page state at failure:', JSON.stringify(dump));
137
+ }
138
+ await page.screenshot({ path: `test-results/uat-open-${alias}.png`, fullPage: true });
139
+ // Wait for the node row to render — the alias text appears in a
140
+ // truncate span.
141
+ const row = page.locator(`text="${alias}"`).first();
142
+ await expect(row).toBeVisible({ timeout: 15000 });
143
+ // Find the ⋮ button in the row's action area. The lifecycle menu
144
+ // button has aria-label `节点 <alias> 生命周期操作`.
145
+ const menuBtn = page.locator(`button[aria-label="节点 ${alias} 生命周期操作"]`).first();
146
+ await expect(menuBtn).toBeVisible();
147
+ await menuBtn.click();
148
+ const editBtn = page.getByRole('menuitem', { name: '编辑属性' });
149
+ await expect(editBtn).toBeVisible();
150
+ // Dispatch a synthetic click via evaluate — the row wraps the menu
151
+ // in a `role="button"` that both intercepts pointer events (regular
152
+ // click) AND swallows Enter (keyboard). Native `.click()` on the
153
+ // button element bypasses both because it isn't hit-tested through
154
+ // the DOM ancestors. A real user's pointer-down still activates
155
+ // the button because CSS puts the menu popover on top; Playwright
156
+ // just can't reason about the layered geometry here.
157
+ await editBtn.evaluate((el: HTMLElement) => el.click());
158
+ const dialog = page.getByRole('dialog', { name: new RegExp(`编辑节点 ${alias} 属性`) });
159
+ await expect(dialog).toBeVisible();
160
+ return dialog;
161
+ }
162
+
163
+ test.beforeAll(async () => {
164
+ await resetFixtures();
165
+ });
166
+
167
+ test.describe('goal-3 UAT', () => {
168
+ test.beforeEach(async ({ context }) => setSession(context));
169
+
170
+ test('1. editor opens with 3 fields visible + editable', async ({ page }) => {
171
+ const dialog = await openEditor(page, 'uat-alpha');
172
+ // 3 inputs — display_name / team / tag input
173
+ const dn = dialog.locator('input[type="text"]').nth(0);
174
+ const team = dialog.locator('input[type="text"]').nth(1);
175
+ const tagInput = dialog.locator('input[placeholder*="新标签"]');
176
+ await expect(dn).toBeVisible();
177
+ await expect(team).toBeVisible();
178
+ await expect(tagInput).toBeVisible();
179
+
180
+ // pre-populated with seeded values
181
+ await expect(dn).toHaveValue('α node');
182
+ await expect(team).toHaveValue('Team A');
183
+
184
+ // caption that answers "does改 display_name change my routing name"
185
+ await expect(dialog).toContainText('不影响别人 @ 你的路由名');
186
+
187
+ // 🔴 alias must NOT appear as an input field
188
+ const aliasInput = dialog.locator('input[name="alias"], input[placeholder*="alias" i]');
189
+ await expect(aliasInput).toHaveCount(0);
190
+
191
+ await page.screenshot({ path: 'test-results/uat-1-editor-open.png', fullPage: false });
192
+ });
193
+
194
+ test('2. save persists after refresh', async ({ page }) => {
195
+ const dialog = await openEditor(page, 'uat-alpha');
196
+ const dn = dialog.locator('input[type="text"]').nth(0);
197
+ await dn.fill('UAT persisted display');
198
+ await dialog.getByRole('button', { name: '保存' }).click();
199
+ // Modal closes on success
200
+ await expect(dialog).not.toBeVisible({ timeout: 8000 });
201
+
202
+ // Refresh the page and reopen the editor — the new value should be there
203
+ await page.reload();
204
+ const dialog2 = await openEditor(page, 'uat-alpha');
205
+ const dn2 = dialog2.locator('input[type="text"]').nth(0);
206
+ await expect(dn2).toHaveValue('UAT persisted display');
207
+ await page.screenshot({ path: 'test-results/uat-2-after-refresh.png' });
208
+ });
209
+
210
+ test('3. two-window race → 409 UI shows message + working "重新加载" button', async ({ browser }) => {
211
+ // Two independent contexts = two independent tabs / windows
212
+ const ctxA = await browser.newContext();
213
+ const ctxB = await browser.newContext();
214
+ await setSession(ctxA);
215
+ await setSession(ctxB);
216
+ const pageA = await ctxA.newPage();
217
+ const pageB = await ctxB.newPage();
218
+
219
+ // Both open the editor with the SAME baseline attrs_revision.
220
+ const dlgA = await openEditor(pageA, 'uat-alpha');
221
+ const dlgB = await openEditor(pageB, 'uat-alpha');
222
+
223
+ // A saves first — succeeds, revision bumps
224
+ await dlgA.locator('input[type="text"]').nth(0).fill('A-wins');
225
+ await dlgA.getByRole('button', { name: '保存' }).click();
226
+ await expect(dlgA).not.toBeVisible({ timeout: 8000 });
227
+
228
+ // B saves with stale baseline — must get 409 with actionable UI
229
+ await dlgB.locator('input[type="text"]').nth(1).fill('B-loses');
230
+ await dlgB.getByRole('button', { name: '保存' }).click();
231
+
232
+ // Error banner appears with the reload button
233
+ await expect(dlgB).toContainText('该节点在你编辑期间被别人改动了');
234
+ const reloadBtn = dlgB.getByRole('button', { name: '重新加载最新值' });
235
+ await expect(reloadBtn).toBeVisible();
236
+ await pageB.screenshot({ path: 'test-results/uat-3a-conflict-shown.png' });
237
+
238
+ // Click the reload — banner should clear and baseline should
239
+ // update to the fresh revision.
240
+ await reloadBtn.click();
241
+ // A short poll for the SWR revalidate + prop-sync effect.
242
+ await expect(dlgB).not.toContainText('该节点在你编辑期间', { timeout: 8000 });
243
+ // The revision line at top should now show the bumped value.
244
+ await expect(dlgB).toContainText('修订版本');
245
+ await pageB.screenshot({ path: 'test-results/uat-3b-after-reload.png' });
246
+
247
+ // Now B can save without conflict — actionable proof
248
+ await dlgB.locator('input[type="text"]').nth(1).fill('B-retry');
249
+ await dlgB.getByRole('button', { name: '保存' }).click();
250
+ await expect(dlgB).not.toBeVisible({ timeout: 8000 });
251
+
252
+ await ctxA.close();
253
+ await ctxB.close();
254
+ });
255
+
256
+ test('4. legacy node (all attrs empty) opens without error', async ({ page }) => {
257
+ const dialog = await openEditor(page, 'uat-beta');
258
+ const dn = dialog.locator('input[type="text"]').nth(0);
259
+ const team = dialog.locator('input[type="text"]').nth(1);
260
+ // Legacy values render as empty strings — no undefined guards needed
261
+ await expect(dn).toHaveValue('');
262
+ await expect(team).toHaveValue('');
263
+ // Revision line reads 0 (not "NaN" or blank)
264
+ await expect(dialog).toContainText('修订版本:0');
265
+ // No error banner
266
+ await expect(dialog.locator('.text-red-200')).toHaveCount(0);
267
+ await page.screenshot({ path: 'test-results/uat-4-legacy-node.png' });
268
+
269
+ // And saving from rev=0 works
270
+ await dn.fill('legacy-now-named');
271
+ await dialog.getByRole('button', { name: '保存' }).click();
272
+ await expect(dialog).not.toBeVisible({ timeout: 8000 });
273
+ });
274
+ });
@@ -0,0 +1,54 @@
1
+ # Node attributes edit — verification summary
2
+
3
+ ## Setup (needed to run `verify.sh`)
4
+
5
+ Production hub `0.9.0-preview.22` does NOT have the endpoint yet (per 通信龙 dispatch). Runs against a source-built hub.
6
+
7
+ ```
8
+ # hub — source build from agent-network main (has PR #497)
9
+ cd <agent-network>/server
10
+ PORT=9210 COMMHUB_DB=/tmp/dev-hub-9210/commhub.db HOST=127.0.0.1 bun run src/index.ts
11
+
12
+ # register a dev user, capture utok
13
+ curl -X POST -H 'Content-Type: application/json' \
14
+ -d '{"username":"devuser","password":"…","display_name":"dev"}' \
15
+ http://127.0.0.1:9210/api/auth/register
16
+
17
+ # dashboard dev server pointed at :9210
18
+ cd <this-repo>
19
+ COMMHUB_URL=http://127.0.0.1:9210 npx next dev -p 3010
20
+
21
+ # run
22
+ UTOK=utok_... HUB_URL=http://127.0.0.1:9210 DASH_URL=http://127.0.0.1:3010 \
23
+ COMMHUB_DB=/tmp/dev-hub-9210/commhub.db \
24
+ bash tests/e2e-node-attrs-edit/verify.sh
25
+ ```
26
+
27
+ ## Results — 22/22 PASS
28
+
29
+ | # | Gate | Assertions |
30
+ |---|------|-----------|
31
+ | ① | display_name/team/tags round-trip | PUT accepted, all three persisted after refresh, `attrs_revision` bumped |
32
+ | ② | Two-editor race → 409 | first writer wins, second gets 409 + `error: attrs_revision_conflict` + `current_attrs_revision` + human message containing 「重新加载」 |
33
+ | ③ | Legacy node (empty attrs) opens without error | `display_name: null`, `tags: []`, `attrs_revision: 0` — no undefined guards needed |
34
+ | ④ | alias never in form nor in body | source-grep confirms no `<input name="alias">`, no `JSON.stringify({alias: ...})`, no `'alias' in clientBody` in proxy; wire test confirms sending alias in PUT body leaves hub alias unchanged |
35
+ | ⑤ | 400 invalid_display_name surfaces reason | HTTP 400, message `显示名不合法:display_name must be a string or null` (Chinese label + hub-side reason string propagated) |
36
+
37
+ ## Witnessed-red on the alias guard
38
+
39
+ Added a smuggle line to the proxy: `if ('alias' in clientBody) forwardBody.alias = clientBody.alias;` — step ④'s "proxy allowlist does not include 'alias'" assertion **fired FAIL** as expected. The wire assertion (alias unchanged) stayed green because the hub itself refuses to consume the alias field regardless of client input — so the two are defense-in-depth, not redundant checks of the same thing.
40
+
41
+ Restored, `grep -c "'alias' in clientBody"` → 0.
42
+
43
+ ## Guards this ships with (structural, not defended by tests alone)
44
+
45
+ - **Proxy field allowlist** (`app/api/hub/nodes/[ref]/attrs/route.ts`): only `base_attrs_revision`, `display_name`, `team`, `tags` are forwarded to the hub. Anything else the client tried to send is dropped at the dashboard boundary, so a future UI bug can't accidentally pipe alias mutations through this proxy.
46
+ - **Editor UI** (`app/components/NodeAttrsEditor.tsx`): alias is rendered as read-only display context ("节点:alias"), never as an editable input. A caption under `display_name` explicitly says the alias stays put.
47
+ - **`display_name` two-question test** (feedback_error_msg_points_at_wrong_layer.md): the caption "仅用于显示,不影响别人 @ 你的路由名(alias 保持 ...)" answers *"if the user assumes it renames them, how long before they realize this path is a dead end?"* — immediately, because the caption is right there.
48
+
49
+ ## Follow-ups not in this PR
50
+
51
+ - **Multi-node bulk edit** (team assignment across N nodes): out of scope for goal-item-3 "一步一步支持"; separate PR when the primary flow is stable.
52
+ - **Search/filter by team/tag** on the nodes page: the data is now stored, but UI faceting is a bigger surface — separate PR.
53
+ - **App-side (mobile) editor**: 通信龙 assigned separately.
54
+ - **Deployment**: dev-only until hub PR #497 lands in an npm release. 通信龙 owns hub release scheduling.