feat(web/mazenet): subscribe to topology SSE stream in editor

Wire the MazeNET editor to the new /topologies/{id}/events SSE route
so live (active|degraded) topologies reflect mutator state transitions
without reload:

- useTopologyStream hook opens an EventSource against
  /topologies/{id}/events?token=<jwt>, with 3s reconnect matching the
  dashboard's /stream consumer. Callback refs avoid tearing down the
  connection on consumer rerenders.
- useMazeApi gains enqueueMutation(topologyId, op, payload,
  expectedVersion?) — thin wrapper over POST /mutations.
- MazeNET.tsx opens the stream only when topoStatus is active|degraded
  (pending editors have nothing to stream) and refetches on
  mutation.applied|failed|status events. Header shows a LIVE /
  CONNECTING… indicator.

Phase A slice — Apply (N changes) with an optimistic staged buffer
lands in a follow-up; the hooks + API method it'll need are already
here.
This commit is contained in:
2026-04-21 14:38:58 -04:00
parent f611e7363b
commit 8ecb9e6c2d
3 changed files with 172 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { useMazeApi } from './useMazeApi';
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
/* Short unique suffix for default names — avoids the DB uniqueness
@@ -424,6 +425,29 @@ const MazeNET: React.FC = () => {
useEffect(() => { refetch(); }, [refetch]);
/* Live topology stream. Open only when the topology is deployed —
* pending topologies have no mutator loop and would just idle on
* keepalives. On any state-transition event we refetch; DB is the
* source of truth and the bus is at-most-once. */
const [streamLive, setStreamLive] = useState(false);
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
setStreamLive(true);
if (event.name === 'mutation.applied'
|| event.name === 'mutation.failed'
|| event.name === 'status') {
refetch();
}
}, [refetch]);
const onStreamError = useCallback(() => { setStreamLive(false); }, []);
useTopologyStream({
topologyId: streamEnabled ? topologyId : null,
enabled: streamEnabled,
onEvent: onStreamEvent,
onError: onStreamError,
});
useEffect(() => { if (!streamEnabled) setStreamLive(false); }, [streamEnabled]);
const onDeploy = async () => {
if (!topologyId) return;
setDeploying(true);
@@ -455,6 +479,11 @@ const MazeNET: React.FC = () => {
<div className="maze-page-sub">
NETWORK OF NETWORKS · {topoStatus.toUpperCase()} · v{topoVersion} ·{' '}
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS
{streamEnabled && (
<span className="alert-text" style={{ color: streamLive ? undefined : 'var(--fg-dim)' }}>
{' '}· {streamLive ? 'LIVE' : 'CONNECTING…'}
</span>
)}
{loadErr && <span className="alert-text"> · {loadErr}</span>}
{actionErr && <span className="alert-text"> · {actionErr}</span>}
</div>