@sleep2agi/agent-network-dashboard 0.6.3-preview.44 → 0.6.3-preview.46
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/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +54 -53
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +13 -13
- package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
- package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
- 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 +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 +15 -15
- package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
- package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
- package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- 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 +15 -15
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/server/app/index.segments/_index.segment.rsc +8 -8
- 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 +15 -15
- package/.next/server/app/login.segments/_full.segment.rsc +15 -15
- package/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/.next/server/app/login.segments/_index.segment.rsc +8 -8
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/logs.html +2 -2
- package/.next/server/app/logs.rsc +15 -15
- package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/messages.html +2 -2
- package/.next/server/app/messages.rsc +15 -15
- package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
- package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
- package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/node.html +2 -2
- package/.next/server/app/node.rsc +15 -15
- package/.next/server/app/node.segments/_full.segment.rsc +15 -15
- package/.next/server/app/node.segments/_head.segment.rsc +4 -4
- package/.next/server/app/node.segments/_index.segment.rsc +8 -8
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes.html +2 -2
- package/.next/server/app/nodes.rsc +15 -15
- package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
- package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- 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_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 +15 -15
- package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +2 -2
- package/.next/server/app/server-logs.rsc +15 -15
- package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/servers.html +2 -2
- package/.next/server/app/servers.rsc +15 -15
- package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- 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_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/networks.html +2 -2
- package/.next/server/app/settings/networks.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- 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_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 +15 -15
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- 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 +15 -15
- package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- 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_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 +15 -15
- package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- 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]__0z0lvdf._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0z0lvdf._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__13svd64._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__13svd64._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_0f4savf._.js +2 -2
- 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_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_10nv4wt._.js +1 -1
- package/.next/server/chunks/ssr/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 +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_components_07gcm8v._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_nodes_page_tsx_0txhbdg._.js +1 -1
- package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_nodes_page_tsx_0txhbdg._.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/{03kazq4f2o8kc.js → 0_ni0bc5ggo6b.js} +1 -1
- package/.next/static/chunks/{0b3jrmai2phnc.js → 0bc4t7o-3aw94.js} +1 -1
- package/.next/static/chunks/0ln7jp~g0s.eb.js +2 -0
- package/.next/static/chunks/{0e4..kvu5tnv-.css → 0lskb_50sdmsi.css} +1 -1
- package/.next/static/chunks/0ox1.tctxohjp.js +1 -0
- package/.next/static/chunks/{0mqxob_pphde0.js → 0uvs09f9rhkp..js} +1 -1
- package/.next/static/chunks/0~rml5aiu3yw7.js +7 -0
- package/.next/static/chunks/{17bch5v2vnph5.js → 0~v0m8f1.aob5.js} +2 -2
- package/.next/static/chunks/16kecv_vyjyyv.js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/HealthBanner.tsx +21 -2
- package/app/components/Sidebar.tsx +8 -2
- package/app/components/StatsBar.tsx +32 -14
- package/app/lib/presence.ts +56 -1
- package/app/nodes/page.tsx +136 -0
- package/app/page.tsx +17 -4
- package/package.json +1 -1
- package/tests/e2e-53-blind-vs-empty.spec.ts +262 -0
- package/tests/e2e-53-presence-status.spec.ts +52 -0
- package/tests/e2e-9-nodes-team-tag-filter.spec.ts +222 -0
- package/.next/static/chunks/0adq.v18n1i97.js +0 -7
- package/.next/static/chunks/0e3gktr80d3im.js +0 -2
- package/.next/static/chunks/0ws5f9sp.pgiv.js +0 -1
- /package/.next/static/{VeCvA8ZJHnHbQwP2X4xfs → _rqRjNp9sIzfig_naJBWn}/_buildManifest.js +0 -0
- /package/.next/static/{VeCvA8ZJHnHbQwP2X4xfs → _rqRjNp9sIzfig_naJBWn}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{VeCvA8ZJHnHbQwP2X4xfs → _rqRjNp9sIzfig_naJBWn}/_ssgManifest.js +0 -0
package/app/nodes/page.tsx
CHANGED
|
@@ -56,7 +56,29 @@ export default function NodesPage() {
|
|
|
56
56
|
const sseFor = (s: { alias: string; network_id?: string }) =>
|
|
57
57
|
(s.network_id ? sseMap[`${s.network_id}:${s.alias}`] : undefined) ?? sseMap[s.alias];
|
|
58
58
|
const [filterStatus, setFilterStatus] = useState('');
|
|
59
|
+
// #492 goal-3 step 2: filter by team (single-select, '' = no filter,
|
|
60
|
+
// '__NO_TEAM__' = "nodes without any team assignment"). team match is
|
|
61
|
+
// strict equality against attrsByAlias[alias].team ?? null — nodes
|
|
62
|
+
// WITHOUT a team never leak into "team X" results (per PR review:
|
|
63
|
+
// fixture must have mixed team values and assert exact aliases, not
|
|
64
|
+
// just count).
|
|
65
|
+
const [filterTeam, setFilterTeam] = useState('');
|
|
66
|
+
// #492 goal-3 step 2: tag filter is multi-select with AND semantics —
|
|
67
|
+
// "show nodes that have ALL of these tags". This is a product
|
|
68
|
+
// decision (per PR review: OR would be the naive user default; AND
|
|
69
|
+
// is intentional so operators can narrow down instead of widen out).
|
|
70
|
+
// Rendered chip row shows explicit "AND" separators + empty-state
|
|
71
|
+
// echoes the filter conditions so a zero-result AND intersection
|
|
72
|
+
// never looks like a data-sync bug.
|
|
73
|
+
const [filterTags, setFilterTags] = useState<string[]>([]);
|
|
59
74
|
const [search, setSearch] = useState('');
|
|
75
|
+
const toggleTag = (t: string) => setFilterTags(cur => cur.includes(t) ? cur.filter(x => x !== t) : [...cur, t]);
|
|
76
|
+
// Distinct teams/tags across visible sessions — drives the select
|
|
77
|
+
// options and chip row, and lets us distinguish "cold-start no attrs"
|
|
78
|
+
// from "no match" in the empty state.
|
|
79
|
+
const distinctTeams = Array.from(new Set(sessions.map(s => attrsByAlias[s.alias]?.team).filter((t): t is string => !!t && t.length > 0))).sort();
|
|
80
|
+
const distinctTags = Array.from(new Set(sessions.flatMap(s => attrsByAlias[s.alias]?.tags ?? []).filter(t => t && t.length > 0))).sort();
|
|
81
|
+
const anyAttrs = distinctTeams.length > 0 || distinctTags.length > 0;
|
|
60
82
|
const pinyinReady = usePinyinReady();
|
|
61
83
|
const muteVersion = useMuteVersion();
|
|
62
84
|
void muteVersion; // re-render on mute toggles (isMuted used in map callbacks)
|
|
@@ -93,6 +115,22 @@ export default function NodesPage() {
|
|
|
93
115
|
if (filterStatus && filterStatus !== s.status) return false;
|
|
94
116
|
return true;
|
|
95
117
|
})
|
|
118
|
+
.filter(s => {
|
|
119
|
+
// #492 team filter — strict match. '' = pass all; '__NO_TEAM__' =
|
|
120
|
+
// only nodes with no team assigned; any other value = exact match.
|
|
121
|
+
// Nodes without a team NEVER leak into a "team X" result.
|
|
122
|
+
if (!filterTeam) return true;
|
|
123
|
+
const team = attrsByAlias[s.alias]?.team ?? null;
|
|
124
|
+
if (filterTeam === '__NO_TEAM__') return team === null || team === '';
|
|
125
|
+
return team === filterTeam;
|
|
126
|
+
})
|
|
127
|
+
.filter(s => {
|
|
128
|
+
// #492 tag filter — AND across all chip selections. Zero selected =
|
|
129
|
+
// pass all. Product decision (AND not OR): operators narrow down.
|
|
130
|
+
if (filterTags.length === 0) return true;
|
|
131
|
+
const nodeTags = attrsByAlias[s.alias]?.tags ?? [];
|
|
132
|
+
return filterTags.every(t => nodeTags.includes(t));
|
|
133
|
+
})
|
|
96
134
|
.filter(s => !search || pinyinMatch(s.alias, search) || pinyinMatch(s.agent || '', search))
|
|
97
135
|
.sort((a, b) => ((isPinned(b.alias) ? 1 : 0) - (isPinned(a.alias) ? 1 : 0)) || (sortOnline(b.alias, b.online) - sortOnline(a.alias, a.online)) || lastActivityAt(b.alias) - lastActivityAt(a.alias)); // R41 pin > R29 online-hysteresis > recency
|
|
98
136
|
|
|
@@ -168,6 +206,24 @@ export default function NodesPage() {
|
|
|
168
206
|
<option value="blocked">Blocked</option>
|
|
169
207
|
<option value="error">Error</option>
|
|
170
208
|
</select>
|
|
209
|
+
{/* #492 team select — only shown once at least one node has a
|
|
210
|
+
team attribute. "__NO_TEAM__" surfaces nodes without team,
|
|
211
|
+
so operators can find nodes that never got tagged. */}
|
|
212
|
+
{distinctTeams.length > 0 && (
|
|
213
|
+
<select
|
|
214
|
+
value={filterTeam}
|
|
215
|
+
onChange={e => setFilterTeam(e.target.value)}
|
|
216
|
+
aria-label={t('按 Team 过滤', 'Filter by team')}
|
|
217
|
+
data-testid="filter-team"
|
|
218
|
+
className="bg-[#161618] border border-[#26262b] rounded-lg px-3 py-2 text-base sm:text-sm text-white focus:border-blue-500/50 focus:outline-none"
|
|
219
|
+
>
|
|
220
|
+
<option value="">{t('全部 Team', 'All teams')}</option>
|
|
221
|
+
<option value="__NO_TEAM__">{t('未分组', '(no team)')}</option>
|
|
222
|
+
{distinctTeams.map(team => (
|
|
223
|
+
<option key={team} value={team}>{team}</option>
|
|
224
|
+
))}
|
|
225
|
+
</select>
|
|
226
|
+
)}
|
|
171
227
|
{/* Round 80: List/Grid toggle hidden on mobile — Grid uses
|
|
172
228
|
grid-cols-1 below `md` and List degrades to the same single
|
|
173
229
|
column, so the toggle has no visual effect under sm and only
|
|
@@ -190,6 +246,57 @@ export default function NodesPage() {
|
|
|
190
246
|
</div>
|
|
191
247
|
</div>
|
|
192
248
|
)}
|
|
249
|
+
{/* #492 tag chip row — separate row so multi-select feels distinct
|
|
250
|
+
from single-select controls above. Each chip toggles inclusion;
|
|
251
|
+
selected chips get cyan bg. Explicit "AND" separators between
|
|
252
|
+
selected chips make the join semantics visible in the UI (per
|
|
253
|
+
PR review: silent AND under selected-multiple → 0 results
|
|
254
|
+
reads as bug; the label makes it a deliberate narrow-down). */}
|
|
255
|
+
{sessions.length > 0 && distinctTags.length > 0 && (
|
|
256
|
+
<div className="mb-4 sm:mb-6 flex flex-wrap items-center gap-1.5" data-testid="filter-tags-row">
|
|
257
|
+
<span className="text-[11px] text-gray-500 mr-1">{t('Tags:', 'Tags:')}</span>
|
|
258
|
+
{distinctTags.map((tag, idx) => {
|
|
259
|
+
const selected = filterTags.includes(tag);
|
|
260
|
+
const isNotFirstSelected = selected && filterTags.slice(0, filterTags.indexOf(tag)).length > 0;
|
|
261
|
+
void idx; void isNotFirstSelected;
|
|
262
|
+
return (
|
|
263
|
+
<button
|
|
264
|
+
key={tag}
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => toggleTag(tag)}
|
|
267
|
+
aria-pressed={selected}
|
|
268
|
+
data-tag={tag}
|
|
269
|
+
data-selected={selected ? 'true' : 'false'}
|
|
270
|
+
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
|
271
|
+
selected
|
|
272
|
+
? 'bg-cyan-500/15 text-cyan-200 border-cyan-500/40'
|
|
273
|
+
: 'bg-[#161618] text-gray-400 border-[#26262b] hover:text-gray-200 hover:border-[#3a3a40]'
|
|
274
|
+
}`}
|
|
275
|
+
>
|
|
276
|
+
{tag}
|
|
277
|
+
</button>
|
|
278
|
+
);
|
|
279
|
+
})}
|
|
280
|
+
{filterTags.length >= 2 && (
|
|
281
|
+
<span
|
|
282
|
+
className="text-[10px] text-cyan-300/80 ml-1 tabular-nums"
|
|
283
|
+
data-testid="filter-tags-and-hint"
|
|
284
|
+
>
|
|
285
|
+
{t(`= 同时具有 ${filterTags.length} 个标签`, `= AND of ${filterTags.length} tags`)}
|
|
286
|
+
</span>
|
|
287
|
+
)}
|
|
288
|
+
{(filterTags.length > 0 || filterTeam) && (
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={() => { setFilterTags([]); setFilterTeam(''); }}
|
|
292
|
+
className="text-[11px] text-gray-500 hover:text-gray-200 underline-offset-2 hover:underline ml-2"
|
|
293
|
+
data-testid="clear-attr-filters"
|
|
294
|
+
>
|
|
295
|
+
{t('清除', 'Clear')}
|
|
296
|
+
</button>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
193
300
|
|
|
194
301
|
{loading ? (
|
|
195
302
|
<div className="animate-pulse space-y-2">
|
|
@@ -203,6 +310,31 @@ export default function NodesPage() {
|
|
|
203
310
|
was the "No nodes match your filters" copy, which was wrong
|
|
204
311
|
for new users who have no filters applied. */
|
|
205
312
|
<NodesEmptyState />
|
|
313
|
+
) : (filterTeam || filterTags.length > 0) ? (
|
|
314
|
+
/* #492: attribute-filter no-match — echo the specific filter
|
|
315
|
+
conditions so the user can tell "no nodes exist with this
|
|
316
|
+
combo" from "I mis-typed / data isn't synced". Distinct
|
|
317
|
+
from the generic status/search "No match" below. Under
|
|
318
|
+
AND semantics for multi-tag, this is the copy 通信龙 asked
|
|
319
|
+
for: "没有节点同时具有 A 和 B" instead of "no match". */
|
|
320
|
+
<EmptyState
|
|
321
|
+
variant="nodes"
|
|
322
|
+
title={t('没有节点符合当前属性筛选', 'No nodes match the current attribute filters')}
|
|
323
|
+
sub={(() => {
|
|
324
|
+
const parts: string[] = [];
|
|
325
|
+
if (filterTeam === '__NO_TEAM__') parts.push(t('未分组', 'no-team'));
|
|
326
|
+
else if (filterTeam) parts.push(t(`Team = ${filterTeam}`, `Team = ${filterTeam}`));
|
|
327
|
+
if (filterTags.length === 1) parts.push(t(`标签 ${filterTags[0]}`, `tag ${filterTags[0]}`));
|
|
328
|
+
else if (filterTags.length >= 2) parts.push(t(`同时具有标签 ${filterTags.join(' 和 ')}`, `all of tags: ${filterTags.join(' AND ')}`));
|
|
329
|
+
return t(`没有节点满足:${parts.join(' + ')}。清除筛选或减少标签。`, `No node satisfies: ${parts.join(' + ')}. Clear filters or drop a tag.`);
|
|
330
|
+
})()}
|
|
331
|
+
/>
|
|
332
|
+
) : !anyAttrs && (filterStatus || search) ? (
|
|
333
|
+
<EmptyState
|
|
334
|
+
variant="nodes"
|
|
335
|
+
title={t('还没有节点被打过标签', 'No nodes have been tagged yet')}
|
|
336
|
+
sub={t('先在节点详情里给几个节点分配 team / tag,然后就可以按属性筛选。', 'Assign team / tag to a few nodes first, then attribute filters will show up.')}
|
|
337
|
+
/>
|
|
206
338
|
) : (
|
|
207
339
|
<EmptyState
|
|
208
340
|
variant="nodes"
|
|
@@ -222,6 +354,8 @@ export default function NodesPage() {
|
|
|
222
354
|
key={s.alias}
|
|
223
355
|
role="button"
|
|
224
356
|
tabIndex={0}
|
|
357
|
+
data-node-card
|
|
358
|
+
data-node-alias={s.alias}
|
|
225
359
|
onClick={() => setChatAlias(s.alias)}
|
|
226
360
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setChatAlias(s.alias); } }}
|
|
227
361
|
className={`relative min-w-0 max-w-full overflow-hidden rounded-xl border border-[#26262b] bg-[#161618] p-4 transition-colors hover:border-cyan-500/40 cursor-pointer ${!s.online ? 'opacity-60' : ''}`}
|
|
@@ -316,6 +450,8 @@ export default function NodesPage() {
|
|
|
316
450
|
key={s.alias}
|
|
317
451
|
role="button"
|
|
318
452
|
tabIndex={0}
|
|
453
|
+
data-node-card
|
|
454
|
+
data-node-alias={s.alias}
|
|
319
455
|
onClick={() => setChatAlias(s.alias)}
|
|
320
456
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setChatAlias(s.alias); } }}
|
|
321
457
|
className={`rounded-lg border border-[#26262b] bg-[#161618] px-3 py-2 sm:px-4 sm:py-3 transition-colors hover:border-cyan-500/40 cursor-pointer ${!s.online ? 'opacity-50' : ''}`}
|
package/app/page.tsx
CHANGED
|
@@ -18,7 +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 } from './lib/presence';
|
|
21
|
+
import { isOnline as presenceIsOnline, sseCountFor as presenceSseCountFor, presenceStatus } from './lib/presence';
|
|
22
22
|
import { useSWRConfig } from 'swr';
|
|
23
23
|
|
|
24
24
|
export default function Dashboard() {
|
|
@@ -83,6 +83,12 @@ export default function Dashboard() {
|
|
|
83
83
|
void pinVersion; // re-sort when pins change (isPinned used in comparator)
|
|
84
84
|
if (isLoading) return <LoadingSkeleton />;
|
|
85
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);
|
|
86
92
|
const sseSessions = health?.sse_sessions || {};
|
|
87
93
|
// #515 / #214 F2: presence = SSE-reachable ("can I talk to it right now").
|
|
88
94
|
// Definition lives in app/lib/presence.ts so stats card / sidebar /
|
|
@@ -90,6 +96,7 @@ export default function Dashboard() {
|
|
|
90
96
|
// an unreliable signal on its own (#520).
|
|
91
97
|
const isOnline = (s: { alias: string; network_id?: string }) => presenceIsOnline(s, sseSessions);
|
|
92
98
|
const online = sessions.filter(isOnline).length;
|
|
99
|
+
const presenceUnknown = presenceState !== 'ready';
|
|
93
100
|
const total = sessions.length;
|
|
94
101
|
const working = sessions.filter(s => s.status === 'working').length;
|
|
95
102
|
// Task stats: prefer /api/stats, fallback to manual
|
|
@@ -131,7 +138,7 @@ export default function Dashboard() {
|
|
|
131
138
|
|
|
132
139
|
return (
|
|
133
140
|
<div className="min-h-screen bg-[#0b0b0d] text-gray-100 p-4 sm:p-6">
|
|
134
|
-
<StatsBar online={online} working={working} total={total} />
|
|
141
|
+
<StatsBar online={online} working={working} total={total} presenceUnknown={presenceUnknown} />
|
|
135
142
|
|
|
136
143
|
{/* #217 S5 (Vincent: "极简极简,这些都可以放到设置里面去"): the
|
|
137
144
|
Dispatch button and UserBar row are gone from Overview.
|
|
@@ -205,12 +212,18 @@ export default function Dashboard() {
|
|
|
205
212
|
</div>
|
|
206
213
|
)}
|
|
207
214
|
|
|
208
|
-
{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.
|
|
209
222
|
<EmptyState
|
|
210
223
|
hint={sessHint}
|
|
211
224
|
taskHistoryCount={Object.values(taskStats).reduce((a, b) => a + b, 0)}
|
|
212
225
|
/>
|
|
213
|
-
) : (() => {
|
|
226
|
+
) : sessions.length === 0 && !sessError ? null : (() => {
|
|
214
227
|
const counts = {
|
|
215
228
|
all: sortedSessions.length,
|
|
216
229
|
working: sortedSessions.filter(s => isOnline(s) && s.status === 'working').length,
|
package/package.json
CHANGED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { test, expect, Page } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
const BASE = process.env.TEST_URL || 'http://localhost:3100';
|
|
4
|
+
|
|
5
|
+
// #53 DOM behaviour — the "blind" vs "truly empty" fixtures MUST render
|
|
6
|
+
// distinct UIs. Same-render is exactly the regression #53 was opened
|
|
7
|
+
// for (unified predicate collapses "can't see" and "nothing there" into
|
|
8
|
+
// a confident, consistent, and wrong 0 online).
|
|
9
|
+
//
|
|
10
|
+
// Acceptance criterion from the issue:
|
|
11
|
+
// > 两个 fixture 必须都在,且断言互不相同 —— 否则又回到「两种情形一个画面」
|
|
12
|
+
//
|
|
13
|
+
// PR #54 review round-2 added a third fixture — BLIND (populated fleet)
|
|
14
|
+
// — because /api/hub/status and /health have INDEPENDENT auth gates, so
|
|
15
|
+
// "status returned sessions, health omitted sse_sessions" is a real
|
|
16
|
+
// reachable combination that only the populated-fleet fixture covers.
|
|
17
|
+
//
|
|
18
|
+
// Assertion matrix (each column must have at least one row unique to
|
|
19
|
+
// itself, and each row must be independently red-provable — a targeted
|
|
20
|
+
// mutation of the code that assertion protects should make ONLY that
|
|
21
|
+
// row's test go red, not just the earliest gate in that test):
|
|
22
|
+
//
|
|
23
|
+
// | BLIND(empty) BLIND(populated) EMPTY
|
|
24
|
+
// "? online" strip | ✓ — ✗ (not.toMatch)
|
|
25
|
+
// "0 online" strip | ✗ — ✓
|
|
26
|
+
// "presence unavail" | — ✓ ✗
|
|
27
|
+
// HealthBanner text | ✓ ✓ ✗
|
|
28
|
+
// agent-alpha visible | — ✓ — ← proves the
|
|
29
|
+
// | grid actually
|
|
30
|
+
// | mounted, so
|
|
31
|
+
// | "cell isn't a
|
|
32
|
+
// | number" can't
|
|
33
|
+
// | pass on an
|
|
34
|
+
// | unrendered grid
|
|
35
|
+
// Online cell === '?' | — ✓ —
|
|
36
|
+
// "Spin up..." pitch | ✗ ✗ — (may or may not)
|
|
37
|
+
//
|
|
38
|
+
// Witnessed-red evidence for each fixture:
|
|
39
|
+
// - BLIND(empty) ← mutate presenceStatus → always 'ready': banner
|
|
40
|
+
// gate at "Unable to read presence" fails.
|
|
41
|
+
// - EMPTY ← the "always 'ready'" mutation doesn't red this
|
|
42
|
+
// test because EMPTY *wants* 'ready'. The
|
|
43
|
+
// regression EMPTY protects is the OPPOSITE
|
|
44
|
+
// direction: "someone treats sse_sessions === {}
|
|
45
|
+
// as if the map were missing". Two targeted
|
|
46
|
+
// mutations of presence.ts, each catches EMPTY
|
|
47
|
+
// at a different assertion — the pair is what
|
|
48
|
+
// proves EMPTY isn't relying on one accidental
|
|
49
|
+
// assertion:
|
|
50
|
+
//
|
|
51
|
+
// (a) `if (sseSessions && Object.keys(sseSessions)
|
|
52
|
+
// .length > 0) return 'ready';` (requires
|
|
53
|
+
// non-empty map to be 'ready')
|
|
54
|
+
// → {} + conns=0 falls through to the conns
|
|
55
|
+
// check → returns 'loading' (NOT 'blind',
|
|
56
|
+
// because EMPTY fixture has conns=0).
|
|
57
|
+
// → page.tsx sets presenceUnknown=true.
|
|
58
|
+
// → StatsBar renders '? online' not '0 online'.
|
|
59
|
+
// → RED at L223: expect(bodyText).toMatch(
|
|
60
|
+
// /\b0\s+online\b/i) — Expected pattern
|
|
61
|
+
// /\b0\s+online\b/i, received "agent
|
|
62
|
+
// network..." (the '0 online' text is
|
|
63
|
+
// gone from the strip).
|
|
64
|
+
//
|
|
65
|
+
// (b) `if (sseSessions && Object.keys(sseSessions)
|
|
66
|
+
// .length > 0) return 'ready'; if (sseSessions
|
|
67
|
+
// !== undefined) return 'blind';` (forces
|
|
68
|
+
// empty-map to 'blind' directly)
|
|
69
|
+
// → {} → 'blind' regardless of conns.
|
|
70
|
+
// → HealthBanner fires the red presence-blind
|
|
71
|
+
// banner.
|
|
72
|
+
// → RED at L217: expect(bodyText.toLowerCase())
|
|
73
|
+
// .not.toContain('unable to read presence')
|
|
74
|
+
// — Expected NOT to contain, received
|
|
75
|
+
// "agent network... unable to read presence
|
|
76
|
+
// — hub reports 0 live SSE...".
|
|
77
|
+
//
|
|
78
|
+
// Both mutations are covered by real runs, not
|
|
79
|
+
// code inspection. Together they show EMPTY's
|
|
80
|
+
// assertion set is defended from multiple angles
|
|
81
|
+
// of the "collapse map={} with map=missing"
|
|
82
|
+
// failure mode — the exact regression #53 opened
|
|
83
|
+
// against.
|
|
84
|
+
// - BLIND(populated) ← 🔴 mutate ONLY StatsBar (make onlineDisplay
|
|
85
|
+
// ignore presenceUnknown and always render numeric
|
|
86
|
+
// `online`): L152 fails with
|
|
87
|
+
// `Expected: "?" Received: "0"`. This proves
|
|
88
|
+
// the '?' assertion targets StatsBar's presence-
|
|
89
|
+
// unknown branch specifically, not merely the
|
|
90
|
+
// earlier banner-visible gate.
|
|
91
|
+
|
|
92
|
+
const HUB_STATUS_ANONYMOUS = {
|
|
93
|
+
// Hub server, when called anonymously / with wrong-scope token,
|
|
94
|
+
// returns 0 sessions AND omits sse_sessions on /health.
|
|
95
|
+
sessions: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// PR #54 review — the two endpoints (/api/hub/status and /health) have
|
|
99
|
+
// independent auth gates, so "status returned sessions, but health's
|
|
100
|
+
// sse_sessions is missing" is a real reachable combination. This
|
|
101
|
+
// fixture exercises the StatCard populated path — a distinct render
|
|
102
|
+
// branch from the empty-fleet strip that the two anonymous fixtures
|
|
103
|
+
// above hit.
|
|
104
|
+
const HUB_STATUS_POPULATED = {
|
|
105
|
+
sessions: [
|
|
106
|
+
{ alias: 'agent-alpha', status: 'working', network_id: 'net_x' },
|
|
107
|
+
{ alias: 'agent-beta', status: 'idle', network_id: 'net_x' },
|
|
108
|
+
{ alias: 'agent-gamma', status: 'idle', network_id: 'net_x' },
|
|
109
|
+
{ alias: 'agent-delta', status: 'working', network_id: 'net_x' },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Health payload for the BLIND fixture — omits `sse_sessions` entirely
|
|
114
|
+
// but reports live connections. That contradiction is the signal.
|
|
115
|
+
const HUB_HEALTH_BLIND = {
|
|
116
|
+
ok: true,
|
|
117
|
+
version: '0.9.0-test',
|
|
118
|
+
sse_connections: 97,
|
|
119
|
+
// Note: sse_sessions deliberately NOT included. That's the whole
|
|
120
|
+
// point of the fixture — the map is missing while conns > 0.
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Health payload for the EMPTY fixture — map is present and empty,
|
|
124
|
+
// conns is 0. Truly no nodes connected.
|
|
125
|
+
const HUB_HEALTH_EMPTY = {
|
|
126
|
+
ok: true,
|
|
127
|
+
version: '0.9.0-test',
|
|
128
|
+
sse_connections: 0,
|
|
129
|
+
sse_sessions: {},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
async function login(context: Page['context'] extends () => infer C ? C : never) {
|
|
133
|
+
const res = await context.request.post(`${BASE}/api/auth/login`, {
|
|
134
|
+
data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
|
|
135
|
+
});
|
|
136
|
+
const setCookie = res.headers()['set-cookie'] || '';
|
|
137
|
+
const match = setCookie.match(/anet_dashboard_session=([^;]+)/);
|
|
138
|
+
if (match) {
|
|
139
|
+
const domain = new URL(BASE).hostname;
|
|
140
|
+
await context.addCookies([{
|
|
141
|
+
name: 'anet_dashboard_session',
|
|
142
|
+
value: decodeURIComponent(match[1]),
|
|
143
|
+
domain,
|
|
144
|
+
path: '/',
|
|
145
|
+
}]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function mockCommon(page: Page, statusBody: object, healthBody: object) {
|
|
150
|
+
await page.route('**/api/hub/status**', route =>
|
|
151
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusBody) }));
|
|
152
|
+
await page.route('**/api/hub/health', route =>
|
|
153
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(healthBody) }));
|
|
154
|
+
// Cheap stubs — HealthBanner also polls /api/hub/stats + /tasks, so
|
|
155
|
+
// return empty happy shapes to keep the amber "N failed" and green
|
|
156
|
+
// "all systems go" branches inert.
|
|
157
|
+
await page.route('**/api/hub/stats**', route =>
|
|
158
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, tasks: { by_status: [] }, nodes: { total: 0 } }) }));
|
|
159
|
+
await page.route('**/api/hub/tasks**', route =>
|
|
160
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [], _hint: {} }) }));
|
|
161
|
+
await page.route('**/api/hub/nodes**', route =>
|
|
162
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [] }) }));
|
|
163
|
+
await page.route('**/api/hub/messages**', route =>
|
|
164
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
test.describe('#53 blind vs empty — different UIs', () => {
|
|
168
|
+
test('BLIND: sse_sessions absent + conns > 0 → alert + "?" online + NO empty-state pitch', async ({ page, context }) => {
|
|
169
|
+
await login(context);
|
|
170
|
+
await mockCommon(page, HUB_STATUS_ANONYMOUS, HUB_HEALTH_BLIND);
|
|
171
|
+
await page.goto(`${BASE}/`);
|
|
172
|
+
// Wait for the HealthBanner blind message to appear.
|
|
173
|
+
await expect(page.locator('text=/Unable to read presence/i').first()).toBeVisible({ timeout: 15000 });
|
|
174
|
+
|
|
175
|
+
const bodyText = await page.locator('body').innerText();
|
|
176
|
+
|
|
177
|
+
// The "?" online — in the empty-fleet strip (StatsBar renders the
|
|
178
|
+
// strip when total===0). Under blind, the strip reads "? online"
|
|
179
|
+
// instead of "0 online". This is the DOM signal that must NOT
|
|
180
|
+
// match the truly-empty fixture below.
|
|
181
|
+
expect(bodyText).toMatch(/\?\s+online\b/);
|
|
182
|
+
expect(bodyText).not.toMatch(/\b0\s+online\b/);
|
|
183
|
+
|
|
184
|
+
// MUST NOT pitch "Spin up your first agent" in this state.
|
|
185
|
+
expect(bodyText).not.toContain('Spin up your first agent');
|
|
186
|
+
|
|
187
|
+
// Positive: HealthBanner alert copy is present.
|
|
188
|
+
expect(bodyText.toLowerCase()).toContain('unable to read presence');
|
|
189
|
+
// Positive: the alert names the "live SSE" side of the
|
|
190
|
+
// contradiction. Reviewer note: an earlier draft also accepted
|
|
191
|
+
// /97/, but that digit can incidentally match other numbers on
|
|
192
|
+
// the page (versions, other counters) — tightening to the phrase
|
|
193
|
+
// that only the banner uses.
|
|
194
|
+
expect(bodyText).toMatch(/live SSE/i);
|
|
195
|
+
|
|
196
|
+
await page.screenshot({ path: 'test-results/e2e-53-blind.png', fullPage: false });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('BLIND (populated fleet): StatCard grid renders "?" for Online + "presence unavailable" subtitle', async ({ page, context }) => {
|
|
200
|
+
await login(context);
|
|
201
|
+
await mockCommon(page, HUB_STATUS_POPULATED, HUB_HEALTH_BLIND);
|
|
202
|
+
await page.goto(`${BASE}/`);
|
|
203
|
+
await expect(page.locator('text=/Unable to read presence/i').first()).toBeVisible({ timeout: 15000 });
|
|
204
|
+
await page.waitForTimeout(1500);
|
|
205
|
+
|
|
206
|
+
const bodyText = await page.locator('body').innerText();
|
|
207
|
+
|
|
208
|
+
// Positive proof the StatCard grid actually rendered — otherwise
|
|
209
|
+
// "Online cell isn't a number" would spuriously pass just because
|
|
210
|
+
// the whole grid failed to mount. Assert we see at least one of
|
|
211
|
+
// the fixture aliases, which appear in the AgentCard grid below
|
|
212
|
+
// the StatsBar.
|
|
213
|
+
expect(bodyText).toContain('agent-alpha');
|
|
214
|
+
|
|
215
|
+
// The StatCard "Online" cell — under populated + blind, the value
|
|
216
|
+
// is '?' and the subtitle drops to 'presence unavailable' (both
|
|
217
|
+
// set by StatsBar via the presenceUnknown prop). This is the
|
|
218
|
+
// primary reachable path in real traffic: hub returned sessions
|
|
219
|
+
// (status endpoint auth held), health omitted sse_sessions (its
|
|
220
|
+
// own auth gate tripped). Two independent auth gates, one
|
|
221
|
+
// rendering combination.
|
|
222
|
+
const onlineCardValue = await page.getByText('Online', { exact: true }).first()
|
|
223
|
+
.evaluate(el => el.previousElementSibling?.textContent ?? null);
|
|
224
|
+
expect(onlineCardValue?.trim()).toBe('?');
|
|
225
|
+
|
|
226
|
+
// Subtitle underneath the card.
|
|
227
|
+
expect(bodyText.toLowerCase()).toContain('presence unavailable');
|
|
228
|
+
|
|
229
|
+
// Empty-state pitch must NOT appear even though total > 0 (it's
|
|
230
|
+
// gated on sessions.length === 0), but assert it anyway — the
|
|
231
|
+
// gate condition on presenceState covers a different path and we
|
|
232
|
+
// want either coverage to catch a regression.
|
|
233
|
+
expect(bodyText).not.toContain('Spin up your first agent');
|
|
234
|
+
|
|
235
|
+
await page.screenshot({ path: 'test-results/e2e-53-populated-blind.png', fullPage: false });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('EMPTY: sse_sessions = {} + conns = 0 → NO presence-blind alert + numeric 0 online', async ({ page, context }) => {
|
|
239
|
+
await login(context);
|
|
240
|
+
await mockCommon(page, HUB_STATUS_ANONYMOUS, HUB_HEALTH_EMPTY);
|
|
241
|
+
await page.goto(`${BASE}/`);
|
|
242
|
+
// Wait for the page to settle. Something needs to render — pick a
|
|
243
|
+
// stable, non-count anchor.
|
|
244
|
+
await expect(page.locator('h1', { hasText: 'Agent Network' }).first()).toBeVisible({ timeout: 15000 });
|
|
245
|
+
await page.waitForTimeout(1500);
|
|
246
|
+
|
|
247
|
+
const bodyText = await page.locator('body').innerText();
|
|
248
|
+
|
|
249
|
+
// The presence-blind alert MUST NOT fire when the map is truly
|
|
250
|
+
// present-and-empty — that would be a false alarm the other way.
|
|
251
|
+
expect(bodyText.toLowerCase()).not.toContain('unable to read presence');
|
|
252
|
+
|
|
253
|
+
// In truly-empty state, presenceStatus === 'ready', so the online
|
|
254
|
+
// cell is a real number (0). Confirm it's NOT "?".
|
|
255
|
+
// (Empty-fleet path uses a thin strip, not the StatCard grid, so
|
|
256
|
+
// we grep the strip text.)
|
|
257
|
+
expect(bodyText).toMatch(/\b0\s+online\b/i);
|
|
258
|
+
expect(bodyText).not.toContain('presence unavailable');
|
|
259
|
+
|
|
260
|
+
await page.screenshot({ path: 'test-results/e2e-53-empty.png', fullPage: false });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { presenceStatus } from '../app/lib/presence';
|
|
3
|
+
|
|
4
|
+
// #53 — three-state ambient presence. Companion to the
|
|
5
|
+
// e2e-53-blind-vs-empty DOM test: this one nails down the predicate
|
|
6
|
+
// contract every consumer inherits from presenceStatus().
|
|
7
|
+
//
|
|
8
|
+
// Two hand-computed fixtures cover the two states that must NEVER
|
|
9
|
+
// collapse to the same UI:
|
|
10
|
+
// - sseSessions === undefined & health.sse_connections > 0 → 'blind'
|
|
11
|
+
// (hub says there are live SSE, but the auth-gated map is missing
|
|
12
|
+
// from THIS response — anonymous / wrong scope; must NOT render 0)
|
|
13
|
+
// - sseSessions === {} & health.sse_connections = 0 → 'ready'
|
|
14
|
+
// (map is present and truly empty; safe to render 0 online)
|
|
15
|
+
//
|
|
16
|
+
// Also:
|
|
17
|
+
// - sseSessions undefined & no health → 'loading' (boot)
|
|
18
|
+
// - sseSessions undefined & health.sse_connections = 0 → 'loading'
|
|
19
|
+
// (conservative — both look identical to us and neither should
|
|
20
|
+
// inflate)
|
|
21
|
+
|
|
22
|
+
test.describe('#53 presenceStatus — undefined vs empty must NOT collapse', () => {
|
|
23
|
+
test('sseSessions absent + hub reports live connections → blind', () => {
|
|
24
|
+
expect(presenceStatus(undefined, { sse_connections: 97 })).toBe('blind');
|
|
25
|
+
expect(presenceStatus(undefined, { sse_connections: 1 })).toBe('blind');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('sseSessions absent + hub reports 0 connections → loading (conservative)', () => {
|
|
29
|
+
// We can't tell "no map + 0 conns" from "hub-connection is stale on
|
|
30
|
+
// its own reporting" — treat as loading and don't render "0 online".
|
|
31
|
+
expect(presenceStatus(undefined, { sse_connections: 0 })).toBe('loading');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('sseSessions absent + no health at all → loading (boot)', () => {
|
|
35
|
+
expect(presenceStatus(undefined, null)).toBe('loading');
|
|
36
|
+
expect(presenceStatus(undefined, undefined)).toBe('loading');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('sseSessions present (even empty) → ready — trust the count', () => {
|
|
40
|
+
// The whole point of #53: "map is {} " means "the caller CAN see
|
|
41
|
+
// presence, and there's nothing to see". That is a DIFFERENT
|
|
42
|
+
// condition from "map is missing entirely" and must render
|
|
43
|
+
// differently.
|
|
44
|
+
expect(presenceStatus({}, { sse_connections: 0 })).toBe('ready');
|
|
45
|
+
expect(presenceStatus({ 'net_a:one': 1 }, { sse_connections: 1 })).toBe('ready');
|
|
46
|
+
// Even a self-contradictory response (map says 0 but conns > 0)
|
|
47
|
+
// is still 'ready' at the map level — the map is authoritative
|
|
48
|
+
// for who's online; the count-vs-map mismatch is a hub-side bug,
|
|
49
|
+
// not a presence-blindness signal.
|
|
50
|
+
expect(presenceStatus({}, { sse_connections: 5 })).toBe('ready');
|
|
51
|
+
});
|
|
52
|
+
});
|