@sleep2agi/agent-network-dashboard 0.4.6-preview.9 → 0.4.6
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/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +69 -55
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/routes-manifest.json +6 -0
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +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 +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 +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 +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/api/hub/servers/route/app-paths-manifest.json +3 -0
- package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
- package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
- package/.next/server/app/api/hub/servers/route.js +7 -0
- package/.next/server/app/api/hub/servers/route.js.map +5 -0
- package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
- package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
- 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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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/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 +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/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 +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 +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 +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 +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 +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 +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 +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/app-paths-manifest.json +1 -0
- package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
- package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
- package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
- package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +2 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
- package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.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/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
- package/.next/static/chunks/01lmbsd37fybu.js +1 -0
- package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
- package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
- package/.next/static/chunks/05uk96gc~9mni.js +1 -0
- package/.next/static/chunks/088ouqy7tekty.js +2 -0
- package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
- package/.next/static/chunks/0glmrdj9112tc.js +7 -0
- package/.next/static/chunks/0hyl7mdapsz38.js +1 -0
- package/.next/static/chunks/0mqwn-l7wtcdo.js +7 -0
- package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
- package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
- package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
- package/.next/static/chunks/127kw7~wsnyu7.css +2 -0
- package/.next/static/chunks/16yfxp9ak2gdm.js +1 -0
- package/.next/static/chunks/174re5ar~kxne.js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/.next/types/routes.d.ts +2 -1
- package/.next/types/validator.ts +9 -0
- package/app/api/hub/servers/route.ts +39 -0
- package/app/components/AppShell.tsx +2 -0
- package/app/components/ChatPopover.tsx +38 -2
- package/app/components/HelpOverlay.tsx +18 -0
- package/app/components/ServersDrawer.tsx +206 -0
- package/app/components/TopoGraph.tsx +833 -138
- package/app/components/utils.ts +8 -7
- package/app/lib/time.ts +36 -0
- package/package.json +1 -1
- package/scripts/help-overlay-topo-test.mjs +44 -0
- package/scripts/servers-drawer-live-test.mjs +96 -0
- package/scripts/topo-active-links-chip-test.mjs +73 -0
- package/scripts/topo-arrow-tier-test.mjs +61 -0
- package/scripts/topo-auto-fit-test.mjs +73 -0
- package/scripts/topo-chat-focus-ring-test.mjs +77 -0
- package/scripts/topo-click-ripple-test.mjs +66 -0
- package/scripts/topo-cwd-tooltip-test.mjs +64 -0
- package/scripts/topo-dblclick-reset-test.mjs +74 -0
- package/scripts/topo-edge-hover-test.mjs +85 -0
- package/scripts/topo-edge-tooltip-test.mjs +70 -0
- package/scripts/topo-fit-key-test.mjs +106 -0
- package/scripts/topo-freshness-chip-test.mjs +74 -0
- package/scripts/topo-group-count-test.mjs +63 -0
- package/scripts/topo-group-hover-test.mjs +82 -0
- package/scripts/topo-hub-title-test.mjs +88 -0
- package/scripts/topo-label-lift-test.mjs +115 -0
- package/scripts/topo-label-scale-test.mjs +78 -0
- package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
- package/scripts/topo-layout-key-test.mjs +91 -0
- package/scripts/topo-minimap-test.mjs +68 -0
- package/scripts/topo-mobile-chips-test.mjs +103 -0
- package/scripts/topo-offline-dim-test.mjs +70 -0
- package/scripts/topo-overlap-test.mjs +32 -5
- package/scripts/topo-pan-cursor-test.mjs +62 -0
- package/scripts/topo-popover-meta-test.mjs +78 -0
- package/scripts/topo-pressure-bar-test.mjs +69 -0
- package/scripts/topo-pulse-rate-test.mjs +71 -0
- package/scripts/topo-recent-signal-empty-test.mjs +63 -0
- package/scripts/topo-reduced-motion-test.mjs +77 -0
- package/scripts/topo-tz-ghost-test.mjs +69 -0
- package/scripts/topo-wheel-zoom-test.mjs +79 -0
- package/scripts/topo-zoom-keys-test.mjs +89 -0
- package/.next/static/chunks/0-7iowzu4.798.js +0 -1
- package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
- package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
- package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
- package/.next/static/chunks/0cj5qr_a8dzky.js +0 -7
- package/.next/static/chunks/0elalb9n_.~di.js +0 -1
- package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
- package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
- package/.next/static/chunks/0kgak.tfh2-~i.js +0 -1
- package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
- package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
- package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
- package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
- package/.next/static/chunks/131.zrqhdbu5x.js +0 -1
- package/.next/static/chunks/165lqf5.z.n10.js +0 -1
- /package/.next/static/{T0hK2WE0kVtyFouPsSpos → 23MxP0RBVA4BaOJ_HMYmM}/_buildManifest.js +0 -0
- /package/.next/static/{T0hK2WE0kVtyFouPsSpos → 23MxP0RBVA4BaOJ_HMYmM}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{T0hK2WE0kVtyFouPsSpos → 23MxP0RBVA4BaOJ_HMYmM}/_ssgManifest.js +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, 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
7
|
import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
|
|
8
|
+
import { parseHubTime, relativeAgo } from '../lib/time';
|
|
8
9
|
|
|
9
10
|
interface MessageFlow {
|
|
10
11
|
from_alias: string;
|
|
@@ -32,6 +33,9 @@ interface FlowLink {
|
|
|
32
33
|
to: string;
|
|
33
34
|
count: number;
|
|
34
35
|
content: string;
|
|
36
|
+
/** ISO timestamp of the most recent message on this edge — drives
|
|
37
|
+
* the Round 10 freshness fade so dormant links recede visually. */
|
|
38
|
+
last_at: string;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
const cx = 500;
|
|
@@ -85,36 +89,118 @@ function truncate(value: string, max: number) {
|
|
|
85
89
|
return value.length > max ? `${value.slice(0, max - 1)}...` : value;
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/** Round 46 / Loop: SWR-freshness chip.
|
|
93
|
+
*
|
|
94
|
+
* TopoGraph's data refreshes every 5 s via SWR but the user has no
|
|
95
|
+
* visible signal that the canvas they're looking at is current. The
|
|
96
|
+
* flow particles and chips (R42 active-links, R43 hub) tell you when
|
|
97
|
+
* the FLEET last did something, not when the DATA last syncing. This
|
|
98
|
+
* chip ticks `live · Ns` against a 1-second interval so the operator
|
|
99
|
+
* can trust freshness without doing internal math.
|
|
100
|
+
*
|
|
101
|
+
* Owns its own setInterval so the parent topology doesn't re-render
|
|
102
|
+
* every second (only this small chip does). lastSyncRef is updated
|
|
103
|
+
* whenever the `sessions` prop reference changes — that's the SWR
|
|
104
|
+
* refresh signal. */
|
|
105
|
+
function FreshnessChip({ sessions }: { sessions: unknown }) {
|
|
106
|
+
const lastSyncRef = useRef<number>(Date.now());
|
|
107
|
+
const [now, setNow] = useState(() => Date.now());
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
// sessions reference changed → SWR just delivered a new payload.
|
|
110
|
+
lastSyncRef.current = Date.now();
|
|
111
|
+
}, [sessions]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
114
|
+
return () => clearInterval(id);
|
|
115
|
+
}, []);
|
|
116
|
+
const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
|
|
117
|
+
// Tint the chip warmer when the data goes stale (>10s since last sync).
|
|
118
|
+
// SWR's default refreshInterval here is 5s, so anything past ~10s is
|
|
119
|
+
// either a poll miss or a network hiccup.
|
|
120
|
+
const stale = sec > 10;
|
|
121
|
+
return (
|
|
122
|
+
<span
|
|
123
|
+
className={stale
|
|
124
|
+
? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
|
|
125
|
+
: "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
|
|
126
|
+
title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
|
|
127
|
+
data-freshness-chip
|
|
128
|
+
>
|
|
129
|
+
live · {sec}s
|
|
130
|
+
</span>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Round 36 / Loop: prefers-reduced-motion hook.
|
|
135
|
+
*
|
|
136
|
+
* Round 29's a11y sweep zeroed CSS animations via media query, but SVG
|
|
137
|
+
* SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
|
|
138
|
+
* — they need JS to opt out. This hook reads the media query and listens
|
|
139
|
+
* for changes so the topology's flow particles, pulses, click ripple
|
|
140
|
+
* and hub breath honour the OS-level preference. */
|
|
141
|
+
function useReducedMotion(): boolean {
|
|
142
|
+
const [reduced, setReduced] = useState(false);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
145
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
146
|
+
setReduced(mq.matches);
|
|
147
|
+
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
148
|
+
mq.addEventListener('change', onChange);
|
|
149
|
+
return () => mq.removeEventListener('change', onChange);
|
|
150
|
+
}, []);
|
|
151
|
+
return reduced;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
|
|
155
|
+
// — the Round 35 fix lives there now alongside the Round 37 mirror so
|
|
156
|
+
// any future TZ-safe parse update happens in one place.
|
|
157
|
+
|
|
158
|
+
/** Round 12 / Loop: status trio audit.
|
|
159
|
+
*
|
|
160
|
+
* Each (status × theme) cell returns a {primary, halo, text} trio. The trio
|
|
161
|
+
* invariant — keep it when adding states or tweaking shades:
|
|
162
|
+
*
|
|
163
|
+
* light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
|
|
164
|
+
* dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
|
|
165
|
+
*
|
|
166
|
+
* * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
|
|
167
|
+
* slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
|
|
168
|
+
* halo would vanish. The other three rows keep the 100-shade.
|
|
169
|
+
*
|
|
170
|
+
* Audit caught one cross-family drift before this round: online-other halo
|
|
171
|
+
* light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
|
|
172
|
+
* (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
|
|
173
|
+
* difference; large hygiene win — every trio is now mono-hue. */
|
|
88
174
|
function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
|
|
89
175
|
if (!isOnline) {
|
|
90
176
|
return {
|
|
91
177
|
label: 'offline',
|
|
92
|
-
primary: isLight ? '#94a3b8' : '#6b7280',
|
|
93
|
-
halo: isLight ? '#e2e8f0' : '#111827',
|
|
94
|
-
text: isLight ? '#475569' : '#9ca3af',
|
|
178
|
+
primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
|
|
179
|
+
halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
|
|
180
|
+
text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
|
|
95
181
|
};
|
|
96
182
|
}
|
|
97
183
|
if (session.status === 'working') {
|
|
98
184
|
return {
|
|
99
185
|
label: 'working',
|
|
100
|
-
primary: isLight ? '#059669' : '#22c55e',
|
|
101
|
-
halo: isLight ? '#d1fae5' : '#14532d',
|
|
102
|
-
text: isLight ? '#065f46' : '#dcfce7',
|
|
186
|
+
primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
|
|
187
|
+
halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
|
|
188
|
+
text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
|
|
103
189
|
};
|
|
104
190
|
}
|
|
105
191
|
if (session.status === 'idle') {
|
|
106
192
|
return {
|
|
107
193
|
label: 'idle',
|
|
108
|
-
primary: isLight ? '#0d9488' : '#2dd4bf',
|
|
109
|
-
halo: isLight ? '#ccfbf1' : '#134e4a',
|
|
110
|
-
text: isLight ? '#115e59' : '#ccfbf1',
|
|
194
|
+
primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
|
|
195
|
+
halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
|
|
196
|
+
text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
|
|
111
197
|
};
|
|
112
198
|
}
|
|
113
199
|
return {
|
|
114
200
|
label: session.status || 'online',
|
|
115
|
-
primary: isLight ? '#0284c7' : '#38bdf8',
|
|
116
|
-
halo: isLight ? '#
|
|
117
|
-
text: isLight ? '#0c4a6e' : '#e0f2fe',
|
|
201
|
+
primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
|
|
202
|
+
halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
|
|
203
|
+
text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
|
|
118
204
|
};
|
|
119
205
|
}
|
|
120
206
|
|
|
@@ -312,12 +398,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
|
|
|
312
398
|
const key = `${message.from_alias}->${message.to_alias}`;
|
|
313
399
|
const current = links.get(key);
|
|
314
400
|
|
|
401
|
+
// Keep the most-recent timestamp per pair so the render can fade
|
|
402
|
+
// dormant edges (Round 10 freshness fade).
|
|
403
|
+
const incoming = message.created_at || '';
|
|
404
|
+
const last_at = !current
|
|
405
|
+
? incoming
|
|
406
|
+
: (incoming > current.last_at ? incoming : current.last_at);
|
|
407
|
+
|
|
315
408
|
links.set(key, {
|
|
316
409
|
key,
|
|
317
410
|
from: message.from_alias,
|
|
318
411
|
to: message.to_alias,
|
|
319
412
|
count: (current?.count || 0) + 1,
|
|
320
413
|
content: current?.content || message.content,
|
|
414
|
+
last_at,
|
|
321
415
|
});
|
|
322
416
|
});
|
|
323
417
|
|
|
@@ -327,6 +421,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
|
|
|
327
421
|
export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
|
|
328
422
|
const theme = useTheme();
|
|
329
423
|
const isLight = theme === 'light';
|
|
424
|
+
const reducedMotion = useReducedMotion();
|
|
330
425
|
const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
|
|
331
426
|
const brand = useBrand();
|
|
332
427
|
const isIntern = brand === 'intern';
|
|
@@ -388,6 +483,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
388
483
|
activeAliases,
|
|
389
484
|
groupKeys,
|
|
390
485
|
groupBoxes,
|
|
486
|
+
gridContentBottom,
|
|
391
487
|
} = useMemo(() => {
|
|
392
488
|
const sseCount = (s: { alias: string; network_id?: string }) =>
|
|
393
489
|
(s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
|
|
@@ -397,17 +493,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
397
493
|
// index, so contiguous-in-array becomes contiguous-in-ring, i.e.
|
|
398
494
|
// each team visually clusters. localeCompare keeps CJK ordering sane.
|
|
399
495
|
const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
|
|
400
|
-
// #112 umbrella: ghost age-out — an offline node not seen
|
|
401
|
-
//
|
|
496
|
+
// #112 umbrella: ghost age-out — an offline node not seen recently is
|
|
497
|
+
// almost certainly a deleted node the server `/api/status` still
|
|
402
498
|
// returns (#74 root cause is server-side; this is the dashboard-side
|
|
403
499
|
// fallback so stale ghosts stop cluttering the topology). A missing
|
|
404
500
|
// last_seen_at is kept (conservative — could be a legitimately new node).
|
|
405
|
-
|
|
501
|
+
// Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
|
|
502
|
+
// screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
|
|
503
|
+
// healthy agent heartbeats every few seconds; if it's been silent for
|
|
504
|
+
// an hour it's effectively gone. 1 h gives a fresh disconnect time to
|
|
505
|
+
// come back while removing dead nodes well within an operator session.
|
|
506
|
+
const GHOST_MS = 60 * 60 * 1000;
|
|
406
507
|
const now = Date.now();
|
|
407
508
|
const isGhost = (s: Session) => {
|
|
408
509
|
if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
|
|
409
|
-
|
|
410
|
-
|
|
510
|
+
// Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
|
|
511
|
+
// before parsing so non-UTC browsers don't see a phantom 8-hour skew
|
|
512
|
+
// and ghost freshly-disconnected nodes.
|
|
513
|
+
const t = parseHubTime(s.last_seen_at);
|
|
514
|
+
return t !== null && now - t > GHOST_MS;
|
|
411
515
|
};
|
|
412
516
|
const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
|
|
413
517
|
const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
|
|
@@ -470,11 +574,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
470
574
|
// padding always fit and stacked group boxes never touch; past the
|
|
471
575
|
// floor the grid overflows and zoom/pan handles it.
|
|
472
576
|
const GROUP_TOP = nodeR + 20;
|
|
473
|
-
//
|
|
474
|
-
// (
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
577
|
+
// Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
|
|
578
|
+
// node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
|
|
579
|
+
// stroke halo for readability) at the BOTTOM row of band N would
|
|
580
|
+
// paint OVER the start of band N+1's group label, creating the
|
|
581
|
+
// "blueleap → eleap", "agent-network-dashboard → t-network / board"
|
|
582
|
+
// visual chopping. Geometry of the collision:
|
|
583
|
+
// dense label visual bottom = node_N.y + radius + denseDrop + halo
|
|
584
|
+
// group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
|
|
585
|
+
// = node_N+1.y - GROUP_TOP + 4
|
|
586
|
+
// node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
|
|
587
|
+
// cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
|
|
588
|
+
// With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
|
|
589
|
+
const denseDrop = nodeScale < 0.8 ? 12 : 14;
|
|
590
|
+
const cellH = Math.max(
|
|
591
|
+
2 * nodeR + 22, // node + dense label within band
|
|
592
|
+
GROUP_TOP + 12, // group-label band + box padding
|
|
593
|
+
nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
|
|
594
|
+
Math.min(100, (gy1 - gy0) / totalRows),
|
|
595
|
+
);
|
|
478
596
|
|
|
479
597
|
// Pass 2 — place each band's members.
|
|
480
598
|
for (const band of bands) {
|
|
@@ -507,12 +625,19 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
507
625
|
const minX = Math.min(...xs), minY = Math.min(...ys);
|
|
508
626
|
return {
|
|
509
627
|
key: band.members.length ? groupKeys[band.members[0].alias] : '',
|
|
628
|
+
count: band.members.length,
|
|
510
629
|
x: minX - GROUP_PAD,
|
|
511
630
|
y: minY - GROUP_TOP,
|
|
512
631
|
w: Math.max(...xs) - minX + GROUP_PAD * 2,
|
|
513
632
|
h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
|
|
514
633
|
};
|
|
515
634
|
});
|
|
635
|
+
// Round 28 / Loop: surface the grid's natural content bottom so the
|
|
636
|
+
// mount effect can auto-fit zoom when the layout would overflow the
|
|
637
|
+
// viewBox. = bottom of the last node row + its label drop + a small
|
|
638
|
+
// breathing buffer. Round 27's cellH bump makes this overflow more
|
|
639
|
+
// common (30-node fleets reach ~774 px, viewBox is 680).
|
|
640
|
+
const gridContentBottom = gy0 + totalRows * cellH + 8;
|
|
516
641
|
return {
|
|
517
642
|
onlineNodes: online,
|
|
518
643
|
offlineNodes: offline,
|
|
@@ -521,6 +646,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
521
646
|
activeAliases: active,
|
|
522
647
|
groupKeys,
|
|
523
648
|
groupBoxes,
|
|
649
|
+
gridContentBottom,
|
|
524
650
|
};
|
|
525
651
|
}
|
|
526
652
|
|
|
@@ -602,16 +728,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
602
728
|
groupKeys,
|
|
603
729
|
// #111: group boxes are a grid-layout feature only — radially scattered
|
|
604
730
|
// ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
|
|
605
|
-
groupBoxes: [] as { key: string; x: number; y: number; w: number; h: number }[],
|
|
731
|
+
groupBoxes: [] as { key: string; count: number; x: number; y: number; w: number; h: number }[],
|
|
732
|
+
// ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
|
|
733
|
+
gridContentBottom: 0,
|
|
606
734
|
};
|
|
607
735
|
}, [messages, sessions, sseSessions, layout, nodeScale]);
|
|
608
736
|
|
|
609
737
|
const workingCount = onlineNodes.filter(s => s.status === 'working').length;
|
|
738
|
+
// Round 6 / Loop: vendor distribution for the header chip — at a glance
|
|
739
|
+
// "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
|
|
740
|
+
// Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
|
|
741
|
+
const vendorDist = useMemo(() => {
|
|
742
|
+
const tally = new Map<string, { initial: string; count: number; color: string }>();
|
|
743
|
+
for (const s of [...onlineNodes, ...offlineNodes]) {
|
|
744
|
+
const v = vendorForModel(s.model);
|
|
745
|
+
const key = v.id === 'unknown' ? '?' : v.initial;
|
|
746
|
+
const cur = tally.get(key);
|
|
747
|
+
if (cur) cur.count++;
|
|
748
|
+
else tally.set(key, { initial: key, count: 1, color: v.mono.text });
|
|
749
|
+
}
|
|
750
|
+
return [...tally.values()].sort((a, b) => b.count - a.count);
|
|
751
|
+
}, [onlineNodes, offlineNodes]);
|
|
610
752
|
// Round 109 (Vincent 4582 P0): hover-gated labels above this node count
|
|
611
753
|
// so dense fleets show clean avatars instead of a wall of overlapping
|
|
612
754
|
// label cards. 16 ≈ where the triple-tier rings start to crowd.
|
|
613
755
|
const denseLayout = onlineNodes.length + offlineNodes.length > 16;
|
|
614
756
|
const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
|
|
757
|
+
// Round 8 / Loop: which group is currently focused. Nodes outside this
|
|
758
|
+
// group + other group boxes fade so the eye locks onto the team you're
|
|
759
|
+
// pointing at. Singletons use their own alias as the group key.
|
|
760
|
+
const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
|
|
615
761
|
|
|
616
762
|
// --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
|
|
617
763
|
// DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
|
|
@@ -631,6 +777,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
631
777
|
// Issue #100: singleton chat popover. One alias at a time — clicking
|
|
632
778
|
// another node swaps the target and the conversation switches in place.
|
|
633
779
|
const [chatAlias, setChatAlias] = useState<string | null>(null);
|
|
780
|
+
// Round 14 / Loop: one-shot click ripple — fades outward from the clicked
|
|
781
|
+
// node so the click registers physically before the popover snaps in.
|
|
782
|
+
// Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
|
|
783
|
+
// Keyed by ts so re-clicking the same node remounts the <circle> and the
|
|
784
|
+
// SMIL <animate> replays. Cleared 600ms after click (longer than the
|
|
785
|
+
// 500ms animation so a final frame at opacity 0 is still in the tree).
|
|
786
|
+
const [clickRipple, setClickRipple] = useState<{
|
|
787
|
+
ts: number; x: number; y: number; r0: number; color: string;
|
|
788
|
+
} | null>(null);
|
|
634
789
|
// #84: when a node is renamed while its chat popover is open, follow the
|
|
635
790
|
// rename so the conversation keeps targeting a live alias.
|
|
636
791
|
useEffect(() => {
|
|
@@ -661,6 +816,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
661
816
|
} catch {}
|
|
662
817
|
}, []);
|
|
663
818
|
|
|
819
|
+
// Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
|
|
820
|
+
// dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
|
|
821
|
+
// 100% zoom — a new user sees the topology with its bottom rows
|
|
822
|
+
// clipped and no hint that there's more below. Auto-fit on first
|
|
823
|
+
// paint (and only if the user has no persisted view) sets the
|
|
824
|
+
// initial zoom so all content fits. Subsequent zoom changes persist
|
|
825
|
+
// and override the auto-fit on reload; explicit reset (0 key) still
|
|
826
|
+
// goes back to 100% so the gesture's "reset to natural size" semantic
|
|
827
|
+
// is preserved.
|
|
828
|
+
//
|
|
829
|
+
// Capture pre-mount persistence in a useState initializer — the
|
|
830
|
+
// existing `persist` effect (declared below) runs on first render
|
|
831
|
+
// with the default {1,0,0} view and writes it to localStorage before
|
|
832
|
+
// the auto-fit effect (deps on async-arriving sessions) gets a turn.
|
|
833
|
+
// Reading localStorage from the effect would see that write and
|
|
834
|
+
// skip the fit. The useState snapshot fires once, before any effects.
|
|
835
|
+
const [hadPersistedViewOnMount] = useState<boolean>(
|
|
836
|
+
() => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
|
|
837
|
+
);
|
|
838
|
+
const autoFitDoneRef = useRef(false);
|
|
839
|
+
useEffect(() => {
|
|
840
|
+
if (autoFitDoneRef.current) return;
|
|
841
|
+
if (hadPersistedViewOnMount) {
|
|
842
|
+
autoFitDoneRef.current = true;
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
|
|
846
|
+
if (gridContentBottom <= VIEWBOX_H) {
|
|
847
|
+
autoFitDoneRef.current = true; // no overflow → no fit needed
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
|
|
851
|
+
setView({ zoom: fitZoom, x: 0, y: 0 });
|
|
852
|
+
autoFitDoneRef.current = true;
|
|
853
|
+
}, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
|
|
854
|
+
|
|
664
855
|
// persist view
|
|
665
856
|
useEffect(() => {
|
|
666
857
|
try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
|
|
@@ -680,6 +871,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
680
871
|
const svg = svgRef.current;
|
|
681
872
|
if (!svg) return;
|
|
682
873
|
const onWheel = (e: WheelEvent) => {
|
|
874
|
+
// Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
|
|
875
|
+
// wheel over the canvas keeps scrolling the page (the topo sits at
|
|
876
|
+
// the top of /; trapping page scroll mid-page is the classic
|
|
877
|
+
// "scroll-jail" anti-pattern). In fullscreen the canvas owns the
|
|
878
|
+
// viewport so any wheel zooms — no page scroll to preserve. Pinch-
|
|
879
|
+
// zoom on a trackpad surfaces as ctrlKey=true natively, which is
|
|
880
|
+
// also what we want (zoom the canvas, not the browser).
|
|
881
|
+
if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
|
|
683
882
|
e.preventDefault();
|
|
684
883
|
const rect = svg.getBoundingClientRect();
|
|
685
884
|
const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
|
|
@@ -700,8 +899,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
700
899
|
};
|
|
701
900
|
svg.addEventListener('wheel', onWheel, { passive: false });
|
|
702
901
|
return () => svg.removeEventListener('wheel', onWheel);
|
|
703
|
-
|
|
704
|
-
|
|
902
|
+
// Re-attach when the fullscreen flag toggles so the handler's
|
|
903
|
+
// closure picks up the new value (capture-by-closure means we'd
|
|
904
|
+
// otherwise read the stale boolean forever).
|
|
905
|
+
}, [isFullscreen]);
|
|
906
|
+
|
|
907
|
+
// Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
|
|
908
|
+
// that the canvas was actually being dragged — "grabbing" while
|
|
909
|
+
// dragRef.current.active = true tells the hand it's moving the
|
|
910
|
+
// viewport. Mirroring dragRef.active into state is the only way to
|
|
911
|
+
// re-render the SVG style; the ref itself doesn't trigger renders.
|
|
912
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
705
913
|
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
706
914
|
if (e.button !== 0) return;
|
|
707
915
|
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
@@ -712,6 +920,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
712
920
|
baseX: viewRef.current.x,
|
|
713
921
|
baseY: viewRef.current.y,
|
|
714
922
|
};
|
|
923
|
+
setIsPanning(true);
|
|
715
924
|
};
|
|
716
925
|
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
717
926
|
const d = dragRef.current;
|
|
@@ -726,6 +935,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
726
935
|
const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
727
936
|
if (!dragRef.current.active) return;
|
|
728
937
|
dragRef.current.active = false;
|
|
938
|
+
setIsPanning(false);
|
|
729
939
|
try {
|
|
730
940
|
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
|
731
941
|
} catch {}
|
|
@@ -743,6 +953,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
743
953
|
};
|
|
744
954
|
const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
|
|
745
955
|
|
|
956
|
+
// Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
|
|
957
|
+
// first-paint auto-fit effect and the keyboard handler so the math is
|
|
958
|
+
// in one place. When content already fits at natural zoom, this is
|
|
959
|
+
// effectively a "recenter" — `f` always lands on a known good view.
|
|
960
|
+
const fitView = useCallback(() => {
|
|
961
|
+
const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
|
|
962
|
+
? 1
|
|
963
|
+
: Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
|
|
964
|
+
setView({ zoom, x: 0, y: 0 });
|
|
965
|
+
}, [gridContentBottom]);
|
|
966
|
+
|
|
967
|
+
// Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
|
|
968
|
+
// Round 29 / Loop: +f to fit content.
|
|
969
|
+
// Listen on window so the user doesn't need to focus the SVG first,
|
|
970
|
+
// but only act when no text input has focus (otherwise typing "-" in
|
|
971
|
+
// a chat would zoom the topology — surprising and bad). Modifier keys
|
|
972
|
+
// pass through so Cmd/Ctrl combos still hit their owners. The button
|
|
973
|
+
// titles below document these shortcuts so users discover them.
|
|
974
|
+
useEffect(() => {
|
|
975
|
+
const onKey = (e: KeyboardEvent) => {
|
|
976
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
977
|
+
const ae = document.activeElement as HTMLElement | null;
|
|
978
|
+
if (ae) {
|
|
979
|
+
const tag = ae.tagName;
|
|
980
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
|
|
981
|
+
}
|
|
982
|
+
if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
|
|
983
|
+
else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
|
|
984
|
+
else if (e.key === '0') { resetView(); e.preventDefault(); }
|
|
985
|
+
else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
|
|
986
|
+
// Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
|
|
987
|
+
// (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
|
|
988
|
+
// outside that window is free for topology use.
|
|
989
|
+
else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
|
|
990
|
+
};
|
|
991
|
+
window.addEventListener('keydown', onKey);
|
|
992
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
993
|
+
// zoomBy / resetView are stable wrt setView callback; fitView changes
|
|
994
|
+
// with gridContentBottom so deps list catches it.
|
|
995
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
996
|
+
}, [fitView]);
|
|
997
|
+
|
|
746
998
|
const toggleFullscreen = () => {
|
|
747
999
|
const el = containerRef.current;
|
|
748
1000
|
if (!el) return;
|
|
@@ -770,6 +1022,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
770
1022
|
<button
|
|
771
1023
|
onClick={() => { if (layout !== 'ring') toggleLayout(); }}
|
|
772
1024
|
aria-pressed={layout === 'ring'}
|
|
1025
|
+
title="Ring layout (l to toggle)"
|
|
773
1026
|
className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
|
|
774
1027
|
>
|
|
775
1028
|
Ring
|
|
@@ -777,6 +1030,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
777
1030
|
<button
|
|
778
1031
|
onClick={() => { if (layout !== 'grid') toggleLayout(); }}
|
|
779
1032
|
aria-pressed={layout === 'grid'}
|
|
1033
|
+
title="Grid layout (l to toggle)"
|
|
780
1034
|
className={`px-2.5 py-1 border-l border-gray-500/25 transition-colors ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
|
|
781
1035
|
>
|
|
782
1036
|
Grid
|
|
@@ -788,9 +1042,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
788
1042
|
<span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
|
|
789
1043
|
{onlineNodes.length} online
|
|
790
1044
|
</span>
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1045
|
+
{/* Round 31 / Loop: fleet-health pressure bar. The "X working /
|
|
1046
|
+
Y online" chips already carry the raw counts; the bar lets
|
|
1047
|
+
the eye get the working/idle/offline RATIO in one glance
|
|
1048
|
+
without mental math. Stacked 3-segment chip, ~64 px wide. */}
|
|
1049
|
+
{(() => {
|
|
1050
|
+
const w = workingCount;
|
|
1051
|
+
const i = onlineNodes.length - workingCount; // idle = online - working
|
|
1052
|
+
const o = offlineNodes.length;
|
|
1053
|
+
const total = w + i + o;
|
|
1054
|
+
if (total === 0) return null;
|
|
1055
|
+
const seg = (n: number, color: string, key: string) => {
|
|
1056
|
+
if (n === 0) return null;
|
|
1057
|
+
return <span key={key} style={{ width: `${(n / total) * 100}%`, background: color, height: '100%' }} />;
|
|
1058
|
+
};
|
|
1059
|
+
// Round 47 / Loop: hidden on mobile — at <640px the chip row
|
|
1060
|
+
// wraps to multiple lines and crowds the topology header;
|
|
1061
|
+
// pressure ratio is best read with the working+online raw
|
|
1062
|
+
// counts (kept visible) anyway.
|
|
1063
|
+
return (
|
|
1064
|
+
<span
|
|
1065
|
+
className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
|
|
1066
|
+
title={`${w} working · ${i} idle · ${o} offline`}
|
|
1067
|
+
data-fleet-pressure
|
|
1068
|
+
>
|
|
1069
|
+
<span className="text-[10px] tracking-wide">pressure</span>
|
|
1070
|
+
<span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
|
|
1071
|
+
{seg(w, isLight ? '#059669' : '#22c55e', 'w')}
|
|
1072
|
+
{seg(i, isLight ? '#0d9488' : '#2dd4bf', 'i')}
|
|
1073
|
+
{seg(o, isLight ? '#94a3b8' : '#6b7280', 'o')}
|
|
1074
|
+
</span>
|
|
1075
|
+
</span>
|
|
1076
|
+
);
|
|
1077
|
+
})()}
|
|
1078
|
+
{vendorDist.length > 1 && (
|
|
1079
|
+
<span
|
|
1080
|
+
className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
|
|
1081
|
+
title="Fleet by model vendor"
|
|
1082
|
+
>
|
|
1083
|
+
{vendorDist.map(v => (
|
|
1084
|
+
<span key={v.initial} className="inline-flex items-baseline gap-0.5">
|
|
1085
|
+
<span style={{ color: v.color }}>{v.initial}</span>
|
|
1086
|
+
<span className="text-gray-500">:{v.count}</span>
|
|
1087
|
+
</span>
|
|
1088
|
+
))}
|
|
1089
|
+
</span>
|
|
1090
|
+
)}
|
|
1091
|
+
{/* Round 42 / Loop: extend active-links chip with the timestamp
|
|
1092
|
+
of the most-recent flow event. Tells the operator at a glance
|
|
1093
|
+
whether the topology is currently humming (last 30s) or has
|
|
1094
|
+
been quiet for a while — the visual flow particles and
|
|
1095
|
+
edge brightness only show that there IS traffic, not when
|
|
1096
|
+
the last one was. Reuses Round 38's relativeAgo. */}
|
|
1097
|
+
{(() => {
|
|
1098
|
+
const recent = flowLinks.reduce<number | null>((acc, l) => {
|
|
1099
|
+
if (!l.last_at) return acc;
|
|
1100
|
+
const t = parseHubTime(l.last_at);
|
|
1101
|
+
if (t === null) return acc;
|
|
1102
|
+
return acc === null || t > acc ? t : acc;
|
|
1103
|
+
}, null);
|
|
1104
|
+
const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
|
|
1105
|
+
return (
|
|
1106
|
+
<span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
|
|
1107
|
+
{flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
|
|
1108
|
+
{rel ? <span className="text-gray-500"> · last {rel}</span> : null}
|
|
1109
|
+
</span>
|
|
1110
|
+
);
|
|
1111
|
+
})()}
|
|
1112
|
+
<FreshnessChip sessions={sessions} />
|
|
794
1113
|
</div>
|
|
795
1114
|
</div>
|
|
796
1115
|
|
|
@@ -810,7 +1129,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
810
1129
|
onPointerMove={onPointerMove}
|
|
811
1130
|
onPointerUp={onPointerUp}
|
|
812
1131
|
onPointerLeave={onPointerUp}
|
|
813
|
-
|
|
1132
|
+
// Round 41 / Loop: the reset-button title (R22) and the Help
|
|
1133
|
+
// overlay (R25) both advertise "double-click the canvas to
|
|
1134
|
+
// reset", but the handler was never actually wired. The text
|
|
1135
|
+
// was lying. Wire it here, guarded so dbl-clicking a node
|
|
1136
|
+
// (which would also trigger the SVG-level handler via event
|
|
1137
|
+
// bubbling) doesn't unexpectedly reset the view on the user.
|
|
1138
|
+
onDoubleClick={(e) => {
|
|
1139
|
+
const t = e.target as Element | null;
|
|
1140
|
+
if (t?.closest('g[data-node]')) return;
|
|
1141
|
+
resetView();
|
|
1142
|
+
}}
|
|
1143
|
+
style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
|
|
814
1144
|
>
|
|
815
1145
|
<defs>
|
|
816
1146
|
<linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
|
|
@@ -832,9 +1162,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
832
1162
|
</feMerge>
|
|
833
1163
|
</filter>
|
|
834
1164
|
)}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1165
|
+
{/* Round 16 / Loop: 3-tier flow-link arrow markers.
|
|
1166
|
+
The single marker had `markerUnits` defaulting to
|
|
1167
|
+
`strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
|
|
1168
|
+
unit arrowheads — visually dominant, the head outweighed
|
|
1169
|
+
the line. Switching to `userSpaceOnUse` decouples arrow
|
|
1170
|
+
size from stroke; binning by count gives a clearer
|
|
1171
|
+
hierarchy than continuous linear scaling:
|
|
1172
|
+
s (count 1-2) → 12 user units
|
|
1173
|
+
m (count 3-4) → 16 user units (alias for `topo-arrow`)
|
|
1174
|
+
l (count 5+) → 22 user units
|
|
1175
|
+
`topo-arrow` stays bound to the medium tier so the legend
|
|
1176
|
+
swatch (line ~1500) renders without change. */}
|
|
1177
|
+
{[
|
|
1178
|
+
{ id: 'topo-arrow-s', size: 12 },
|
|
1179
|
+
{ id: 'topo-arrow', size: 16 },
|
|
1180
|
+
{ id: 'topo-arrow-l', size: 22 },
|
|
1181
|
+
].map(m => (
|
|
1182
|
+
<marker
|
|
1183
|
+
key={m.id}
|
|
1184
|
+
id={m.id}
|
|
1185
|
+
viewBox="0 0 10 10"
|
|
1186
|
+
refX="8"
|
|
1187
|
+
refY="5"
|
|
1188
|
+
markerWidth={m.size}
|
|
1189
|
+
markerHeight={m.size}
|
|
1190
|
+
markerUnits="userSpaceOnUse"
|
|
1191
|
+
orient="auto-start-reverse"
|
|
1192
|
+
>
|
|
1193
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
|
|
1194
|
+
</marker>
|
|
1195
|
+
))}
|
|
838
1196
|
{/* Round 45: radial radar sweep gradient — bright at center
|
|
839
1197
|
(where it meets the hub) fading to leading edge. */}
|
|
840
1198
|
<radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
|
|
@@ -966,32 +1324,55 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
966
1324
|
only — groupBoxes is empty in ring mode. Rendered behind the
|
|
967
1325
|
flow links + nodes; pointer-events off so they never intercept
|
|
968
1326
|
a node click. Restrained dashed container + group-name label. */}
|
|
969
|
-
{groupBoxes.map(box =>
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
x={box.x + 12}
|
|
985
|
-
y={box.y + 14}
|
|
986
|
-
fill={pal.legendText}
|
|
987
|
-
fontSize="13"
|
|
988
|
-
fontFamily="monospace"
|
|
989
|
-
fontWeight="700"
|
|
1327
|
+
{groupBoxes.map(box => {
|
|
1328
|
+
const isHovered = hoveredGroup === box.key;
|
|
1329
|
+
// Round 18 / Loop: group-box hover linkage. The Round 8 fade
|
|
1330
|
+
// already dropped OUT-of-focus groups to 0.28, but the IN-focus
|
|
1331
|
+
// group sat at its baseline appearance — no positive emphasis.
|
|
1332
|
+
// Hovering now upgrades the box to an "accent" treatment:
|
|
1333
|
+
// solid stroke (not dashed), thicker, accent-coloured; brighter
|
|
1334
|
+
// text and slightly stronger fill. Label and box read as one
|
|
1335
|
+
// selected unit. Geometry unchanged → overlap test untouched.
|
|
1336
|
+
return (
|
|
1337
|
+
<g
|
|
1338
|
+
key={`grp-${box.key}`}
|
|
1339
|
+
data-group={box.key}
|
|
1340
|
+
className="transition-opacity"
|
|
1341
|
+
style={{ pointerEvents: 'none', opacity: !hoveredGroup || isHovered ? 1 : 0.28 }}
|
|
990
1342
|
>
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1343
|
+
<rect
|
|
1344
|
+
x={box.x}
|
|
1345
|
+
y={box.y}
|
|
1346
|
+
width={box.w}
|
|
1347
|
+
height={box.h}
|
|
1348
|
+
rx="14"
|
|
1349
|
+
fill={isLight ? '#0f172a' : '#a5b4fc'}
|
|
1350
|
+
fillOpacity={isHovered ? (isLight ? 0.05 : 0.09) : (isLight ? 0.025 : 0.045)}
|
|
1351
|
+
stroke={isHovered ? pal.legendAccent : pal.ringStroke}
|
|
1352
|
+
strokeWidth={isHovered ? 2 : 1.5}
|
|
1353
|
+
strokeDasharray={isHovered ? 'none' : '6 6'}
|
|
1354
|
+
style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out' }}
|
|
1355
|
+
/>
|
|
1356
|
+
<text
|
|
1357
|
+
x={box.x + 12}
|
|
1358
|
+
y={box.y + 14}
|
|
1359
|
+
fill={isHovered ? pal.legendHeadline : pal.legendText}
|
|
1360
|
+
fontSize="13"
|
|
1361
|
+
fontFamily="monospace"
|
|
1362
|
+
fontWeight="700"
|
|
1363
|
+
style={{ transition: 'fill 200ms ease-out' }}
|
|
1364
|
+
>
|
|
1365
|
+
{box.key}
|
|
1366
|
+
{/* Round 19 / Loop: member-count chip. Inline tspan stays
|
|
1367
|
+
in the single <text> bbox the overlap test reads, so
|
|
1368
|
+
the node↔label guard still catches if the chip ever
|
|
1369
|
+
pushes the label far enough right to clip a node.
|
|
1370
|
+
Smaller + lighter weight reads as metadata, not name. */}
|
|
1371
|
+
<tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
|
|
1372
|
+
</text>
|
|
1373
|
+
</g>
|
|
1374
|
+
);
|
|
1375
|
+
})}
|
|
995
1376
|
|
|
996
1377
|
{/* directed message flows */}
|
|
997
1378
|
{flowLinks.map((link, index) => {
|
|
@@ -999,11 +1380,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
999
1380
|
const to = nodePositions[link.to];
|
|
1000
1381
|
if (!from || !to) return null;
|
|
1001
1382
|
|
|
1002
|
-
|
|
1383
|
+
// Round 7 / Loop: lift now scales with distance so short links
|
|
1384
|
+
// aren't over-bent (long links keep the ~36px hump), and the
|
|
1385
|
+
// particle period shortens with link.count so busier edges
|
|
1386
|
+
// visibly pulse faster — instant info density on top of the
|
|
1387
|
+
// existing stroke-width-by-count chip.
|
|
1388
|
+
const dist = Math.hypot(to.x - from.x, to.y - from.y);
|
|
1389
|
+
const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
|
|
1003
1390
|
const path = curvePath(from, to, lift);
|
|
1004
1391
|
const width = Math.min(2 + link.count, 7);
|
|
1005
|
-
const duration = 2.
|
|
1006
|
-
|
|
1392
|
+
const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
|
|
1393
|
+
// Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
|
|
1394
|
+
// stays at full intensity; over 5 minutes it decays to ~35%.
|
|
1395
|
+
// Surfaces "what's happening now" vs background chatter without
|
|
1396
|
+
// hiding old flow entirely (some context still useful). `now`
|
|
1397
|
+
// captured at useMemo-recompute time (every 5s message refresh)
|
|
1398
|
+
// — accuracy is within the poll interval, plenty.
|
|
1399
|
+
const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
|
|
1400
|
+
const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
|
|
1401
|
+
// Round 16 arrow-tier binning — keep `topo-arrow` as the
|
|
1402
|
+
// medium tier id so the legend swatch picks it up unchanged.
|
|
1403
|
+
const arrowId = link.count <= 2 ? 'topo-arrow-s'
|
|
1404
|
+
: link.count <= 4 ? 'topo-arrow'
|
|
1405
|
+
: 'topo-arrow-l';
|
|
1406
|
+
|
|
1407
|
+
// Round 39 / Loop: edge hover tooltip — surface the same
|
|
1408
|
+
// last_at + count info the freshness fade and arrow tier
|
|
1409
|
+
// already encode visually, in plain text. The stroke is the
|
|
1410
|
+
// hover target; SVG `<title>` honours newlines on every
|
|
1411
|
+
// browser the dashboard targets.
|
|
1412
|
+
const lastAt = relativeAgo(link.last_at);
|
|
1413
|
+
const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
|
|
1414
|
+
// Round 40 / Loop: edges follow node hover — when an alias is
|
|
1415
|
+
// hovered, every edge touching it brightens, the rest fade.
|
|
1416
|
+
// Pairs with the Round 8 group-focus fade on nodes: hover a
|
|
1417
|
+
// node to find "who is this agent talking to" at a glance.
|
|
1418
|
+
// No hover → multiplier is 1.0 (current behaviour preserved).
|
|
1419
|
+
const edgeOpacityMul = !hoveredAlias
|
|
1420
|
+
? 1
|
|
1421
|
+
: (link.from === hoveredAlias || link.to === hoveredAlias)
|
|
1422
|
+
? 1.7 // boost the touching edge
|
|
1423
|
+
: 0.35; // dim the rest
|
|
1007
1424
|
return (
|
|
1008
1425
|
<g key={link.key}>
|
|
1009
1426
|
<path
|
|
@@ -1011,10 +1428,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1011
1428
|
fill="none"
|
|
1012
1429
|
stroke={pal.flowEdge}
|
|
1013
1430
|
strokeWidth={width}
|
|
1014
|
-
opacity={isLight ? 0.22 : 0.28}
|
|
1431
|
+
opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
|
|
1015
1432
|
filter={isLight ? undefined : 'url(#topo-glow)'}
|
|
1016
|
-
markerEnd=
|
|
1017
|
-
|
|
1433
|
+
markerEnd={`url(#${arrowId})`}
|
|
1434
|
+
className="transition-opacity duration-300"
|
|
1435
|
+
style={{ pointerEvents: 'stroke' }}
|
|
1436
|
+
>
|
|
1437
|
+
<title>{tooltip}</title>
|
|
1438
|
+
</path>
|
|
1018
1439
|
<path
|
|
1019
1440
|
id={`flow-path-${index}`}
|
|
1020
1441
|
d={path}
|
|
@@ -1022,37 +1443,56 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1022
1443
|
stroke={pal.flowPath}
|
|
1023
1444
|
strokeWidth="1"
|
|
1024
1445
|
strokeDasharray="2 12"
|
|
1025
|
-
opacity={isLight ? 0.4 : 0.75}
|
|
1446
|
+
opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
|
|
1447
|
+
className="transition-opacity duration-300"
|
|
1026
1448
|
/>
|
|
1027
|
-
|
|
1028
|
-
<
|
|
1029
|
-
|
|
1449
|
+
{!reducedMotion && (
|
|
1450
|
+
<circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
|
|
1451
|
+
<animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
|
|
1452
|
+
</circle>
|
|
1453
|
+
)}
|
|
1030
1454
|
</g>
|
|
1031
1455
|
);
|
|
1032
1456
|
})}
|
|
1033
1457
|
|
|
1034
|
-
{/* center hub — round 39
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
the
|
|
1040
|
-
|
|
1041
|
-
|
|
1458
|
+
{/* center hub — round 39 sized + round 13 restraint.
|
|
1459
|
+
The hub is the control-plane anchor. r39 gave it two outward
|
|
1460
|
+
pulses (r 10→38, 2.4s, double-phase) which read as "loudest
|
|
1461
|
+
node in the room" — wrong semantics: anchors don't emit,
|
|
1462
|
+
they hold. r13 swaps the kinetic pulse for a slow opacity
|
|
1463
|
+
breath on the static halo (4s, ±15% from base), so the hub
|
|
1464
|
+
stays alive without throwing kinetic energy outward. The
|
|
1465
|
+
dual-circle "lit lamp" core is unchanged. */}
|
|
1466
|
+
{layout === 'ring' && (<g data-topo-hub>
|
|
1467
|
+
{/* Round 43 / Loop: hub `<title>` summary — hovering the
|
|
1468
|
+
central glow now answers "what is this?" with a one-line
|
|
1469
|
+
fleet snapshot. Duplicates the header chips by design;
|
|
1470
|
+
hovering the most visually prominent element is the
|
|
1471
|
+
most natural impulse, so satisfy it where the cursor
|
|
1472
|
+
already is. Falls clean: omits sub-clauses when their
|
|
1473
|
+
counts are zero. */}
|
|
1474
|
+
<title>{(() => {
|
|
1475
|
+
const total = sessions.length;
|
|
1476
|
+
const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
|
|
1477
|
+
if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
|
|
1478
|
+
if (workingCount > 0) parts.push(`${workingCount} working`);
|
|
1479
|
+
if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
|
|
1480
|
+
return parts.join(' · ');
|
|
1481
|
+
})()}</title>
|
|
1482
|
+
{/* grounding halo — now breathes in opacity, no expansion */}
|
|
1042
1483
|
<circle
|
|
1043
1484
|
cx={cx} cy={cy} r="18"
|
|
1044
1485
|
fill={isLight ? '#d1fae5' : '#10b981'}
|
|
1045
|
-
opacity={isLight ? 0.
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
<animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
|
|
1486
|
+
opacity={isLight ? 0.42 : 0.12}
|
|
1487
|
+
>
|
|
1488
|
+
{!reducedMotion && (
|
|
1489
|
+
<animate
|
|
1490
|
+
attributeName="opacity"
|
|
1491
|
+
values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
|
|
1492
|
+
dur="4s"
|
|
1493
|
+
repeatCount="indefinite"
|
|
1494
|
+
/>
|
|
1495
|
+
)}
|
|
1056
1496
|
</circle>
|
|
1057
1497
|
{/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
|
|
1058
1498
|
<circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
|
|
@@ -1060,7 +1500,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1060
1500
|
</g>)}
|
|
1061
1501
|
|
|
1062
1502
|
{/* agent nodes */}
|
|
1063
|
-
{[...onlineNodes, ...offlineNodes].map(session => {
|
|
1503
|
+
{[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
|
|
1064
1504
|
const pos = nodePositions[session.alias];
|
|
1065
1505
|
if (!pos) return null;
|
|
1066
1506
|
|
|
@@ -1080,13 +1520,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1080
1520
|
// occlude an avatar), and the full card appears only for the
|
|
1081
1521
|
// hovered node or once zoomed in past 1.4×.
|
|
1082
1522
|
const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
|
|
1523
|
+
// Round 8: when a node in another group is hovered, fade this
|
|
1524
|
+
// one. Same-group nodes (incl. singletons matching the hovered
|
|
1525
|
+
// alias) stay full. Pure visual focus, geometry unchanged.
|
|
1526
|
+
const inFocus = !hoveredGroup || (groupKeys[session.alias] ?? session.alias) === hoveredGroup;
|
|
1083
1527
|
|
|
1084
1528
|
return (
|
|
1085
1529
|
<g
|
|
1086
1530
|
key={session.alias}
|
|
1087
1531
|
data-node={session.alias}
|
|
1088
|
-
|
|
1089
|
-
|
|
1532
|
+
// Round 3 / Loop: `anet-fade-in` runs once when the <g>
|
|
1533
|
+
// mounts — a new session entering the fleet (or the topology
|
|
1534
|
+
// first rendering) eases in instead of popping. Re-renders of
|
|
1535
|
+
// an existing node don't re-trigger (React preserves the <g>
|
|
1536
|
+
// via the alias key), so status changes don't flicker. The
|
|
1537
|
+
// global prefers-reduced-motion sweep already neutralises it.
|
|
1538
|
+
className="group transition-opacity anet-fade-in"
|
|
1539
|
+
style={{
|
|
1540
|
+
cursor: 'pointer',
|
|
1541
|
+
// Round 17 / Loop: offline nodes drop to 0.6 at rest so
|
|
1542
|
+
// online nodes pop without losing the offline-as-ghost
|
|
1543
|
+
// information. The dashed stroke + smaller radius already
|
|
1544
|
+
// say "offline"; dimming the whole group strengthens the
|
|
1545
|
+
// online-vs-offline hierarchy at first glance. Exempt the
|
|
1546
|
+
// chat-focused node — if the user explicitly opened a
|
|
1547
|
+
// popover targeting an offline alias, that node stays
|
|
1548
|
+
// full-brightness so the focus ring + popover read as one
|
|
1549
|
+
// selected thing rather than a dimmed selection. Group-
|
|
1550
|
+
// hover fade (Round 8) still wins when inFocus is false.
|
|
1551
|
+
opacity: !inFocus
|
|
1552
|
+
? 0.32
|
|
1553
|
+
: chatAlias === session.alias
|
|
1554
|
+
? 1
|
|
1555
|
+
: isOnline ? 1 : 0.6,
|
|
1556
|
+
// Round 9 / Loop: stagger the anet-fade-in so the topology
|
|
1557
|
+
// reveals as a wave on first paint instead of one big pop.
|
|
1558
|
+
// Cap at 24 indices (≈600ms tail) so 50-node fleets still
|
|
1559
|
+
// finish revealing within a beat. CSS animation-delay only
|
|
1560
|
+
// applies during the keyframe — re-renders without a new
|
|
1561
|
+
// mount (same alias key) don't replay, so status changes
|
|
1562
|
+
// never trigger the stagger again.
|
|
1563
|
+
animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
|
|
1564
|
+
}}
|
|
1090
1565
|
// Stop the pointerdown from reaching the SVG pan handler: the
|
|
1091
1566
|
// SVG calls setPointerCapture, and a captured pointer makes
|
|
1092
1567
|
// Chromium fire the follow-up `click` on the SVG instead of
|
|
@@ -1094,21 +1569,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1094
1569
|
// Side effect (intended): you pan from empty canvas, not by
|
|
1095
1570
|
// grabbing a node.
|
|
1096
1571
|
onPointerDown={(e) => e.stopPropagation()}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1572
|
+
// Track hover globally (not just under denseLayout) so the
|
|
1573
|
+
// Round 8 group-focus fade works at any fleet size.
|
|
1574
|
+
onPointerEnter={() => setHoveredAlias(session.alias)}
|
|
1575
|
+
onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
|
|
1576
|
+
onClick={() => {
|
|
1577
|
+
setChatAlias(session.alias);
|
|
1578
|
+
// Round 14 ripple — capture position, radius and status
|
|
1579
|
+
// colour at click time so the one-shot circle is self-
|
|
1580
|
+
// contained (no re-render-time recomputation needed).
|
|
1581
|
+
setClickRipple({
|
|
1582
|
+
ts: Date.now(),
|
|
1583
|
+
x: pos.x, y: pos.y, r0: radius,
|
|
1584
|
+
color: status.primary,
|
|
1585
|
+
});
|
|
1586
|
+
setTimeout(() => setClickRipple(prev =>
|
|
1587
|
+
prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
|
|
1588
|
+
}}
|
|
1100
1589
|
>
|
|
1101
1590
|
{/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
|
|
1102
1591
|
Falls back to just the alias when the node reports no
|
|
1103
|
-
model/runtime.
|
|
1104
|
-
|
|
1105
|
-
|
|
1592
|
+
model/runtime.
|
|
1593
|
+
Round 33 / Loop: cwd line answers "what is this agent on?".
|
|
1594
|
+
Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
|
|
1595
|
+
so the operator knows whether to wait or chase. Online nodes
|
|
1596
|
+
skip the line (Round 27's 1 h ghost age-out means anything
|
|
1597
|
+
still online has heartbeated recently — the line would just
|
|
1598
|
+
be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
|
|
1599
|
+
("… 06:00:28" assumed UTC) formats. */}
|
|
1600
|
+
{(() => {
|
|
1601
|
+
// Round 35 / Loop: TZ-safe parsing via the shared lib helper
|
|
1602
|
+
// — same parseHubTime is used by isGhost above (Round 38
|
|
1603
|
+
// factored it to app/lib/time.ts so both paths interpret SQL
|
|
1604
|
+
// bare timestamps as UTC on every browser).
|
|
1605
|
+
const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
|
|
1606
|
+
return (
|
|
1607
|
+
<title>{[
|
|
1608
|
+
identityLine(session.model, session.runtime) || session.alias,
|
|
1609
|
+
session.project_dir ? `cwd: ${session.project_dir}` : null,
|
|
1610
|
+
lastSeen ? `last seen: ${lastSeen}` : null,
|
|
1611
|
+
].filter(Boolean).join('\n')}</title>
|
|
1612
|
+
);
|
|
1613
|
+
})()}
|
|
1614
|
+
{/* Round 2 / Loop: hover ring — a thin outer stroke that fades
|
|
1615
|
+
in when the cursor enters the node, signalling clickability
|
|
1616
|
+
(real-user feedback for the chat-popover open). Pure CSS via
|
|
1617
|
+
Tailwind group-hover, so it costs nothing per frame and
|
|
1618
|
+
respects prefers-reduced-motion via the global media query. */}
|
|
1619
|
+
<circle
|
|
1620
|
+
cx={pos.x}
|
|
1621
|
+
cy={pos.y}
|
|
1622
|
+
r={radius + 12}
|
|
1623
|
+
fill="none"
|
|
1624
|
+
stroke={status.primary}
|
|
1625
|
+
// strokeWidth must NOT be 1.5 (offline status ring) or 3
|
|
1626
|
+
// (online status ring) — the overlap test selects by those
|
|
1627
|
+
// exact widths and would mis-count this invisible hover
|
|
1628
|
+
// ring as a node footprint.
|
|
1629
|
+
strokeWidth="2"
|
|
1630
|
+
className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
|
|
1631
|
+
style={{ pointerEvents: 'none' }}
|
|
1632
|
+
/>
|
|
1633
|
+
{/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
|
|
1634
|
+
open targeting this node, anchor a persistent ring around
|
|
1635
|
+
it so the floating popover visibly links back to its source
|
|
1636
|
+
node. Static (not pulsing) so it reads as "selected state"
|
|
1637
|
+
rather than "this node is active". strokeWidth=2.5 stays
|
|
1638
|
+
clear of the overlap-test selectors (1.5 / 3). Sits just
|
|
1639
|
+
outside the halo radius+8 so it never overlaps a neighbour
|
|
1640
|
+
(halos already pack flush in dense grids). */}
|
|
1641
|
+
{chatAlias === session.alias && (
|
|
1642
|
+
<circle
|
|
1643
|
+
cx={pos.x}
|
|
1644
|
+
cy={pos.y}
|
|
1645
|
+
r={radius + 14}
|
|
1646
|
+
fill="none"
|
|
1647
|
+
stroke={status.primary}
|
|
1648
|
+
strokeWidth="2.5"
|
|
1649
|
+
opacity={isLight ? 0.85 : 0.95}
|
|
1650
|
+
filter={!isLight ? 'url(#topo-glow)' : undefined}
|
|
1651
|
+
className="transition-opacity duration-200"
|
|
1652
|
+
style={{ pointerEvents: 'none' }}
|
|
1653
|
+
/>
|
|
1654
|
+
)}
|
|
1655
|
+
{isActive && !reducedMotion && (
|
|
1106
1656
|
<circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
|
|
1107
1657
|
<animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
|
|
1108
1658
|
<animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
|
|
1109
1659
|
</circle>
|
|
1110
1660
|
)}
|
|
1111
|
-
|
|
1661
|
+
{/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
|
|
1662
|
+
status colour changes so idle↔working↔offline doesn't snap
|
|
1663
|
+
— task replies / node-rename / SSE updates ease in. */}
|
|
1664
|
+
<circle
|
|
1665
|
+
cx={pos.x}
|
|
1666
|
+
cy={pos.y}
|
|
1667
|
+
r={radius + 8}
|
|
1668
|
+
fill={status.halo}
|
|
1669
|
+
opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
|
|
1670
|
+
className="transition-[fill,opacity] duration-300 ease-out"
|
|
1671
|
+
/>
|
|
1112
1672
|
<circle
|
|
1113
1673
|
cx={pos.x}
|
|
1114
1674
|
cy={pos.y}
|
|
@@ -1118,6 +1678,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1118
1678
|
strokeWidth={isOnline ? 3 : 1.5}
|
|
1119
1679
|
strokeDasharray={isOnline ? 'none' : '5 5'}
|
|
1120
1680
|
filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
|
|
1681
|
+
className="transition-[fill,stroke] duration-300 ease-out"
|
|
1121
1682
|
/>
|
|
1122
1683
|
{/* Issue #96: node "avatar" is now driven by the model
|
|
1123
1684
|
vendor. Decision order:
|
|
@@ -1204,52 +1765,115 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1204
1765
|
</g>
|
|
1205
1766
|
);
|
|
1206
1767
|
})()}
|
|
1207
|
-
{session.status === 'working' && (
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1768
|
+
{session.status === 'working' && (() => {
|
|
1769
|
+
// Round 24 / Loop: pulse rate ↔ traffic. The dot's dur
|
|
1770
|
+
// was a flat 1.1s — every working node looked equally
|
|
1771
|
+
// busy. Mapping it to sse:N lets the eye land on what's
|
|
1772
|
+
// actually hot. 3 discrete tiers read cleaner than
|
|
1773
|
+
// continuous easing at a glance; thresholds picked so
|
|
1774
|
+
// typical 1-2-SSE Claude/Codex sessions stay in the
|
|
1775
|
+
// "active" tier and only multi-pane orchestrators pop
|
|
1776
|
+
// into the "busy" tier. Geometry unchanged (r=2.5).
|
|
1777
|
+
const sse = sseCountFor ?? 0;
|
|
1778
|
+
const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
|
|
1779
|
+
return (
|
|
1780
|
+
<circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
|
|
1781
|
+
{!reducedMotion && (
|
|
1782
|
+
<animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
|
|
1783
|
+
)}
|
|
1784
|
+
</circle>
|
|
1785
|
+
);
|
|
1786
|
+
})()}
|
|
1216
1787
|
|
|
1217
1788
|
{/* Round 98 (issue #61): label rect 124px → 100px.
|
|
1218
1789
|
Round 109/110 (Vincent P0): full opaque card below the
|
|
1219
1790
|
density threshold / on hover / when zoomed; otherwise a
|
|
1220
1791
|
lightweight plain-text alias that keeps every node
|
|
1221
|
-
labelled without an opaque box covering its neighbours.
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1792
|
+
labelled without an opaque box covering its neighbours.
|
|
1793
|
+
Round 15 / Loop: when nodeScale=S the node shrinks 30%
|
|
1794
|
+
but the label card was staying full-size, so the label
|
|
1795
|
+
visually outweighed the small node. Tighten the card
|
|
1796
|
+
frame, alias / sub fontSize, drop-offset and truncate
|
|
1797
|
+
length specifically for S; M and L keep their existing
|
|
1798
|
+
sizes (M ≈ L for labels by design — the S user is the
|
|
1799
|
+
one who explicitly asked for a denser view). */}
|
|
1800
|
+
{(() => {
|
|
1801
|
+
const isSmall = nodeScale < 0.8;
|
|
1802
|
+
const cardW = isSmall ? 88 : 100;
|
|
1803
|
+
const cardH = isSmall ? 36 : 42;
|
|
1804
|
+
const cardTopY = isSmall ? -12 : -14;
|
|
1805
|
+
const aliasFs = isSmall ? 11 : 12;
|
|
1806
|
+
const subFs = isSmall ? 8 : 9;
|
|
1807
|
+
const subY = isSmall ? 15 : 17;
|
|
1808
|
+
const dropY = isSmall ? 18 : 22;
|
|
1809
|
+
const fullMax = isSmall ? 11 : 12;
|
|
1810
|
+
const denseFs = isSmall ? 9 : 10;
|
|
1811
|
+
const denseDrop = isSmall ? 12 : 14;
|
|
1812
|
+
// Round 26 / Loop: micro-lift the label group on hover —
|
|
1813
|
+
// 1.5 px upward, 200 ms ease. Pairs with the Round 18
|
|
1814
|
+
// group-box hover-accent treatment to give the same
|
|
1815
|
+
// "this is the focused element" feedback at the per-
|
|
1816
|
+
// node level. CSS transform stacks onto the SVG
|
|
1817
|
+
// positioning transform attribute (SVG 2 cascade);
|
|
1818
|
+
// bbox is unchanged at rest, so the overlap-test gate
|
|
1819
|
+
// continues to see the geometric layout it expects.
|
|
1820
|
+
return showFullLabel ? (
|
|
1821
|
+
<g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
|
|
1822
|
+
className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
|
|
1823
|
+
<rect x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
|
|
1824
|
+
<text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
|
|
1825
|
+
{truncate(session.alias, fullMax)}
|
|
1826
|
+
</text>
|
|
1827
|
+
<text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
|
|
1828
|
+
{status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
|
|
1829
|
+
</text>
|
|
1830
|
+
</g>
|
|
1831
|
+
) : (
|
|
1832
|
+
<text
|
|
1833
|
+
x={pos.x}
|
|
1834
|
+
y={pos.y + radius + denseDrop}
|
|
1835
|
+
textAnchor="middle"
|
|
1836
|
+
fill={status.text}
|
|
1837
|
+
fontSize={denseFs}
|
|
1838
|
+
fontFamily="monospace"
|
|
1839
|
+
fontWeight="700"
|
|
1840
|
+
opacity={0.9}
|
|
1841
|
+
className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
|
|
1842
|
+
style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
|
|
1843
|
+
stroke={pal.containerBg}
|
|
1844
|
+
strokeWidth="3"
|
|
1845
|
+
>
|
|
1846
|
+
{truncate(session.alias, isSmall ? 9 : 10)}
|
|
1230
1847
|
</text>
|
|
1231
|
-
|
|
1232
|
-
)
|
|
1233
|
-
<text
|
|
1234
|
-
x={pos.x}
|
|
1235
|
-
y={pos.y + radius + 14}
|
|
1236
|
-
textAnchor="middle"
|
|
1237
|
-
fill={status.text}
|
|
1238
|
-
fontSize="10"
|
|
1239
|
-
fontFamily="monospace"
|
|
1240
|
-
fontWeight="700"
|
|
1241
|
-
opacity={0.9}
|
|
1242
|
-
style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
|
|
1243
|
-
stroke={pal.containerBg}
|
|
1244
|
-
strokeWidth="3"
|
|
1245
|
-
>
|
|
1246
|
-
{truncate(session.alias, 10)}
|
|
1247
|
-
</text>
|
|
1248
|
-
)}
|
|
1848
|
+
);
|
|
1849
|
+
})()}
|
|
1249
1850
|
</g>
|
|
1250
1851
|
);
|
|
1251
1852
|
})}
|
|
1252
1853
|
|
|
1854
|
+
{/* Round 14 / Loop: click ripple — one-shot expanding ring from
|
|
1855
|
+
the clicked node, ~500ms, status-coloured. Sits inside the
|
|
1856
|
+
zoom/pan <g> so it scales / pans with the topology. Keyed by
|
|
1857
|
+
ts so a re-click on any node (same or different) remounts the
|
|
1858
|
+
<circle> and the SMIL <animate> elements replay from t=0.
|
|
1859
|
+
strokeWidth=2 doesn't match the overlap-test selectors. */}
|
|
1860
|
+
{clickRipple && !reducedMotion && (
|
|
1861
|
+
<circle
|
|
1862
|
+
key={clickRipple.ts}
|
|
1863
|
+
cx={clickRipple.x}
|
|
1864
|
+
cy={clickRipple.y}
|
|
1865
|
+
r={clickRipple.r0 + 4}
|
|
1866
|
+
fill="none"
|
|
1867
|
+
stroke={clickRipple.color}
|
|
1868
|
+
strokeWidth="2"
|
|
1869
|
+
opacity="0"
|
|
1870
|
+
style={{ pointerEvents: 'none' }}
|
|
1871
|
+
>
|
|
1872
|
+
<animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
|
|
1873
|
+
<animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
|
|
1874
|
+
</circle>
|
|
1875
|
+
)}
|
|
1876
|
+
|
|
1253
1877
|
</g>
|
|
1254
1878
|
|
|
1255
1879
|
{/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
|
|
@@ -1264,11 +1888,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1264
1888
|
<rect x="0" y="0" width="230" height="84" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
|
|
1265
1889
|
<text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
|
|
1266
1890
|
<text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
|
|
1267
|
-
{
|
|
1268
|
-
|
|
1269
|
-
|
|
1891
|
+
{/* Round 45 / Loop: empty state. The panel used to render
|
|
1892
|
+
"recent signal" + "0 msgs" with three blank slots below
|
|
1893
|
+
when no flow yet — read as "broken" rather than "quiet".
|
|
1894
|
+
A muted centred placeholder makes the empty state
|
|
1895
|
+
deliberate. Messages count CAN diverge from flowLinks
|
|
1896
|
+
count (raw count vs. deduped pairs), so the placeholder
|
|
1897
|
+
fires on flowLinks.length=0 specifically. */}
|
|
1898
|
+
{flowLinks.length === 0 ? (
|
|
1899
|
+
<text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
|
|
1900
|
+
no flow yet
|
|
1270
1901
|
</text>
|
|
1271
|
-
)
|
|
1902
|
+
) : (
|
|
1903
|
+
flowLinks.slice(0, 3).map((link, index) => (
|
|
1904
|
+
<text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
|
|
1905
|
+
{truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
|
|
1906
|
+
</text>
|
|
1907
|
+
))
|
|
1908
|
+
)}
|
|
1272
1909
|
</g>
|
|
1273
1910
|
|
|
1274
1911
|
{/* legend */}
|
|
@@ -1284,6 +1921,62 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1284
1921
|
</g>
|
|
1285
1922
|
</svg>
|
|
1286
1923
|
|
|
1924
|
+
{/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
|
|
1925
|
+
zoom let users lose their position — the minimap shows the
|
|
1926
|
+
whole topology miniaturised plus a viewport rectangle so the
|
|
1927
|
+
user always knows where they are. Click anywhere to recenter
|
|
1928
|
+
the canvas there. Only mounted when the view is non-default
|
|
1929
|
+
(zoomed or panned) since at 1× centered the minimap and the
|
|
1930
|
+
canvas show the same thing. HTML overlay so it stays fixed
|
|
1931
|
+
while the SVG transforms. */}
|
|
1932
|
+
{(() => {
|
|
1933
|
+
const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
|
|
1934
|
+
if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
|
|
1935
|
+
const MW = 120, MH = 82;
|
|
1936
|
+
const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
|
|
1937
|
+
const rectX = (-view.x / view.zoom) * sx;
|
|
1938
|
+
const rectY = (-view.y / view.zoom) * sy;
|
|
1939
|
+
const rectW = (VIEWBOX_W / view.zoom) * sx;
|
|
1940
|
+
const rectH = (VIEWBOX_H / view.zoom) * sy;
|
|
1941
|
+
return (
|
|
1942
|
+
<div
|
|
1943
|
+
className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
|
|
1944
|
+
style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
|
|
1945
|
+
role="img"
|
|
1946
|
+
aria-label="Topology minimap — click to recenter"
|
|
1947
|
+
title="Minimap · click to recenter"
|
|
1948
|
+
onClick={(e) => {
|
|
1949
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
1950
|
+
const fx = (e.clientX - r.left) / r.width;
|
|
1951
|
+
const fy = (e.clientY - r.top) / r.height;
|
|
1952
|
+
setView(prev => ({
|
|
1953
|
+
...prev,
|
|
1954
|
+
x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
|
|
1955
|
+
y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
|
|
1956
|
+
}));
|
|
1957
|
+
}}
|
|
1958
|
+
data-topo-minimap
|
|
1959
|
+
>
|
|
1960
|
+
<svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
|
|
1961
|
+
{[...onlineNodes, ...offlineNodes].map(s => {
|
|
1962
|
+
const p = nodePositions[s.alias];
|
|
1963
|
+
if (!p) return null;
|
|
1964
|
+
const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
|
|
1965
|
+
const isOn = s.status !== 'offline' || !!sseN;
|
|
1966
|
+
const st = nodeStatus(s, isOn, isLight);
|
|
1967
|
+
return <circle key={s.alias} cx={p.x * sx} cy={p.y * sy} r={isOn ? 1.7 : 1.2} fill={st.primary} opacity={isOn ? 0.9 : 0.5} />;
|
|
1968
|
+
})}
|
|
1969
|
+
{/* viewport rectangle */}
|
|
1970
|
+
<rect
|
|
1971
|
+
x={Math.max(0, rectX)} y={Math.max(0, rectY)}
|
|
1972
|
+
width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
|
|
1973
|
+
height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
|
|
1974
|
+
fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
|
|
1975
|
+
/>
|
|
1976
|
+
</svg>
|
|
1977
|
+
</div>
|
|
1978
|
+
);
|
|
1979
|
+
})()}
|
|
1287
1980
|
{/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
|
|
1288
1981
|
overlay so they stay fixed while the SVG content transforms.
|
|
1289
1982
|
Round 104: Vincent 实测 — the reset action used to be hidden
|
|
@@ -1320,6 +2013,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1320
2013
|
className="px-2 py-1 hover:bg-white/5 transition-colors"
|
|
1321
2014
|
style={{ color: pal.legendText }}
|
|
1322
2015
|
aria-label="Zoom out"
|
|
2016
|
+
title="Zoom out (−)"
|
|
1323
2017
|
>
|
|
1324
2018
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M5 12h14" /></svg>
|
|
1325
2019
|
</button>
|
|
@@ -1335,6 +2029,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1335
2029
|
className="px-2 py-1 hover:bg-white/5 transition-colors"
|
|
1336
2030
|
style={{ color: pal.legendText }}
|
|
1337
2031
|
aria-label="Zoom in"
|
|
2032
|
+
title="Zoom in (+)"
|
|
1338
2033
|
>
|
|
1339
2034
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M12 5v14M5 12h14" /></svg>
|
|
1340
2035
|
</button>
|
|
@@ -1344,7 +2039,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
|
|
|
1344
2039
|
className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
|
|
1345
2040
|
style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
|
|
1346
2041
|
aria-label="Reset view"
|
|
1347
|
-
title="Reset zoom + pan (or double-click the canvas)"
|
|
2042
|
+
title="Reset zoom + pan (0, or double-click the canvas)"
|
|
1348
2043
|
>
|
|
1349
2044
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1350
2045
|
<path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
|