@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.
- package/.next/BUILD_COMMIT +1 -1
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +17 -17
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/routes-manifest.json +8 -0
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
- package/.next/server/app/admin.html +2 -2
- package/.next/server/app/admin.rsc +3 -3
- package/.next/server/app/admin.segments/_full.segment.rsc +3 -3
- package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
- package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route/app-paths-manifest.json +3 -0
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route/build-manifest.json +9 -0
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route/server-reference-manifest.json +4 -0
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route.js +7 -0
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route.js.map +5 -0
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route.js.nft.json +1 -0
- package/.next/server/app/api/hub/nodes/[ref]/attrs/route_client-reference-manifest.js +3 -0
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +2 -2
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/logs.html +2 -2
- package/.next/server/app/logs.rsc +2 -2
- package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
- package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
- package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
- package/.next/server/app/messages.html +2 -2
- package/.next/server/app/messages.rsc +3 -3
- package/.next/server/app/messages.segments/_full.segment.rsc +3 -3
- package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
- package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
- package/.next/server/app/node/page_client-reference-manifest.js +1 -1
- package/.next/server/app/node.html +2 -2
- package/.next/server/app/node.rsc +3 -3
- package/.next/server/app/node.segments/_full.segment.rsc +3 -3
- package/.next/server/app/node.segments/_head.segment.rsc +1 -1
- package/.next/server/app/node.segments/_index.segment.rsc +2 -2
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/node.segments/node.segment.rsc +1 -1
- package/.next/server/app/nodes/page.js.nft.json +1 -1
- package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes.html +2 -2
- package/.next/server/app/nodes.rsc +3 -3
- package/.next/server/app/nodes.segments/_full.segment.rsc +3 -3
- package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers.html +2 -2
- package/.next/server/app/providers.rsc +2 -2
- package/.next/server/app/providers.segments/_full.segment.rsc +2 -2
- package/.next/server/app/providers.segments/_head.segment.rsc +1 -1
- package/.next/server/app/providers.segments/_index.segment.rsc +2 -2
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/providers.segments/providers.segment.rsc +1 -1
- package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +2 -2
- package/.next/server/app/server-logs.rsc +2 -2
- package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
- package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/servers.html +2 -2
- package/.next/server/app/servers.rsc +2 -2
- package/.next/server/app/servers.segments/_full.segment.rsc +2 -2
- package/.next/server/app/servers.segments/_head.segment.rsc +1 -1
- package/.next/server/app/servers.segments/_index.segment.rsc +2 -2
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/servers.segments/servers.segment.rsc +1 -1
- package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/networks.html +2 -2
- package/.next/server/app/settings/networks.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens.html +2 -2
- package/.next/server/app/settings/tokens.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +3 -3
- package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
- package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks.html +2 -2
- package/.next/server/app/tasks.rsc +2 -2
- package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +1 -0
- package/.next/server/chunks/0hue__next-internal_server_app_api_hub_nodes_[ref]_attrs_route_actions_0wln_el.js +3 -0
- package/.next/server/chunks/0hue__next-internal_server_app_api_hub_nodes_[ref]_attrs_route_actions_0wln_el.js.map +1 -0
- package/.next/server/chunks/[root-of-the-server]__0xcnv_3._.js +3 -0
- package/.next/server/chunks/[root-of-the-server]__0xcnv_3._.js.map +1 -0
- package/.next/server/chunks/ssr/0_97_f0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_nodes_page_tsx_0gbynyr._.js +1 -1
- package/.next/server/chunks/ssr/0_97_f0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_nodes_page_tsx_0gbynyr._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_01~-ebd._.js +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_01~-ebd._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_04lx.2p._.js +0 -0
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_04lx.2p._.js.map +1 -0
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0lzn~cs._.js +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0lzn~cs._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0m5cykd._.js +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0m5cykd._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0mek_e~._.js +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0mek_e~._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0wz8z_e._.js +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0wz8z_e._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0~ejwde._.js +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_0~ejwde._.js.map +1 -1
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_13qeljx._.js +0 -0
- package/.next/server/chunks/ssr/0s9s_fef0eb55-b39c-4abc-b7ff-48440b9ad805_scratchpad_dashrel_app_13qeljx._.js.map +1 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__0l~vdgn._.js → [root-of-the-server]__0dg_7v_._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0dg_7v_._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0i.14-6._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0i.14-6._.js.map +1 -0
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +2 -2
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/03b2p9debsy1a.js +1 -0
- package/.next/static/chunks/0e4..kvu5tnv-.css +1 -0
- package/.next/static/chunks/0e906ufpl1m9y.js +1 -0
- package/.next/static/chunks/0ujziowx3arw9.js +2 -0
- package/.next/static/chunks/{0.o~6-f11_j6i.js → 0yz.xuueoguvm.js} +1 -1
- package/.next/static/chunks/13ds9xb3_g9e8.js +1 -0
- package/.next/static/chunks/{04y_am25nk4g..js → 13ltv2rvrnldj.js} +1 -1
- package/.next/static/chunks/16_6kx6mkfwg3.js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/.next/types/routes.d.ts +2 -1
- package/.next/types/validator.ts +9 -0
- package/app/api/hub/nodes/[ref]/attrs/route.ts +198 -0
- package/app/components/NodeAttrsEditor.tsx +0 -0
- package/app/components/NodeLifecycleMenu.tsx +22 -2
- package/app/lib/hooks.ts +29 -3
- package/app/nodes/page.tsx +4 -1
- package/package.json +1 -1
- package/tests/e2e-node-attrs-edit/uat.spec.ts +274 -0
- package/tests/e2e-node-attrs-edit/verify-summary.md +54 -0
- package/tests/e2e-node-attrs-edit/verify.sh +239 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__04fj5h4._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__04fj5h4._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0l~vdgn._.js.map +0 -1
- package/.next/static/chunks/01cd3uiuzejbt.js +0 -1
- package/.next/static/chunks/04~ypes~t4khh.js +0 -1
- package/.next/static/chunks/0p~997s2myymh.js +0 -1
- package/.next/static/chunks/0xjf71a0j_v8e.js +0 -1
- package/.next/static/chunks/0xy6sa.x2zvar.css +0 -1
- package/.next/static/chunks/14xnrhzhmv37o.js +0 -2
- /package/.next/static/{FHSqMAj0yD_AXCKEYrGTW → 0XBMlkY9s6UJakDv5hO8n}/_buildManifest.js +0 -0
- /package/.next/static/{FHSqMAj0yD_AXCKEYrGTW → 0XBMlkY9s6UJakDv5hO8n}/_clientMiddlewareManifest.js +0 -0
- /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
|
-
//
|
|
159
|
-
//
|
|
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
|
|
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);
|
package/app/nodes/page.tsx
CHANGED
|
@@ -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
|
@@ -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.
|