diff --git a/decnet_web/src/components/Orchestrator.css b/decnet_web/src/components/Orchestrator.css index c0f9eee0..31db5c81 100644 --- a/decnet_web/src/components/Orchestrator.css +++ b/decnet_web/src/components/Orchestrator.css @@ -241,3 +241,188 @@ /* Animations */ @keyframes orch-pulse { from { opacity: 0.5; } to { opacity: 1; } } @keyframes orch-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +/* ── Row interactivity ─────────────────────────────────── */ +.orchestrator-root .logs-table tr.clickable { cursor: pointer; } +.orchestrator-root .logs-table tr.clickable:hover { + background: rgba(238, 130, 238, 0.04); +} + +/* ── Inspector drawer ──────────────────────────────────── */ +.orchestrator-root .orchestrator-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: flex-end; + z-index: 1000; + animation: od-fade 0.15s ease; +} +@keyframes od-fade { from { opacity: 0; } to { opacity: 1; } } + +.orchestrator-root .orchestrator-drawer { + width: min(620px, 100%); + height: 100%; + background: var(--bg); + border-left: 1px solid var(--violet); + box-shadow: -12px 0 40px rgba(238, 130, 238, 0.1); + overflow-y: auto; + display: flex; + flex-direction: column; + animation: od-slide 0.2s ease; +} +@keyframes od-slide { + from { transform: translateX(30px); opacity: 0.6; } + to { transform: none; opacity: 1; } +} + +.orchestrator-root .orchestrator-drawer .bd-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.orchestrator-root .orchestrator-drawer .bd-head h3 { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + letter-spacing: 3px; + color: var(--violet); + margin: 0; +} +.orchestrator-root .orchestrator-drawer .close-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + display: flex; + padding: 4px; + cursor: pointer; +} +.orchestrator-root .orchestrator-drawer .close-btn:hover { border-color: var(--violet); } + +.orchestrator-root .orchestrator-drawer .bd-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.orchestrator-root .orchestrator-drawer .kvs { + display: grid; + grid-template-columns: 130px 1fr; + gap: 10px 12px; + font-size: 0.8rem; + align-items: center; +} +.orchestrator-root .orchestrator-drawer .kvs .k { + opacity: 0.55; + font-size: 0.7rem; + letter-spacing: 1.5px; + display: inline-flex; + align-items: center; +} +.orchestrator-root .orchestrator-drawer .kvs .v { word-break: break-all; } +.orchestrator-root .orchestrator-drawer .kvs .v.mono { + font-family: var(--font-mono); + font-size: 0.78rem; +} + +/* Source-tag chips disambiguate the opaque dst id: bare UUIDs come from + topology_deckies, "host_uuid:name" composites come from the fleet + (host_uuid="local") or SWARM shards. */ +.orchestrator-root .orchestrator-drawer .src-dst-cell { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.orchestrator-root .orchestrator-drawer .src-dst-cell .hash-text { + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--matrix); +} +.orchestrator-root .orchestrator-drawer .chip.src-topology { + border-color: var(--matrix); + color: var(--matrix); +} +.orchestrator-root .orchestrator-drawer .chip.src-fleet { + border-color: var(--violet); + color: var(--violet); +} +.orchestrator-root .orchestrator-drawer .chip.src-shard { + border-color: #ffaa00; + color: #ffaa00; +} + +.orchestrator-root .orchestrator-drawer .type-label { + font-size: 0.68rem; + letter-spacing: 2px; + opacity: 0.6; + margin-bottom: 8px; +} + +.orchestrator-root .orchestrator-drawer .code-block { + background: var(--panel); + border: 1px solid var(--border); + border-left: 2px solid var(--violet); + padding: 12px 14px; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--matrix); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.orchestrator-root .orchestrator-drawer .hash-row { + display: flex; + align-items: center; + gap: 8px; +} +.orchestrator-root .orchestrator-drawer .hash-row .hash-text { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--matrix); + word-break: break-all; + flex: 1; +} +.orchestrator-root .orchestrator-drawer .icon-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + padding: 4px 6px; + display: inline-flex; + cursor: pointer; +} +.orchestrator-root .orchestrator-drawer .icon-btn:hover { border-color: var(--violet); } + +.orchestrator-root .orchestrator-drawer .bd-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.orchestrator-root .orchestrator-drawer .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 1.5px; + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + cursor: pointer; + transition: all 0.3s ease; + opacity: 0.8; +} +.orchestrator-root .orchestrator-drawer .btn.ghost:hover { + opacity: 1; + border-color: var(--matrix); + box-shadow: var(--matrix-glow); +} diff --git a/decnet_web/src/components/Orchestrator.tsx b/decnet_web/src/components/Orchestrator.tsx index 1aa794e7..a370e2e1 100644 --- a/decnet_web/src/components/Orchestrator.tsx +++ b/decnet_web/src/components/Orchestrator.tsx @@ -5,6 +5,7 @@ import { } from '../icons'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; +import OrchestratorInspector from './OrchestratorInspector'; import { useOrchestratorStream, type OrchestratorStreamEvent } from './useOrchestratorStream'; import './Orchestrator.css'; @@ -51,6 +52,7 @@ const Orchestrator: React.FC = () => { const [status, setStatus] = useState('connecting'); const [paused, setPaused] = useState(false); const [now, setNow] = useState(Date.now()); + const [selected, setSelected] = useState(null); const limit = 50; const pausedRef = useRef(paused); @@ -210,7 +212,11 @@ const Orchestrator: React.FC = () => { const cls = !r.success ? 'fail' : fresh ? 'fresh' : ''; const kindCls = r.kind === 'traffic' || r.kind === 'file' ? r.kind : ''; return ( - + setSelected(r)} + > {timeAgo(r.ts)} {r.kind} @@ -244,6 +250,13 @@ const Orchestrator: React.FC = () => { + + {selected && ( + setSelected(null)} + /> + )} ); }; diff --git a/decnet_web/src/components/OrchestratorInspector.tsx b/decnet_web/src/components/OrchestratorInspector.tsx new file mode 100644 index 00000000..cc4ab99c --- /dev/null +++ b/decnet_web/src/components/OrchestratorInspector.tsx @@ -0,0 +1,161 @@ +import React, { useMemo } from 'react'; +import { X, Cpu, Copy, ArrowRight } from '../icons'; +import { useToast } from './Toasts/useToast'; + +export interface OrchestratorInspectorEntry { + uuid: string; + ts: string; + kind: 'traffic' | 'file' | string; + protocol: string; + action: string; + src_decky_uuid: string | null; + dst_decky_uuid: string; + success: boolean; + payload: string; +} + +interface Props { + event: OrchestratorInspectorEntry; + onClose: () => void; +} + +const renderDeckyId = (id: string | null): string => id ?? '—'; + +const sourceTag = (id: string | null): 'topology' | 'fleet' | 'shard' | null => { + if (!id) return null; + // Composite "host_uuid:name" identifies fleet/shard rows; + // bare UUIDs (8-4-4-4-12) are MazeNET TopologyDecky.uuid. + if (id.includes(':')) return id.startsWith('local:') ? 'fleet' : 'shard'; + return /^[0-9a-f]{8}-/i.test(id) ? 'topology' : null; +}; + +const OrchestratorInspector: React.FC = ({ event, onClose }) => { + const { push } = useToast(); + + const prettyPayload = useMemo(() => { + try { + return JSON.stringify(JSON.parse(event.payload), null, 2); + } catch { + return event.payload; + } + }, [event.payload]); + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + push({ text: `${label} COPIED`, tone: 'matrix', icon: 'copy' }); + } catch { + push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' }); + } + }; + + const copyEvent = () => copy(JSON.stringify(event, null, 2), 'EVENT JSON'); + const copyPayload = () => copy(prettyPayload, 'PAYLOAD JSON'); + + const kindCls = event.kind === 'traffic' || event.kind === 'file' ? event.kind : ''; + const srcSrc = sourceTag(event.src_decky_uuid); + const dstSrc = sourceTag(event.dst_decky_uuid); + const isLive = event.uuid.startsWith('live-'); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

+ + + {isLive ? 'LIVE EVENT' : `EVENT #${event.uuid.slice(0, 8)}`} + + + {event.kind.toUpperCase()} + +

+ +
+
+
+
TS
+
{new Date(event.ts).toLocaleString()}
+ +
PROTOCOL
+
+ {event.protocol.toUpperCase()} +
+ +
ACTION
+
{event.action}
+ +
OUTCOME
+
+ + {event.success ? '✓ SUCCESS' : '✗ FAILURE'} + +
+ +
SRC
+
+ {event.src_decky_uuid ? ( + + {renderDeckyId(event.src_decky_uuid)} + {srcSrc && {srcSrc.toUpperCase()}} + + ) : ( + + )} +
+ +
+
+ + {renderDeckyId(event.dst_decky_uuid)} + {dstSrc && {dstSrc.toUpperCase()}} + +
+ + {!isLive && ( + <> +
EVENT UUID
+
+
+ {event.uuid} + +
+
+ + )} +
+ +
+
PAYLOAD
+
{prettyPayload}
+
+ +
+
EXPORT
+
+ + +
+
+
+
+
+ ); +}; + +export default OrchestratorInspector;