@sleep2agi/agent-network-dashboard 0.6.3-preview.43 → 0.6.3-preview.45
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 -0
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +78 -78
- package/.next/fallback-build-manifest.json +3 -3
- 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.js.nft.json +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 +12 -12
- package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
- package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/admin/page.js.nft.json +1 -1
- 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 +14 -14
- package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
- package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
- package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
- package/.next/server/app/admin.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +14 -14
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/server/app/index.segments/_index.segment.rsc +7 -7
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login/page.js.nft.json +1 -1
- 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 +14 -14
- package/.next/server/app/login.segments/_full.segment.rsc +14 -14
- package/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/.next/server/app/login.segments/_index.segment.rsc +7 -7
- package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/.next/server/app/logs/page.js.nft.json +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 +14 -14
- package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
- package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
- package/.next/server/app/logs.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
- package/.next/server/app/messages/page.js.nft.json +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 +14 -14
- package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
- package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
- package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
- package/.next/server/app/messages.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
- package/.next/server/app/node/page.js.nft.json +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 +14 -14
- package/.next/server/app/node.segments/_full.segment.rsc +14 -14
- package/.next/server/app/node.segments/_head.segment.rsc +4 -4
- package/.next/server/app/node.segments/_index.segment.rsc +7 -7
- package/.next/server/app/node.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/node.segments/node.segment.rsc +3 -3
- 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 +14 -14
- package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
- package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
- package/.next/server/app/nodes.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
- 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.js.nft.json +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 +14 -14
- package/.next/server/app/providers.segments/_full.segment.rsc +14 -14
- package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/providers.segments/_index.segment.rsc +7 -7
- package/.next/server/app/providers.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
- package/.next/server/app/server-logs/page.js.nft.json +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 +14 -14
- package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
- package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
- package/.next/server/app/servers/page.js.nft.json +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 +14 -14
- package/.next/server/app/servers.segments/_full.segment.rsc +14 -14
- package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/servers.segments/_index.segment.rsc +7 -7
- package/.next/server/app/servers.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
- package/.next/server/app/settings/networks/page.js.nft.json +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 +14 -14
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens/page.js.nft.json +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 +14 -14
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings.html +2 -2
- package/.next/server/app/settings.rsc +14 -14
- package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
- package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
- package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks/page.js.nft.json +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 +14 -14
- package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
- package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
- package/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0jns048._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0jns048._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0va6h-y._.js +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0va6h-y._.js.map +1 -1
- package/.next/server/chunks/ssr/{[root-of-the-server]__056tw5g._.js → [root-of-the-server]__0z0lvdf._.js} +2 -2
- package/.next/server/chunks/ssr/[root-of-the-server]__0z0lvdf._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__13svd64._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__13svd64._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_0f4savf._.js +3 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_0f4savf._.js.map +1 -1
- package/.next/server/chunks/ssr/{dash-492_agent-network-dashboard_app_12yiaen._.js → dash-492_agent-network-dashboard_app_07foku.._.js} +2 -2
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_07foku.._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0fv9~x1._.js +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0fv9~x1._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0jey2is._.js +3 -0
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0jey2is._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0tbukk6._.js +3 -0
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0tbukk6._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_10nv4wt._.js +3 -0
- package/.next/server/chunks/ssr/{dash-492_agent-network-dashboard_app_02~ml1y._.js.map → dash-492_agent-network-dashboard_app_10nv4wt._.js.map} +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_components_07gcm8v._.js +3 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_components_07gcm8v._.js.map +1 -1
- 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/0.t9q6b8-tp7o.js +1 -0
- package/.next/static/chunks/{07hmiozabjis~.js → 00.lep~gx75xc.js} +1 -1
- package/.next/static/chunks/0casvy.vqveum.js +7 -0
- package/.next/static/chunks/0e3gktr80d3im.js +2 -0
- package/.next/static/chunks/0gv~xarphwy7~.js +1 -0
- package/.next/static/chunks/0jgo1j.i.wd5q.js +7 -0
- package/.next/static/chunks/0k_3kdj.1c2m5.js +1 -0
- package/.next/static/chunks/0uj3h67aerffm.js +1 -0
- package/.next/static/chunks/0~rml5aiu3yw7.js +7 -0
- package/.next/static/chunks/0~v0m8f1.aob5.js +4 -0
- package/.next/static/chunks/16._eot2r-z1r.js +1 -0
- package/.next/static/chunks/16kecv_vyjyyv.js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/ChatPopover.tsx +9 -2
- package/app/components/HealthBanner.tsx +21 -2
- package/app/components/Sidebar.tsx +21 -2
- package/app/components/StatsBar.tsx +32 -14
- package/app/components/TaskChatPanel.tsx +11 -3
- package/app/components/TopoGraph.tsx +25 -11
- package/app/lib/presence.ts +98 -0
- package/app/page.tsx +23 -14
- package/package.json +1 -1
- package/tests/e2e-515-presence-predicate.spec.ts +75 -0
- package/tests/e2e-515-presence.spec.ts +148 -0
- package/tests/e2e-53-blind-vs-empty.spec.ts +262 -0
- package/tests/e2e-53-presence-status.spec.ts +52 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__056tw5g._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__06g2-qq._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__06g2-qq._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0qv41lg._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0qv41lg._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_02~ml1y._.js +0 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_04nejob._.js +0 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_04nejob._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0kehfih._.js +0 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0kehfih._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0vvr81v._.js +0 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0vvr81v._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0~9qq2o._.js +0 -3
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0~9qq2o._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_12yiaen._.js.map +0 -1
- package/.next/static/chunks/00kg.oomong9m.js +0 -1
- package/.next/static/chunks/02a6-zbyr.zoy.js +0 -2
- package/.next/static/chunks/06ww0tn_jfcfd.js +0 -1
- package/.next/static/chunks/0_90al8hntttb.js +0 -1
- package/.next/static/chunks/0idb.71vziyhz.js +0 -7
- package/.next/static/chunks/0qw7i_y5i75.q.js +0 -1
- package/.next/static/chunks/0szzm0ma-335n.js +0 -4
- package/.next/static/chunks/0xxfl.bqy.bd4.js +0 -7
- package/.next/static/chunks/0yscbgm2a_d1u.js +0 -1
- package/.next/static/chunks/0zj956.f-k1jj.js +0 -7
- package/.next/static/chunks/17ns4oqzib99e.js +0 -1
- /package/.next/static/{BXw6vds1QR_zb-M-KgpT7 → EEoQxe-lggOsAx0h8hIFx}/_buildManifest.js +0 -0
- /package/.next/static/{BXw6vds1QR_zb-M-KgpT7 → EEoQxe-lggOsAx0h8hIFx}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{BXw6vds1QR_zb-M-KgpT7 → EEoQxe-lggOsAx0h8hIFx}/_ssgManifest.js +0 -0
|
@@ -11,6 +11,7 @@ import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIden
|
|
|
11
11
|
import { parseHubTime, relativeAgo } from '../lib/time';
|
|
12
12
|
import { DASHBOARD_VERSION } from '../lib/version';
|
|
13
13
|
import { useChatUnread } from '../lib/chat-unread';
|
|
14
|
+
import { isOnline as presenceIsOnline, sseCountFor as presenceSseCountFor } from '../lib/presence';
|
|
14
15
|
|
|
15
16
|
/** v0.10.0 Hero 1+2 / §3.F server-health hook — fetches the normalized
|
|
16
17
|
* /api/hub/servers payload (preview.370 unblocked real-data via the
|
|
@@ -817,8 +818,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
817
818
|
treeConnectors,
|
|
818
819
|
treeContentBox,
|
|
819
820
|
} = useMemo(() => {
|
|
820
|
-
|
|
821
|
-
|
|
821
|
+
// #515: unified with page.tsx / Sidebar.tsx via app/lib/presence.ts.
|
|
822
|
+
const sseCount = (s: { alias: string; network_id?: string }) => presenceSseCountFor(s, sseSessions);
|
|
822
823
|
// Round 106 (issue #83): sort by alias so same-prefix agents
|
|
823
824
|
// (通信龙 / 通信牛 / 通信工程马 …, or 研究员1号 / 研究员2号 …) end up
|
|
824
825
|
// adjacent in the array — the tier layout below assigns angles by
|
|
@@ -837,6 +838,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
837
838
|
// come back while removing dead nodes well within an operator session.
|
|
838
839
|
const GHOST_MS = 60 * 60 * 1000;
|
|
839
840
|
const now = Date.now();
|
|
841
|
+
// #515 note: isGhost is deliberately NOT converted to presenceIsOnline —
|
|
842
|
+
// it's a different concept (age-based pruning of stale offline nodes) and
|
|
843
|
+
// its `status !== 'offline'` arm gives an intentional aliveness buffer:
|
|
844
|
+
// a node the hub still tags as `working` gets to stick around for the
|
|
845
|
+
// full GHOST_MS even if SSE dropped. Tightening that would silently
|
|
846
|
+
// shrink the visible topology on flaky-SSE hubs — separate change.
|
|
840
847
|
const isGhost = (s: Session) => {
|
|
841
848
|
if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
|
|
842
849
|
// Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
|
|
@@ -845,8 +852,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
845
852
|
const t = parseHubTime(s.last_seen_at);
|
|
846
853
|
return t !== null && now - t > GHOST_MS;
|
|
847
854
|
};
|
|
848
|
-
|
|
849
|
-
|
|
855
|
+
// #515: online = SSE-reachable (shared with page.tsx / Sidebar via presence.ts).
|
|
856
|
+
// Old predicate (`status !== 'offline' || sseCount`) is retired — hub `status` is
|
|
857
|
+
// an unreliable signal on its own (see presence.ts and #520).
|
|
858
|
+
const online = sessions.filter(s => presenceIsOnline(s, sseSessions)).sort(byAlias);
|
|
859
|
+
const offline = sessions.filter(s => !presenceIsOnline(s, sseSessions) && !isGhost(s)).sort(byAlias);
|
|
850
860
|
const positions: Record<string, Point> = {};
|
|
851
861
|
|
|
852
862
|
if (layout === 'grid') {
|
|
@@ -974,7 +984,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
974
984
|
// preserving the node↔label overlap-test guarantee from R19.
|
|
975
985
|
let w = 0, i = 0, o = 0;
|
|
976
986
|
for (const s of band.members) {
|
|
977
|
-
const isOn = s
|
|
987
|
+
const isOn = presenceIsOnline(s, sseSessions);
|
|
978
988
|
if (s.status === 'working') w++;
|
|
979
989
|
else if (isOn) i++;
|
|
980
990
|
else o++;
|
|
@@ -1214,7 +1224,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1214
1224
|
const tally = (members: Session[]) => {
|
|
1215
1225
|
let w = 0, i = 0, o = 0;
|
|
1216
1226
|
for (const s of members) {
|
|
1217
|
-
const isOn = s
|
|
1227
|
+
const isOn = presenceIsOnline(s, sseSessions);
|
|
1218
1228
|
if (s.status === 'working') w++;
|
|
1219
1229
|
else if (isOn) i++;
|
|
1220
1230
|
else o++;
|
|
@@ -4197,7 +4207,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
4197
4207
|
: null;
|
|
4198
4208
|
const allSessions = [...onlineNodes, ...offlineNodes];
|
|
4199
4209
|
const survivors = allSessions.filter(s => {
|
|
4200
|
-
|
|
4210
|
+
// #515: pinned-status filter shares the presence definition too.
|
|
4211
|
+
// Was `s.status !== 'offline'` (pure status), even more divergent
|
|
4212
|
+
// than the count sites — a `working`-but-SSE-disconnected node
|
|
4213
|
+
// would survive the `offline` pin filter and hide inside "online".
|
|
4214
|
+
const isOnline = presenceIsOnline(s, sseSessions);
|
|
4201
4215
|
if (pinnedStatus === 'working' && s.status !== 'working') return false;
|
|
4202
4216
|
if (pinnedStatus === 'idle' && !(isOnline && s.status !== 'working')) return false;
|
|
4203
4217
|
if (pinnedStatus === 'offline' && isOnline) return false;
|
|
@@ -10493,8 +10507,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
10493
10507
|
const pos = nodePositions[session.alias];
|
|
10494
10508
|
if (!pos) return null;
|
|
10495
10509
|
|
|
10496
|
-
|
|
10497
|
-
const
|
|
10510
|
+
// #515: main per-node render uses the shared presence definition.
|
|
10511
|
+
const sseCountFor = presenceSseCountFor(session, sseSessions);
|
|
10512
|
+
const isOnline = presenceIsOnline(session, sseSessions);
|
|
10498
10513
|
const status = nodeStatus(session, isOnline, isLight);
|
|
10499
10514
|
const isActive = activeAliases.has(session.alias);
|
|
10500
10515
|
// #113: node size scales with the S/M/L control; halos, labels,
|
|
@@ -15290,8 +15305,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
15290
15305
|
{[...onlineNodes, ...offlineNodes].map(s => {
|
|
15291
15306
|
const p = nodePositions[s.alias];
|
|
15292
15307
|
if (!p) return null;
|
|
15293
|
-
const
|
|
15294
|
-
const isOn = s.status !== 'offline' || !!sseN;
|
|
15308
|
+
const isOn = presenceIsOnline(s, sseSessions);
|
|
15295
15309
|
const st = nodeStatus(s, isOn, isLight);
|
|
15296
15310
|
return (
|
|
15297
15311
|
/* Round 372 / Loop: minimap offline-dot opacity
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// #515 / #53: single source of truth for "is this node online?" across
|
|
2
|
+
// stats card / sidebar count / topology halos / chat popover / task
|
|
3
|
+
// panel @-mention picker. If you add a new place that decides "online"
|
|
4
|
+
// vs "offline", import isOnline() from here — don't roll your own.
|
|
5
|
+
//
|
|
6
|
+
// Definition: **SSE-reachable with at least one live connection**.
|
|
7
|
+
// Adopted by #214 F2 (originally in app/page.tsx). Consolidated here so
|
|
8
|
+
// consumers never disagree.
|
|
9
|
+
//
|
|
10
|
+
// Why `> 0` and not `!== undefined`:
|
|
11
|
+
// - Hub `getSSEStats()` writes `sessions[name] = arr.length` and the
|
|
12
|
+
// three removal paths (cancel, rekey, dead-pushEvent) each `delete`
|
|
13
|
+
// the key when the array empties. So on the current hub the value
|
|
14
|
+
// is never `0` — but this JSON comes from a hub whose version this
|
|
15
|
+
// dashboard doesn't control (see the legacy alias-only fallback
|
|
16
|
+
// below for the version we already know we support), so treating
|
|
17
|
+
// the map defensively costs nothing and keeps the definition
|
|
18
|
+
// pointing the same direction #214 F2 took: SSE-reachable = "can I
|
|
19
|
+
// talk to it right now", and a zero-count row is not that.
|
|
20
|
+
// - `!!sseLookup(s)` in the old page.tsx also treated 0 as false;
|
|
21
|
+
// preserving that keeps this a pure refactor for page.tsx.
|
|
22
|
+
//
|
|
23
|
+
// SSE keys in health.sse_sessions are `network_id:alias` since v0.7+
|
|
24
|
+
// (per-network scoping). Legacy fallback: bare alias.
|
|
25
|
+
|
|
26
|
+
export type SseSessionsMap = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
export type PresenceSubject = {
|
|
29
|
+
alias: string;
|
|
30
|
+
network_id?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function sseCountFor(session: PresenceSubject, sseSessions: SseSessionsMap | undefined): number | undefined {
|
|
34
|
+
if (!sseSessions) return undefined;
|
|
35
|
+
const scoped = session.network_id ? sseSessions[`${session.network_id}:${session.alias}`] : undefined;
|
|
36
|
+
const value = (scoped ?? sseSessions[session.alias]) as number | undefined;
|
|
37
|
+
return typeof value === 'number' ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isOnline(session: PresenceSubject, sseSessions: SseSessionsMap | undefined): boolean {
|
|
41
|
+
const n = sseCountFor(session, sseSessions);
|
|
42
|
+
return typeof n === 'number' && n > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
// #53: three-state ambient presence signal.
|
|
47
|
+
//
|
|
48
|
+
// `sse_sessions` on hub's /health is **auth-gated** (post `#495-followup`
|
|
49
|
+
// / `f28a6c1b`): an anonymous or wrong-scope caller gets the field
|
|
50
|
+
// **completely omitted** — indistinguishable at the JSON level from
|
|
51
|
+
// "no nodes connected".
|
|
52
|
+
//
|
|
53
|
+
// If we hand that state to isOnline() and only render numbers, the UI
|
|
54
|
+
// shows a confident, consistent, and *wrong* 0 online everywhere and
|
|
55
|
+
// the "three sites disagree" signal #515 used to have — which was
|
|
56
|
+
// itself the clue that something upstream was broken — is gone.
|
|
57
|
+
//
|
|
58
|
+
// So callers need to distinguish three cases:
|
|
59
|
+
//
|
|
60
|
+
// 'ready' — sseSessions is present (even if empty {}). Trust the
|
|
61
|
+
// count. `filter(isOnline).length` is the answer.
|
|
62
|
+
// 'blind' — sseSessions is absent but hub `sse_connections > 0`.
|
|
63
|
+
// Two fields contradict → we are seeing an anonymized
|
|
64
|
+
// view. Do NOT render "0 online"; render an alert.
|
|
65
|
+
// 'loading' — sseSessions is absent and no positive contradiction
|
|
66
|
+
// (either health hasn't loaded, or hub reports 0
|
|
67
|
+
// connections and no map). Render a placeholder — don't
|
|
68
|
+
// inflate the count, but don't cry wolf either.
|
|
69
|
+
//
|
|
70
|
+
// Callers that render user-visible counts MUST branch on this. Callers
|
|
71
|
+
// that just need a per-node boolean (halos, chat-popover badges) can
|
|
72
|
+
// stay on isOnline() — those degrade gracefully to "everyone offline"
|
|
73
|
+
// which is the safer visual, while the ambient banner handles the
|
|
74
|
+
// "why".
|
|
75
|
+
// -----------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export type PresenceStatus = 'ready' | 'blind' | 'loading';
|
|
78
|
+
|
|
79
|
+
export type HealthShape = {
|
|
80
|
+
sse_connections?: number;
|
|
81
|
+
} | null | undefined;
|
|
82
|
+
|
|
83
|
+
export function presenceStatus(
|
|
84
|
+
sseSessions: SseSessionsMap | undefined,
|
|
85
|
+
health: HealthShape,
|
|
86
|
+
): PresenceStatus {
|
|
87
|
+
if (sseSessions !== undefined) return 'ready';
|
|
88
|
+
// sseSessions is absent. Are we blind or truly empty/loading?
|
|
89
|
+
const conns = health && typeof health.sse_connections === 'number' ? health.sse_connections : 0;
|
|
90
|
+
// The contradiction case — hub says there are connections but the
|
|
91
|
+
// map isn't in this response — is the strongest "you're anonymous"
|
|
92
|
+
// signal we have.
|
|
93
|
+
if (conns > 0) return 'blind';
|
|
94
|
+
// No map AND hub reports 0 connections (or health hasn't loaded).
|
|
95
|
+
// Both look identical to us, and both should render placeholders,
|
|
96
|
+
// not "0 online" numbers. Conservative default = 'loading'.
|
|
97
|
+
return 'loading';
|
|
98
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import { useSessions, useHealth, useTasks, useStats } from './lib/hooks';
|
|
|
18
18
|
import { useChatUnread } from './lib/chat-unread';
|
|
19
19
|
import { isPinned, usePinVersion } from './lib/chat-pin';
|
|
20
20
|
import { useSSE } from './lib/useSSE';
|
|
21
|
+
import { isOnline as presenceIsOnline, sseCountFor as presenceSseCountFor, presenceStatus } from './lib/presence';
|
|
21
22
|
import { useSWRConfig } from 'swr';
|
|
22
23
|
|
|
23
24
|
export default function Dashboard() {
|
|
@@ -82,18 +83,20 @@ export default function Dashboard() {
|
|
|
82
83
|
void pinVersion; // re-sort when pins change (isPinned used in comparator)
|
|
83
84
|
if (isLoading) return <LoadingSkeleton />;
|
|
84
85
|
|
|
86
|
+
// #53: distinguish "we have the SSE map" from "we can't see it". The
|
|
87
|
+
// per-node `isOnline` predicate below is fine for halo/legend rendering
|
|
88
|
+
// (degrades to "everyone offline") but the header count MUST NOT
|
|
89
|
+
// confidently show 0 online when we're actually blind — HealthBanner
|
|
90
|
+
// surfaces the "why", and StatsBar renders '?' for online.
|
|
91
|
+
const presenceState = presenceStatus(health?.sse_sessions, health);
|
|
85
92
|
const sseSessions = health?.sse_sessions || {};
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// The old definition (status !== 'offline' OR sse) inflated the count
|
|
92
|
-
// with stale hub statuses and disagreed with /admin and /nodes, which
|
|
93
|
-
// both count pure SSE — and a "online" card you can't chat with
|
|
94
|
-
// contradicts the M2 tap-to-chat model. One definition everywhere.
|
|
95
|
-
const isOnline = (s: { alias: string; status: string; network_id?: string }) => !!sseLookup(s);
|
|
93
|
+
// #515 / #214 F2: presence = SSE-reachable ("can I talk to it right now").
|
|
94
|
+
// Definition lives in app/lib/presence.ts so stats card / sidebar /
|
|
95
|
+
// topology halos always agree. See presence.ts for why hub `status` is
|
|
96
|
+
// an unreliable signal on its own (#520).
|
|
97
|
+
const isOnline = (s: { alias: string; network_id?: string }) => presenceIsOnline(s, sseSessions);
|
|
96
98
|
const online = sessions.filter(isOnline).length;
|
|
99
|
+
const presenceUnknown = presenceState !== 'ready';
|
|
97
100
|
const total = sessions.length;
|
|
98
101
|
const working = sessions.filter(s => s.status === 'working').length;
|
|
99
102
|
// Task stats: prefer /api/stats, fallback to manual
|
|
@@ -135,7 +138,7 @@ export default function Dashboard() {
|
|
|
135
138
|
|
|
136
139
|
return (
|
|
137
140
|
<div className="min-h-screen bg-[#0b0b0d] text-gray-100 p-4 sm:p-6">
|
|
138
|
-
<StatsBar online={online} working={working} total={total} />
|
|
141
|
+
<StatsBar online={online} working={working} total={total} presenceUnknown={presenceUnknown} />
|
|
139
142
|
|
|
140
143
|
{/* #217 S5 (Vincent: "极简极简,这些都可以放到设置里面去"): the
|
|
141
144
|
Dispatch button and UserBar row are gone from Overview.
|
|
@@ -209,12 +212,18 @@ export default function Dashboard() {
|
|
|
209
212
|
</div>
|
|
210
213
|
)}
|
|
211
214
|
|
|
212
|
-
{sessions.length === 0 && !sessError ? (
|
|
215
|
+
{sessions.length === 0 && !sessError && presenceState === 'ready' ? (
|
|
216
|
+
// #53: only pitch "Spin up your first agent" when we actually
|
|
217
|
+
// know the fleet is empty. If presence is blind or still loading,
|
|
218
|
+
// suppress the empty state — HealthBanner already surfaces the
|
|
219
|
+
// real reason and the "first agent" CTA in that context would
|
|
220
|
+
// read as "the fleet died overnight", exactly the failure mode
|
|
221
|
+
// #53 was opened for.
|
|
213
222
|
<EmptyState
|
|
214
223
|
hint={sessHint}
|
|
215
224
|
taskHistoryCount={Object.values(taskStats).reduce((a, b) => a + b, 0)}
|
|
216
225
|
/>
|
|
217
|
-
) : (() => {
|
|
226
|
+
) : sessions.length === 0 && !sessError ? null : (() => {
|
|
218
227
|
const counts = {
|
|
219
228
|
all: sortedSessions.length,
|
|
220
229
|
working: sortedSessions.filter(s => isOnline(s) && s.status === 'working').length,
|
|
@@ -266,7 +275,7 @@ export default function Dashboard() {
|
|
|
266
275
|
xl breakpoint auto-inherits lg=3 cols; 2xl bumps to 4. */}
|
|
267
276
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3 sm:gap-4">
|
|
268
277
|
{filtered.map(s => (
|
|
269
|
-
<AgentCard key={s.alias} session={s} hasSse={isOnline(s)} sseCount={
|
|
278
|
+
<AgentCard key={s.alias} session={s} hasSse={isOnline(s)} sseCount={presenceSseCountFor(s, sseSessions) ?? 0} onChat={cmd.openTab} unreadCount={unreadCount(s.alias)} />
|
|
270
279
|
))}
|
|
271
280
|
</div>
|
|
272
281
|
{filtered.length === 0 && (
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { isOnline, sseCountFor } from '../app/lib/presence';
|
|
3
|
+
|
|
4
|
+
// #515 predicate unit test — the "narrow" companion to
|
|
5
|
+
// tests/e2e-515-presence.spec.ts. That one proves the three DOM
|
|
6
|
+
// consumers agree (integration). This one nails down the specific
|
|
7
|
+
// invariant every consumer now inherits from app/lib/presence.ts:
|
|
8
|
+
//
|
|
9
|
+
// A node with `status='working'` but NO SSE entry MUST classify as
|
|
10
|
+
// NOT online — because hub `status` doesn't age out on heartbeat
|
|
11
|
+
// loss (see presence.ts and #520). Historically L4204's pinnedStatus
|
|
12
|
+
// filter used pure `s.status !== 'offline'`, so pinning "offline"
|
|
13
|
+
// dropped this zombie into "online" and it hid from the offline
|
|
14
|
+
// filter — exactly the bug 通信龙 flagged as needing its own
|
|
15
|
+
// assertion so a future refactor can't silently regress it.
|
|
16
|
+
//
|
|
17
|
+
// If any downstream consumer of `isOnline(...)` starts special-casing
|
|
18
|
+
// `status` again, THIS test goes red — matching the "unified predicate"
|
|
19
|
+
// contract more precisely than a UI-level test could without hitting
|
|
20
|
+
// the pin-intersection chip's two-dim activation requirement.
|
|
21
|
+
|
|
22
|
+
const NET = 'net_a';
|
|
23
|
+
|
|
24
|
+
test.describe('#515 predicate — SSE ground-truths presence, status alone lies', () => {
|
|
25
|
+
test('SSE-reachable with any status → online', () => {
|
|
26
|
+
const sse = { [`${NET}:working-sse`]: 1, [`${NET}:idle-sse`]: 1, [`${NET}:offline-sse`]: 2 };
|
|
27
|
+
expect(isOnline({ alias: 'working-sse', network_id: NET }, sse)).toBe(true);
|
|
28
|
+
expect(isOnline({ alias: 'idle-sse', network_id: NET }, sse)).toBe(true);
|
|
29
|
+
// Even a node the hub already flagged offline is "online" if SSE
|
|
30
|
+
// is talking — the hub row is stale, the socket is ground truth.
|
|
31
|
+
expect(isOnline({ alias: 'offline-sse', network_id: NET }, sse)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('SSE-less with status=working → NOT online (the L4204 zombie case)', () => {
|
|
35
|
+
const sse = { [`${NET}:someone-else`]: 1 };
|
|
36
|
+
// The regression bait — before #515 this same session slipped
|
|
37
|
+
// through the pinned-offline filter and hid inside "online".
|
|
38
|
+
expect(isOnline({ alias: 'working-no-sse', network_id: NET }, sse)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('SSE-less with any status → NOT online', () => {
|
|
42
|
+
const sse = {};
|
|
43
|
+
expect(isOnline({ alias: 'w', network_id: NET }, sse)).toBe(false);
|
|
44
|
+
expect(isOnline({ alias: 'i', network_id: NET }, sse)).toBe(false);
|
|
45
|
+
expect(isOnline({ alias: 'o', network_id: NET }, sse)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('legacy alias-only SSE key still resolves (no network_id scoping)', () => {
|
|
49
|
+
const sse = { 'legacy-node': 1 };
|
|
50
|
+
// Older hubs (< v0.7) key sse_sessions by bare alias. presence
|
|
51
|
+
// falls back to bare-alias when scoped lookup misses.
|
|
52
|
+
expect(isOnline({ alias: 'legacy-node' }, sse)).toBe(true);
|
|
53
|
+
expect(isOnline({ alias: 'legacy-node', network_id: NET }, sse)).toBe(true);
|
|
54
|
+
expect(sseCountFor({ alias: 'legacy-node' }, sse)).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('undefined sseSessions map → NOT online (fail-closed)', () => {
|
|
58
|
+
// If health hasn't loaded yet the map is undefined. Presence
|
|
59
|
+
// MUST fail-closed — treating undefined as "everyone online"
|
|
60
|
+
// would inflate the count during boot.
|
|
61
|
+
expect(isOnline({ alias: 'x', network_id: NET }, undefined)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('SSE value === 0 → NOT online (PR #52 round-2 finding 1)', () => {
|
|
65
|
+
// Older hubs may leave a zero-count entry in sse_sessions when the
|
|
66
|
+
// last connection dropped without cleanup. `!== undefined` would
|
|
67
|
+
// count this as online — inflating in exactly the direction #214 F2
|
|
68
|
+
// set out to eliminate. `> 0` is the SSE-reachable ground truth.
|
|
69
|
+
// page.tsx's old `!!sseLookup(s)` also treated 0 as false — so
|
|
70
|
+
// preserving `> 0` keeps this a pure refactor for that call site.
|
|
71
|
+
const sse = { [`${NET}:dead-edge`]: 0 };
|
|
72
|
+
expect(sseCountFor({ alias: 'dead-edge', network_id: NET }, sse)).toBe(0);
|
|
73
|
+
expect(isOnline({ alias: 'dead-edge', network_id: NET }, sse)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { test, expect, Page } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
const BASE = process.env.TEST_URL || 'http://localhost:3100';
|
|
4
|
+
|
|
5
|
+
// #515 regression test: stats card / sidebar / topology legend must show
|
|
6
|
+
// the SAME "online" count when fed identical hub state. Historically each
|
|
7
|
+
// site computed its own predicate (`isOnline = ...`) and they drifted
|
|
8
|
+
// (page 95, sidebar 99, legend 99) — the fix extracts a single shared
|
|
9
|
+
// presence.isOnline() in app/lib/presence.ts.
|
|
10
|
+
//
|
|
11
|
+
// SCOPE OF THIS TEST — this proves "given the same data, the 3 DOM
|
|
12
|
+
// consumers render the same number", AND that number is the SSE-reachable
|
|
13
|
+
// count (not the status-based count). It does NOT prove the wire fetch
|
|
14
|
+
// gets sse_sessions from the right upstream endpoint — for that, see the
|
|
15
|
+
// production-dashboard "before" screenshot referenced in the PR body and
|
|
16
|
+
// the deploy-time smoke test. See memory
|
|
17
|
+
// feedback_unit_mock_hides_wire_shape_bugs for why one class of evidence
|
|
18
|
+
// doesn't cover the other.
|
|
19
|
+
//
|
|
20
|
+
// Fixture design (deliberately asymmetric):
|
|
21
|
+
// - SSE-reachable count differs from status-based count in ABSOLUTE
|
|
22
|
+
// value, so `expect(x).toBe(EXPECTED)` catches a mutation of the
|
|
23
|
+
// shared predicate (finding: card=sidebar=legend but wrong number)
|
|
24
|
+
// as well as drift (finding: two of three read the same, one different).
|
|
25
|
+
// - Includes a `SSE:0` node to gate the `> 0` fix from PR #52 round 2
|
|
26
|
+
// (findings 1) — a `!== undefined` predicate would count agent-06.
|
|
27
|
+
|
|
28
|
+
const FIXTURE_STATUS = {
|
|
29
|
+
sessions: [
|
|
30
|
+
// present in SSE map AND status alive
|
|
31
|
+
{ alias: 'agent-01', status: 'working', network_id: 'net_a' },
|
|
32
|
+
{ alias: 'agent-02', status: 'idle', network_id: 'net_a' },
|
|
33
|
+
// present in SSE map but hub row is stale — SSE wins
|
|
34
|
+
{ alias: 'agent-03', status: 'offline', network_id: 'net_a' },
|
|
35
|
+
// NOT in SSE map, hub row still says working (the classic zombie)
|
|
36
|
+
{ alias: 'agent-04', status: 'working', network_id: 'net_a' },
|
|
37
|
+
// NOT in SSE map, hub row still says idle
|
|
38
|
+
{ alias: 'agent-05', status: 'idle', network_id: 'net_a' },
|
|
39
|
+
// SSE map value is literally 0 (dead-connection edge from an older
|
|
40
|
+
// hub that didn't clean up empty client arrays; #515 round-2
|
|
41
|
+
// finding 1). Correct behaviour: NOT online.
|
|
42
|
+
{ alias: 'agent-06', status: 'working', network_id: 'net_a' },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const FIXTURE_HEALTH = {
|
|
47
|
+
ok: true,
|
|
48
|
+
version: '0.9.0-test',
|
|
49
|
+
sessions: 6,
|
|
50
|
+
sse_connections: 3,
|
|
51
|
+
sse_sessions: {
|
|
52
|
+
'net_a:agent-01': 1,
|
|
53
|
+
'net_a:agent-02': 1,
|
|
54
|
+
'net_a:agent-03': 1,
|
|
55
|
+
'net_a:agent-06': 0, // dead-connection edge — MUST NOT count as online
|
|
56
|
+
},
|
|
57
|
+
uptime: 1,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// SSE-reachable with count > 0: agent-01, 02, 03 = 3
|
|
61
|
+
// Status-based (retired): agent-01, 02, 04, 05, 06 = 5
|
|
62
|
+
// `sseCountFor !== undefined` (finding 1 bug): 01, 02, 03, 06 = 4
|
|
63
|
+
// Under status mutation → all three consumers agree on 5, absolute
|
|
64
|
+
// assertion catches. Under `!== undefined` regression → all three
|
|
65
|
+
// agree on 4, absolute assertion catches too. Under drift (one
|
|
66
|
+
// consumer reverts to inline old) → one reads 5, others read 3.
|
|
67
|
+
const EXPECTED_ONLINE = 3;
|
|
68
|
+
|
|
69
|
+
async function login(context: Page['context'] extends () => infer C ? C : never) {
|
|
70
|
+
const res = await context.request.post(`${BASE}/api/auth/login`, {
|
|
71
|
+
data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
|
|
72
|
+
});
|
|
73
|
+
const setCookie = res.headers()['set-cookie'] || '';
|
|
74
|
+
const match = setCookie.match(/anet_dashboard_session=([^;]+)/);
|
|
75
|
+
if (match) {
|
|
76
|
+
const domain = new URL(BASE).hostname;
|
|
77
|
+
await context.addCookies([{
|
|
78
|
+
name: 'anet_dashboard_session',
|
|
79
|
+
value: decodeURIComponent(match[1]),
|
|
80
|
+
domain,
|
|
81
|
+
path: '/',
|
|
82
|
+
}]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function mockHubEndpoints(page: Page) {
|
|
87
|
+
await page.route('**/api/hub/status**', async (route) => {
|
|
88
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_STATUS) });
|
|
89
|
+
});
|
|
90
|
+
await page.route('**/api/hub/health', async (route) => {
|
|
91
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_HEALTH) });
|
|
92
|
+
});
|
|
93
|
+
await page.route('**/api/hub/nodes**', async (route) => {
|
|
94
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [] }) });
|
|
95
|
+
});
|
|
96
|
+
await page.route('**/api/hub/tasks**', async (route) => {
|
|
97
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [], _hint: {} }) });
|
|
98
|
+
});
|
|
99
|
+
await page.route('**/api/hub/stats**', async (route) => {
|
|
100
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
|
|
101
|
+
});
|
|
102
|
+
await page.route('**/api/hub/messages**', async (route) => {
|
|
103
|
+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test.describe('#515 presence — one online count everywhere', () => {
|
|
108
|
+
test('stats card / sidebar / topology legend all show the same number', async ({ page, context }) => {
|
|
109
|
+
await login(context);
|
|
110
|
+
await mockHubEndpoints(page);
|
|
111
|
+
await page.goto(`${BASE}/`);
|
|
112
|
+
await expect(page.locator('text=/\\d+\\s*\\/\\s*\\d+\\s+online/i').first()).toBeVisible({ timeout: 20000 });
|
|
113
|
+
await page.waitForTimeout(1500);
|
|
114
|
+
|
|
115
|
+
// Structural anchors — body-text regex is too greedy across the
|
|
116
|
+
// multiple "N Online" phrases on the page.
|
|
117
|
+
const cardText = await page.getByText('Online', { exact: true }).first()
|
|
118
|
+
.evaluate(el => el.previousElementSibling?.textContent ?? null);
|
|
119
|
+
const sidebarText = await page.getByRole('link', { name: /Agent Network/i }).first().innerText();
|
|
120
|
+
const sidebarMatch = sidebarText.match(/(\d+)\s*\/\s*\d+\s+online/i);
|
|
121
|
+
const legendName = await page.getByRole('link', { name: /^\d+\s+online$/i }).first().getAttribute('aria-label')
|
|
122
|
+
|| await page.getByRole('link', { name: /^\d+\s+online$/i }).first().innerText();
|
|
123
|
+
const legendMatch = legendName.match(/(\d+)/);
|
|
124
|
+
|
|
125
|
+
const readings = {
|
|
126
|
+
card: cardText ? Number(cardText.trim()) : null,
|
|
127
|
+
sidebar: sidebarMatch ? Number(sidebarMatch[1]) : null,
|
|
128
|
+
legend: legendMatch ? Number(legendMatch[1]) : null,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
console.log('#515 presence readings:', JSON.stringify(readings));
|
|
132
|
+
|
|
133
|
+
// Strict equality per #515 acceptance: no tolerance, no `>0` sanity.
|
|
134
|
+
// The absolute value catches "shared predicate regressed" (all three
|
|
135
|
+
// agree but wrong); the pairwise equality catches "one consumer
|
|
136
|
+
// reverted to inline old predicate" (drift).
|
|
137
|
+
expect(readings.card).toBe(EXPECTED_ONLINE);
|
|
138
|
+
expect(readings.sidebar).toBe(EXPECTED_ONLINE);
|
|
139
|
+
expect(readings.legend).toBe(EXPECTED_ONLINE);
|
|
140
|
+
expect(readings.card).toBe(readings.sidebar);
|
|
141
|
+
expect(readings.sidebar).toBe(readings.legend);
|
|
142
|
+
|
|
143
|
+
await page.screenshot({
|
|
144
|
+
path: 'test-results/e2e-515-three-numbers-agree.png',
|
|
145
|
+
fullPage: false,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|