@sleep2agi/agent-network-dashboard 0.4.6-preview.3 → 0.4.6-preview.5
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_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +4 -4
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-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.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- 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 +1 -1
- package/.next/server/app/admin.html +1 -1
- package/.next/server/app/admin.rsc +1 -1
- package/.next/server/app/admin.segments/_full.segment.rsc +1 -1
- package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
- package/.next/server/app/admin.segments/_index.segment.rsc +1 -1
- package/.next/server/app/admin.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +2 -2
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/server/app/index.segments/_tree.segment.rsc +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 +2 -2
- package/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/.next/server/app/login.segments/_index.segment.rsc +1 -1
- package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- 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.html +1 -1
- package/.next/server/app/logs.rsc +1 -1
- package/.next/server/app/logs.segments/_full.segment.rsc +1 -1
- package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/logs.segments/_index.segment.rsc +1 -1
- package/.next/server/app/logs.segments/_tree.segment.rsc +1 -1
- 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.html +1 -1
- package/.next/server/app/messages.rsc +1 -1
- package/.next/server/app/messages.segments/_full.segment.rsc +1 -1
- package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
- package/.next/server/app/messages.segments/_index.segment.rsc +1 -1
- package/.next/server/app/messages.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
- package/.next/server/app/node.html +1 -1
- package/.next/server/app/node.rsc +1 -1
- package/.next/server/app/node.segments/_full.segment.rsc +1 -1
- package/.next/server/app/node.segments/_head.segment.rsc +1 -1
- package/.next/server/app/node.segments/_index.segment.rsc +1 -1
- package/.next/server/app/node.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/node.segments/node.segment.rsc +1 -1
- package/.next/server/app/nodes.html +1 -1
- package/.next/server/app/nodes.rsc +1 -1
- package/.next/server/app/nodes.segments/_full.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/_index.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +1 -1
- package/.next/server/app/server-logs.rsc +1 -1
- package/.next/server/app/server-logs.segments/_full.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/_index.segment.rsc +1 -1
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +1 -1
- 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/settings/networks.html +1 -1
- package/.next/server/app/settings/networks.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +1 -1
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +1 -1
- 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.html +1 -1
- package/.next/server/app/settings/tokens.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +1 -1
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +1 -1
- 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 +2 -2
- package/.next/server/app/settings.segments/_full.segment.rsc +2 -2
- package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_index.segment.rsc +1 -1
- package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- 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.html +1 -1
- package/.next/server/app/tasks.rsc +1 -1
- package/.next/server/app/tasks.segments/_full.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/_index.segment.rsc +1 -1
- package/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
- 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/chunks/ssr/_0nne7mz._.js +1 -1
- package/.next/server/chunks/ssr/_0nne7mz._.js.map +1 -1
- package/.next/server/chunks/ssr/app_0aum_g5._.js +1 -1
- package/.next/server/chunks/ssr/app_0aum_g5._.js.map +1 -1
- package/.next/server/chunks/ssr/app_0esuljp._.js +1 -1
- package/.next/server/chunks/ssr/app_0esuljp._.js.map +1 -1
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/0exo44x.p_gq7.js +1 -0
- package/.next/static/chunks/{0218jmcfde~9s.js → 0u0_jo83jrziw.js} +1 -1
- package/.next/static/chunks/{0z1xslnw3jx1s.js → 12_8n4k-hg72i.js} +1 -1
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/TopoGraph.tsx +73 -29
- package/app/components/types.ts +4 -0
- package/app/lib/vendorIdentity.ts +145 -0
- package/app/page.tsx +18 -1
- package/package.json +1 -1
- package/scripts/topo-rename-test.mjs +106 -0
- package/scripts/topo-vendor-test.mjs +96 -0
- package/.next/static/chunks/13j9vr8_l9ket.js +0 -1
- /package/.next/static/{kXeBUTKSdNooA8p2BTTfp → FXhbXa0x4YZ0yjsgUY54q}/_buildManifest.js +0 -0
- /package/.next/static/{kXeBUTKSdNooA8p2BTTfp → FXhbXa0x4YZ0yjsgUY54q}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{kXeBUTKSdNooA8p2BTTfp → FXhbXa0x4YZ0yjsgUY54q}/_ssgManifest.js +0 -0
|
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
4
4
|
import { Session } from './types';
|
|
5
5
|
import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
|
|
6
6
|
import { ChatPopover } from './ChatPopover';
|
|
7
|
+
import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
|
|
7
8
|
|
|
8
9
|
interface MessageFlow {
|
|
9
10
|
from_alias: string;
|
|
@@ -15,6 +16,9 @@ interface MessageFlow {
|
|
|
15
16
|
interface TopoGraphProps {
|
|
16
17
|
sessions: Session[];
|
|
17
18
|
sseSessions: Record<string, number>;
|
|
19
|
+
// #84: a node.renamed event from the Overview's SSE listener. When the
|
|
20
|
+
// currently open chat popover targets `from`, it follows the rename to `to`.
|
|
21
|
+
renameSignal?: { from: string; to: string; ts: number } | null;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
interface Point {
|
|
@@ -283,7 +287,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
|
|
|
283
287
|
return [...links.values()].slice(0, 18);
|
|
284
288
|
}
|
|
285
289
|
|
|
286
|
-
export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
290
|
+
export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
|
|
287
291
|
const theme = useTheme();
|
|
288
292
|
const isLight = theme === 'light';
|
|
289
293
|
const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
|
|
@@ -485,6 +489,16 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
|
485
489
|
// Issue #100: singleton chat popover. One alias at a time — clicking
|
|
486
490
|
// another node swaps the target and the conversation switches in place.
|
|
487
491
|
const [chatAlias, setChatAlias] = useState<string | null>(null);
|
|
492
|
+
// #84: when a node is renamed while its chat popover is open, follow the
|
|
493
|
+
// rename so the conversation keeps targeting a live alias.
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
if (renameSignal && renameSignal.from === chatAlias) {
|
|
496
|
+
setChatAlias(renameSignal.to);
|
|
497
|
+
}
|
|
498
|
+
// chatAlias intentionally omitted — only react to a new rename signal,
|
|
499
|
+
// not to the user opening/closing the popover.
|
|
500
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
501
|
+
}, [renameSignal]);
|
|
488
502
|
|
|
489
503
|
useEffect(() => { viewRef.current = view; }, [view]);
|
|
490
504
|
|
|
@@ -908,6 +922,10 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
|
908
922
|
onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
|
|
909
923
|
onClick={() => setChatAlias(session.alias)}
|
|
910
924
|
>
|
|
925
|
+
{/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
|
|
926
|
+
Falls back to just the alias when the node reports no
|
|
927
|
+
model/runtime. */}
|
|
928
|
+
<title>{identityLine(session.model, session.runtime) || session.alias}</title>
|
|
911
929
|
{isActive && (
|
|
912
930
|
<circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
|
|
913
931
|
<animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
|
|
@@ -925,33 +943,26 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
|
925
943
|
strokeDasharray={isOnline ? 'none' : '5 5'}
|
|
926
944
|
filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
|
|
927
945
|
/>
|
|
928
|
-
{/*
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
946
|
+
{/* Issue #96: node "avatar" is now driven by the model
|
|
947
|
+
vendor. Decision order:
|
|
948
|
+
1. ?brand=intern flag, or an intern-aliased node with
|
|
949
|
+
no model field → 书生 coin (preserves #79).
|
|
950
|
+
2. vendor has a packaged logo asset → that logo image
|
|
951
|
+
(intern-s1-* models land here via vendorForModel).
|
|
952
|
+
3. known vendor, logo asset not shipped yet → a
|
|
953
|
+
vendor-tinted monogram (spec-mandated fallback).
|
|
954
|
+
4. unknown vendor / null model → the prefix-group
|
|
955
|
+
hue-hashed initial (#83/#99 behaviour, unchanged). */}
|
|
935
956
|
{(() => {
|
|
936
957
|
const ar = isOnline ? 14 : 10;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
// forces the coin on every node (full brand showcase).
|
|
943
|
-
const isInternNode = isIntern || /书生|书小生|intern/i.test(session.alias);
|
|
944
|
-
if (isInternNode) {
|
|
945
|
-
// Round 102: self-contained "avatar coin" — 书小生 figure
|
|
946
|
-
// keyed off its white background and composited onto a
|
|
947
|
-
// cream circular backplate. The cream coin gives the
|
|
948
|
-
// dark-haired figure consistent contrast on BOTH the
|
|
949
|
-
// dark cyber theme and light theme. Render at the node
|
|
950
|
-
// diameter so the coin fills inside the status ring.
|
|
951
|
-
const size = radius * 2;
|
|
958
|
+
const size = radius * 2;
|
|
959
|
+
const vendor = vendorForModel(session.model);
|
|
960
|
+
const internByAlias = /书生|书小生|intern/i.test(session.alias);
|
|
961
|
+
|
|
962
|
+
if (isIntern || internByAlias || vendor.logo) {
|
|
952
963
|
return (
|
|
953
964
|
<image
|
|
954
|
-
href=
|
|
965
|
+
href={vendor.logo ?? '/intern_avatar.png'}
|
|
955
966
|
x={pos.x - size / 2}
|
|
956
967
|
y={pos.y - size / 2}
|
|
957
968
|
width={size}
|
|
@@ -960,12 +971,25 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
|
960
971
|
/>
|
|
961
972
|
);
|
|
962
973
|
}
|
|
974
|
+
if (vendor.id !== 'unknown') {
|
|
975
|
+
// Known model house, logo asset not in public/vendors/
|
|
976
|
+
// yet — vendor-tinted monogram stands in.
|
|
977
|
+
return (
|
|
978
|
+
<>
|
|
979
|
+
<circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
|
|
980
|
+
<text
|
|
981
|
+
x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
|
|
982
|
+
fill={vendor.mono.text} fontSize={isOnline ? 14 : 10}
|
|
983
|
+
fontFamily="monospace" fontWeight="700"
|
|
984
|
+
>
|
|
985
|
+
{vendor.initial}
|
|
986
|
+
</text>
|
|
987
|
+
</>
|
|
988
|
+
);
|
|
989
|
+
}
|
|
963
990
|
// Round 106 (issue #83): hue keyed to the prefix group,
|
|
964
|
-
// not the full alias — every 通信* node shares one color
|
|
965
|
-
// every 研究员* another, so teams cluster visually even
|
|
966
|
-
// when the tier layout spreads them across rings.
|
|
991
|
+
// not the full alias — every 通信* node shares one color.
|
|
967
992
|
const c = aliasAvatarColors(groupKeys[session.alias] || session.alias);
|
|
968
|
-
const fs = isOnline ? 14 : 10;
|
|
969
993
|
return (
|
|
970
994
|
<>
|
|
971
995
|
<circle cx={pos.x} cy={pos.y} r={ar} fill={c.bg} stroke={c.ring} strokeWidth="1" />
|
|
@@ -975,7 +999,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
|
975
999
|
dy="0.34em"
|
|
976
1000
|
textAnchor="middle"
|
|
977
1001
|
fill={c.text}
|
|
978
|
-
fontSize={
|
|
1002
|
+
fontSize={isOnline ? 14 : 10}
|
|
979
1003
|
fontFamily="monospace"
|
|
980
1004
|
fontWeight="700"
|
|
981
1005
|
>
|
|
@@ -984,6 +1008,26 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
|
|
|
984
1008
|
</>
|
|
985
1009
|
);
|
|
986
1010
|
})()}
|
|
1011
|
+
{/* Issue #96: runtime badge — small corner glyph marking the
|
|
1012
|
+
execution shell (CLI / SDK / HTTP API). Sits bottom-right
|
|
1013
|
+
of the avatar; colours kept off the working/idle/offline
|
|
1014
|
+
status hues. Absent when the node reports no runtime. */}
|
|
1015
|
+
{(() => {
|
|
1016
|
+
const rt = runtimeIdentity(session.runtime);
|
|
1017
|
+
if (!rt) return null;
|
|
1018
|
+
const br = isOnline ? 7 : 5.5;
|
|
1019
|
+
const bx = pos.x + radius * 0.72;
|
|
1020
|
+
const by = pos.y + radius * 0.72;
|
|
1021
|
+
const icon = br * 2 * 0.62;
|
|
1022
|
+
return (
|
|
1023
|
+
<g style={{ pointerEvents: 'none' }}>
|
|
1024
|
+
<circle cx={bx} cy={by} r={br} fill={pal.containerBg} stroke={rt.color} strokeWidth="1.5" />
|
|
1025
|
+
<g transform={`translate(${bx - icon / 2} ${by - icon / 2}) scale(${icon / 24})`}>
|
|
1026
|
+
<path d={rt.iconPath} fill="none" stroke={rt.color} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
1027
|
+
</g>
|
|
1028
|
+
</g>
|
|
1029
|
+
);
|
|
1030
|
+
})()}
|
|
987
1031
|
{session.status === 'working' && (
|
|
988
1032
|
// working pulse — small dot just inside the status ring,
|
|
989
1033
|
// above the avatar text. Was a 18px horizontal bar at
|
package/app/components/types.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface Session {
|
|
|
12
12
|
last_seen_at?: string;
|
|
13
13
|
config_path?: string;
|
|
14
14
|
network_id?: string;
|
|
15
|
+
// #96: model vendor + execution runtime. Added to /api/status sessions in
|
|
16
|
+
// commhub-server 0.8.1-preview.0; null on older nodes / older hubs.
|
|
17
|
+
model?: string | null;
|
|
18
|
+
runtime?: string | null;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export interface Health {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Issue #96: node visual identity — model vendor + runtime.
|
|
2
|
+
*
|
|
3
|
+
* Single source for two orthogonal node dimensions:
|
|
4
|
+
* - vendor : which model house powers the node (avatar body)
|
|
5
|
+
* - runtime: which execution shell it runs in (corner badge)
|
|
6
|
+
*
|
|
7
|
+
* Both are driven by the server `model` / `runtime` fields added in
|
|
8
|
+
* commhub-server 0.8.1-preview.0 (#96 Phase 1, commit 08482ef). Old nodes
|
|
9
|
+
* report `model: null` / `runtime: null` and fall back gracefully — never a
|
|
10
|
+
* broken image or blank avatar.
|
|
11
|
+
*
|
|
12
|
+
* Adding a vendor = one entry in VENDOR_RULES. Dropping a real logo file into
|
|
13
|
+
* public/vendors/ + setting `logo` lights it up with zero render-code change;
|
|
14
|
+
* until then a vendor-tinted monogram stands in (not a fake official logo).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface VendorIdentity {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
/** Monogram fallback colours, used when `logo` is null. */
|
|
21
|
+
mono: { bg: string; ring: string; text: string };
|
|
22
|
+
/** 1-char monogram shown when there's no logo asset. */
|
|
23
|
+
initial: string;
|
|
24
|
+
/** Packaged logo asset path, or null → render the monogram instead. */
|
|
25
|
+
logo: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const UNKNOWN_VENDOR: VendorIdentity = {
|
|
29
|
+
id: 'unknown',
|
|
30
|
+
label: 'Unknown vendor',
|
|
31
|
+
mono: { bg: 'hsl(220 10% 26%)', ring: 'hsl(220 10% 46%)', text: 'hsl(220 14% 82%)' },
|
|
32
|
+
initial: '?',
|
|
33
|
+
logo: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Ordered prefix rules — first match wins. `test` runs against a lowercased
|
|
37
|
+
// model id. Keep the most specific prefixes first.
|
|
38
|
+
const VENDOR_RULES: Array<{ test: (m: string) => boolean; vendor: VendorIdentity }> = [
|
|
39
|
+
{
|
|
40
|
+
test: (m) => m.startsWith('intern'),
|
|
41
|
+
vendor: {
|
|
42
|
+
id: 'intern',
|
|
43
|
+
label: '书生 · 上海 AI 实验室',
|
|
44
|
+
mono: { bg: 'hsl(28 38% 24%)', ring: 'hsl(32 45% 52%)', text: 'hsl(34 60% 82%)' },
|
|
45
|
+
initial: '书',
|
|
46
|
+
// #79 shipped this asset — reuse it as the 书生 vendor logo.
|
|
47
|
+
logo: '/intern_avatar.png',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
test: (m) => m.startsWith('minimax'),
|
|
52
|
+
vendor: {
|
|
53
|
+
id: 'minimax',
|
|
54
|
+
label: 'MiniMax',
|
|
55
|
+
mono: { bg: 'hsl(18 50% 26%)', ring: 'hsl(18 65% 52%)', text: 'hsl(20 80% 82%)' },
|
|
56
|
+
initial: 'M',
|
|
57
|
+
logo: null, // → public/vendors/minimax.* when assets land
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
test: (m) => m.startsWith('claude'),
|
|
62
|
+
vendor: {
|
|
63
|
+
id: 'anthropic',
|
|
64
|
+
label: 'Anthropic',
|
|
65
|
+
mono: { bg: 'hsl(16 32% 26%)', ring: 'hsl(16 48% 54%)', text: 'hsl(18 60% 84%)' },
|
|
66
|
+
initial: 'A',
|
|
67
|
+
logo: null,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
test: (m) => m.startsWith('gpt') || m.startsWith('codex') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4'),
|
|
72
|
+
vendor: {
|
|
73
|
+
id: 'openai',
|
|
74
|
+
label: 'OpenAI',
|
|
75
|
+
mono: { bg: 'hsl(165 26% 22%)', ring: 'hsl(165 40% 44%)', text: 'hsl(165 45% 80%)' },
|
|
76
|
+
initial: 'O',
|
|
77
|
+
logo: null,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Resolve a model id to its vendor identity. `null` / unmatched → UNKNOWN. */
|
|
83
|
+
export function vendorForModel(model: string | null | undefined): VendorIdentity {
|
|
84
|
+
if (!model) return UNKNOWN_VENDOR;
|
|
85
|
+
const m = model.toLowerCase();
|
|
86
|
+
for (const rule of VENDOR_RULES) {
|
|
87
|
+
if (rule.test(m)) return rule.vendor;
|
|
88
|
+
}
|
|
89
|
+
return UNKNOWN_VENDOR;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type Runtime = 'claude-code-cli' | 'codex-sdk' | 'claude-agent-sdk' | 'http-api';
|
|
93
|
+
|
|
94
|
+
export interface RuntimeIdentity {
|
|
95
|
+
label: string;
|
|
96
|
+
/** SVG path(s) drawn inside a 0..24 viewBox for the corner badge. */
|
|
97
|
+
iconPath: string;
|
|
98
|
+
/** Badge accent colour — kept clear of working/idle/offline status hues. */
|
|
99
|
+
color: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const RUNTIME_MAP: Record<Runtime, RuntimeIdentity> = {
|
|
103
|
+
// terminal / CLI
|
|
104
|
+
'claude-code-cli': {
|
|
105
|
+
label: 'Claude Code CLI',
|
|
106
|
+
iconPath: 'M4 5h16v14H4z M7 9l3 3-3 3 M13 15h4',
|
|
107
|
+
color: '#a78bfa',
|
|
108
|
+
},
|
|
109
|
+
// code-block / SDK
|
|
110
|
+
'codex-sdk': {
|
|
111
|
+
label: 'Codex SDK',
|
|
112
|
+
iconPath: 'M9 7l-5 5 5 5 M15 7l5 5-5 5',
|
|
113
|
+
color: '#38bdf8',
|
|
114
|
+
},
|
|
115
|
+
// SDK, distinct hue from codex
|
|
116
|
+
'claude-agent-sdk': {
|
|
117
|
+
label: 'Claude Agent SDK',
|
|
118
|
+
iconPath: 'M4 7h16v10H4z M8 11h8 M8 14h5',
|
|
119
|
+
color: '#34d399',
|
|
120
|
+
},
|
|
121
|
+
// cloud / API — non-resident process
|
|
122
|
+
'http-api': {
|
|
123
|
+
label: 'HTTP API',
|
|
124
|
+
iconPath: 'M7 18a4 4 0 010-8 5 5 0 019.6-1.3A3.5 3.5 0 0118 18z',
|
|
125
|
+
color: '#fbbf24',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Resolve a server runtime string to badge identity. Unknown → null. */
|
|
130
|
+
export function runtimeIdentity(runtime: string | null | undefined): RuntimeIdentity | null {
|
|
131
|
+
if (!runtime) return null;
|
|
132
|
+
return RUNTIME_MAP[runtime as Runtime] ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Compact one-line identity for hover / detail surfaces:
|
|
136
|
+
* "Anthropic · claude-opus-4 · Claude Code CLI". Pieces with no data drop. */
|
|
137
|
+
export function identityLine(model: string | null | undefined, runtime: string | null | undefined): string {
|
|
138
|
+
const v = vendorForModel(model);
|
|
139
|
+
const r = runtimeIdentity(runtime);
|
|
140
|
+
const parts: string[] = [];
|
|
141
|
+
if (v.id !== 'unknown') parts.push(v.label);
|
|
142
|
+
if (model) parts.push(model);
|
|
143
|
+
if (r) parts.push(r.label);
|
|
144
|
+
return parts.join(' · ');
|
|
145
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -42,6 +42,10 @@ export default function Dashboard() {
|
|
|
42
42
|
const [showDispatch, setShowDispatch] = useState(false);
|
|
43
43
|
const [inbox, setInbox] = useState<InboxMessage[]>([]);
|
|
44
44
|
const [agentFilter, setAgentFilter] = useState<'all' | 'working' | 'idle' | 'offline'>('all');
|
|
45
|
+
// #84: last node.renamed event — passed to TopoGraph so an open chat
|
|
46
|
+
// popover follows the rename instead of pointing at a dead alias. `ts`
|
|
47
|
+
// makes the effect re-fire even when the same from/to repeats.
|
|
48
|
+
const [renameSignal, setRenameSignal] = useState<{ from: string; to: string; ts: number } | null>(null);
|
|
45
49
|
const { mutate } = useSWRConfig();
|
|
46
50
|
|
|
47
51
|
// SSE: instant revalidation on CommHub events
|
|
@@ -51,6 +55,19 @@ export default function Dashboard() {
|
|
|
51
55
|
url: '/api/hub/events',
|
|
52
56
|
enabled: sseEnabled,
|
|
53
57
|
onEvent: (event) => {
|
|
58
|
+
// #84 (RFC-010 §3.4): node.renamed — revalidate the session list so the
|
|
59
|
+
// new alias propagates instantly (TopoGraph + node grid re-render, the
|
|
60
|
+
// avatar hue recomputes as a pure fn of alias) instead of waiting for
|
|
61
|
+
// the next 5s poll. The renamed node's history keeps the old alias
|
|
62
|
+
// server-side, so the task list needs no revalidation here.
|
|
63
|
+
if (event.type === 'node.renamed') {
|
|
64
|
+
mutate('/api/hub/status');
|
|
65
|
+
const d = event.data as { old_alias?: string; new_alias?: string } | undefined;
|
|
66
|
+
if (d?.old_alias && d?.new_alias) {
|
|
67
|
+
setRenameSignal({ from: d.old_alias, to: d.new_alias, ts: Date.now() });
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
54
71
|
if (['new_task', 'new_message', 'new_reply', 'node_status_changed', 'broadcast'].includes(event.type)) {
|
|
55
72
|
mutate('/api/hub/status');
|
|
56
73
|
mutate((key: string) => typeof key === 'string' && key.startsWith('/api/hub/tasks'), undefined, { revalidate: true });
|
|
@@ -368,7 +385,7 @@ export default function Dashboard() {
|
|
|
368
385
|
</div>
|
|
369
386
|
)}
|
|
370
387
|
|
|
371
|
-
{showTopo && sessions.length > 0 && <TopoGraph sessions={sessions} sseSessions={sseSessions} />}
|
|
388
|
+
{showTopo && sessions.length > 0 && <TopoGraph sessions={sessions} sseSessions={sseSessions} renameSignal={renameSignal} />}
|
|
372
389
|
|
|
373
390
|
{sessions.length === 0 && !sessError ? (
|
|
374
391
|
<EmptyState
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* Issue #84 verification: node.renamed SSE handler.
|
|
2
|
+
* Mocks the SSE stream + status API so a node.renamed event flips the
|
|
3
|
+
* topology node old→new alias instantly, and an open chat popover follows
|
|
4
|
+
* the rename instead of pointing at a dead alias. */
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
|
|
9
|
+
const OLD = '节点旧名', NEW = '节点新名';
|
|
10
|
+
const browser = await chromium.launch({ headless: true });
|
|
11
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
12
|
+
await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
|
|
13
|
+
await ctx.addInitScript(() => {
|
|
14
|
+
try {
|
|
15
|
+
localStorage.setItem('anet-theme', 'cyber');
|
|
16
|
+
localStorage.removeItem('anet-brand');
|
|
17
|
+
localStorage.removeItem('anet-topo-view');
|
|
18
|
+
localStorage.setItem('anet-topo-layout', 'grid');
|
|
19
|
+
sessionStorage.setItem('anet_v3_auth', '1');
|
|
20
|
+
} catch {}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// shared test state
|
|
24
|
+
let fireRename = false; // test arms this once the old-name node is confirmed
|
|
25
|
+
let renamed = false; // flips true the moment the rename event is served
|
|
26
|
+
|
|
27
|
+
// status: old alias before rename, new alias after
|
|
28
|
+
await ctx.route('**/api/hub/status*', async (route) => {
|
|
29
|
+
const r = await route.fetch();
|
|
30
|
+
const b = await r.json();
|
|
31
|
+
const nid = (b.sessions || [])[0]?.network_id || 'default';
|
|
32
|
+
const alias = renamed ? NEW : OLD;
|
|
33
|
+
await route.fulfill({ response: r, json: { ...b, sessions: [
|
|
34
|
+
{ alias, status: 'idle', network_id: nid, created_at: '2026-05-15T00:00:00Z', updated_at: '2026-05-15T00:00:00Z', last_seen_at: '2026-05-15T00:00:00Z' },
|
|
35
|
+
] } });
|
|
36
|
+
});
|
|
37
|
+
await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
|
|
38
|
+
|
|
39
|
+
// SSE: HEAD probe says "available"; GET serves `connected` until the test
|
|
40
|
+
// arms fireRename, then one reconnect later it serves the node.renamed event.
|
|
41
|
+
await ctx.route('**/api/hub/events**', async (route) => {
|
|
42
|
+
const req = route.request();
|
|
43
|
+
if (req.method() === 'HEAD') {
|
|
44
|
+
await route.fulfill({ status: 204, headers: { 'x-anet-sse-available': 'true' } });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let body = `data: ${JSON.stringify({ type: 'connected', session: 'tester' })}\n\n`;
|
|
48
|
+
if (fireRename && !renamed) {
|
|
49
|
+
renamed = true; // status refetch triggered by the handler now returns NEW
|
|
50
|
+
body += `data: ${JSON.stringify({
|
|
51
|
+
type: 'node.renamed', event: 'node.renamed', alias: NEW,
|
|
52
|
+
txn_id: 'rtxn_test', ts: '2026-05-15T00:00:00Z',
|
|
53
|
+
data: { old_alias: OLD, new_alias: NEW, surfaces_updated: ['config', 'commhub'], history_policy: 'preserve' },
|
|
54
|
+
})}\n\n`;
|
|
55
|
+
}
|
|
56
|
+
await route.fulfill({ status: 200, headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, body });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const page = await ctx.newPage();
|
|
60
|
+
await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
|
|
61
|
+
await page.waitForFunction(() => {
|
|
62
|
+
const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
|
|
63
|
+
return !!svg && svg.querySelectorAll('circle[r="26"]').length > 0;
|
|
64
|
+
}, { timeout: 30000 }).catch(() => {});
|
|
65
|
+
await page.waitForTimeout(600);
|
|
66
|
+
|
|
67
|
+
const results = {};
|
|
68
|
+
const svg = 'svg[viewBox="0 0 1000 680"]';
|
|
69
|
+
const nodeTexts = () => page.$$eval(`${svg} text`, els => els.map(e => (e.textContent || '').trim()));
|
|
70
|
+
|
|
71
|
+
// --- before rename: old alias rendered ---
|
|
72
|
+
let texts = await nodeTexts();
|
|
73
|
+
results.oldNameShown = texts.includes(OLD);
|
|
74
|
+
|
|
75
|
+
// open the chat popover on the old-name node
|
|
76
|
+
const ring = page.locator(`${svg} circle[r="26"]`).first();
|
|
77
|
+
const bb = await ring.boundingBox();
|
|
78
|
+
if (bb) await page.mouse.wheel(0, bb.y - 160);
|
|
79
|
+
await page.waitForTimeout(150);
|
|
80
|
+
await ring.click({ force: true });
|
|
81
|
+
await page.waitForTimeout(400);
|
|
82
|
+
const popover = page.locator('[role="dialog"][aria-label^="Chat with"]');
|
|
83
|
+
results.popoverOpenedOnOld = (await popover.getAttribute('aria-label')) === `Chat with ${OLD}`;
|
|
84
|
+
|
|
85
|
+
// --- arm the rename, wait for the SSE reconnect to deliver it ---
|
|
86
|
+
fireRename = true;
|
|
87
|
+
await page.waitForFunction((oldName) => {
|
|
88
|
+
const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
|
|
89
|
+
if (!svg) return false;
|
|
90
|
+
const txt = [...svg.querySelectorAll('text')].map(t => (t.textContent || '').trim());
|
|
91
|
+
return !txt.includes(oldName);
|
|
92
|
+
}, OLD, { timeout: 15000 }).catch(() => {});
|
|
93
|
+
await page.waitForTimeout(500);
|
|
94
|
+
|
|
95
|
+
// --- after rename: topology flipped to the new alias ---
|
|
96
|
+
texts = await nodeTexts();
|
|
97
|
+
results.newNameShown = texts.includes(NEW);
|
|
98
|
+
results.oldNameGone = !texts.includes(OLD);
|
|
99
|
+
// chat popover followed the rename
|
|
100
|
+
results.popoverFollowedRename = (await popover.getAttribute('aria-label').catch(() => null)) === `Chat with ${NEW}`;
|
|
101
|
+
|
|
102
|
+
await browser.close();
|
|
103
|
+
const ok = results.oldNameShown && results.popoverOpenedOnOld &&
|
|
104
|
+
results.newNameShown && results.oldNameGone && results.popoverFollowedRename;
|
|
105
|
+
console.log(`${ok ? '✅' : '❌'} node.renamed handler:`, JSON.stringify(results));
|
|
106
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* Issue #96 verification: node visual identity — vendor avatar + runtime
|
|
2
|
+
* badge. Mocks /api/hub/status with model/runtime fields and checks the
|
|
3
|
+
* TopoGraph node rendering picks the right avatar + badge + tooltip. */
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
|
|
8
|
+
mkdirSync('/tmp/anet-issue-96', { recursive: true });
|
|
9
|
+
const browser = await chromium.launch({ headless: true });
|
|
10
|
+
|
|
11
|
+
// alias → { model, runtime } — one node per vendor + runtime + a null-model node
|
|
12
|
+
const FLEET = [
|
|
13
|
+
{ alias: 'intern-1', model: 'intern-s1-pro', runtime: 'claude-code-cli' },
|
|
14
|
+
{ alias: 'minimax-1', model: 'MiniMax-Text-01', runtime: 'codex-sdk' },
|
|
15
|
+
{ alias: 'claude-1', model: 'claude-opus-4', runtime: 'claude-agent-sdk' },
|
|
16
|
+
{ alias: 'openai-1', model: 'gpt-4o', runtime: 'http-api' },
|
|
17
|
+
{ alias: 'legacy-1', model: null, runtime: null },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
async function run(theme) {
|
|
21
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
22
|
+
await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
|
|
23
|
+
await ctx.addInitScript((th) => {
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem('anet-theme', th);
|
|
26
|
+
localStorage.removeItem('anet-brand');
|
|
27
|
+
localStorage.removeItem('anet-topo-view');
|
|
28
|
+
localStorage.setItem('anet-topo-layout', 'grid');
|
|
29
|
+
sessionStorage.setItem('anet_v3_auth', '1');
|
|
30
|
+
} catch {}
|
|
31
|
+
}, theme);
|
|
32
|
+
await ctx.route('**/api/hub/status*', async (route) => {
|
|
33
|
+
const r = await route.fetch();
|
|
34
|
+
const b = await r.json();
|
|
35
|
+
const nid = (b.sessions || [])[0]?.network_id || 'default';
|
|
36
|
+
const sessions = FLEET.map(f => ({
|
|
37
|
+
alias: f.alias, status: 'idle', network_id: nid, model: f.model, runtime: f.runtime,
|
|
38
|
+
created_at: '2026-05-14T00:00:00Z', updated_at: '2026-05-14T00:00:00Z', last_seen_at: '2026-05-14T00:00:00Z',
|
|
39
|
+
}));
|
|
40
|
+
await route.fulfill({ response: r, json: { ...b, sessions } });
|
|
41
|
+
});
|
|
42
|
+
await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
|
|
43
|
+
|
|
44
|
+
const page = await ctx.newPage();
|
|
45
|
+
await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
|
|
46
|
+
await page.waitForFunction(() => {
|
|
47
|
+
const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
|
|
48
|
+
return !!svg && svg.querySelectorAll('circle[r="26"]').length > 0;
|
|
49
|
+
}, { timeout: 30000 }).catch(() => {});
|
|
50
|
+
await page.waitForTimeout(700);
|
|
51
|
+
|
|
52
|
+
const results = {};
|
|
53
|
+
const svg = 'svg[viewBox="0 0 1000 680"]';
|
|
54
|
+
|
|
55
|
+
// intern model → <image> avatar (intern_avatar.png)
|
|
56
|
+
results.internUsesImage = await page.locator(`${svg} image[href="/intern_avatar.png"]`).count() >= 1;
|
|
57
|
+
|
|
58
|
+
// vendor monograms — minimax 'M' / anthropic 'A' / openai 'O' present as avatar text
|
|
59
|
+
const texts = await page.$$eval(`${svg} text`, els => els.map(e => (e.textContent || '').trim()));
|
|
60
|
+
results.minimaxMonogram = texts.includes('M');
|
|
61
|
+
results.anthropicMonogram = texts.includes('A');
|
|
62
|
+
results.openaiMonogram = texts.includes('O');
|
|
63
|
+
// legacy null-model node → alias-hue initial 'L' (not a vendor monogram)
|
|
64
|
+
results.legacyAliasInitial = texts.includes('L');
|
|
65
|
+
|
|
66
|
+
// runtime badges: 4 nodes have a runtime → 4 badge groups. Badge = a small
|
|
67
|
+
// circle stroked in a runtime colour. Count distinct runtime stroke colours.
|
|
68
|
+
const badgeColors = await page.$$eval(`${svg} circle`, els =>
|
|
69
|
+
els.map(e => e.getAttribute('stroke'))
|
|
70
|
+
.filter(s => ['#a78bfa', '#38bdf8', '#34d399', '#fbbf24'].includes(s)));
|
|
71
|
+
results.runtimeBadges = new Set(badgeColors).size === 4;
|
|
72
|
+
|
|
73
|
+
// <title> tooltip carries the identity line for a vendor node
|
|
74
|
+
results.tooltipHasIdentity = await page.evaluate(() => {
|
|
75
|
+
const titles = [...document.querySelectorAll('svg[viewBox="0 0 1000 680"] g > title')]
|
|
76
|
+
.map(t => t.textContent || '');
|
|
77
|
+
return titles.some(t => /Anthropic · claude-opus-4 · Claude Agent SDK/.test(t));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await page.screenshot({ path: `/tmp/anet-issue-96/vendor-${theme}.png` });
|
|
81
|
+
await ctx.close();
|
|
82
|
+
|
|
83
|
+
const ok = results.internUsesImage && results.minimaxMonogram && results.anthropicMonogram &&
|
|
84
|
+
results.openaiMonogram && results.legacyAliasInitial && results.runtimeBadges &&
|
|
85
|
+
results.tooltipHasIdentity;
|
|
86
|
+
console.log(`${ok ? '✅' : '❌'} [${theme}]:`, JSON.stringify(results));
|
|
87
|
+
return ok;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const all = [];
|
|
91
|
+
all.push(await run('cyber'));
|
|
92
|
+
all.push(await run('light'));
|
|
93
|
+
await browser.close();
|
|
94
|
+
const pass = all.every(Boolean);
|
|
95
|
+
console.log(pass ? '\n✅ ALL PASS' : '\n❌ FAIL');
|
|
96
|
+
process.exit(pass ? 0 : 1);
|