diff --git a/decnet_web/src/components/Orchestrator.css b/decnet_web/src/components/Orchestrator.css new file mode 100644 index 00000000..c0f9eee0 --- /dev/null +++ b/decnet_web/src/components/Orchestrator.css @@ -0,0 +1,243 @@ +/* Orchestrator — synthetic life-injection activity feed. + * Scoped under .orchestrator-root, mirrors the Bounty/DeckyFleet pattern. */ + +.orchestrator-root { display: flex; flex-direction: column; gap: 20px; } + +/* Header */ +.orchestrator-root .page-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + border-bottom: 1px solid var(--border); + padding-bottom: 16px; + gap: 24px; +} +.orchestrator-root .page-title-group { display: flex; flex-direction: column; gap: 6px; } +.orchestrator-root .page-header h1 { + font-size: 1.3rem; + letter-spacing: 4px; + font-weight: 700; + margin: 0; + color: var(--matrix); +} +.orchestrator-root .page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; } + +.orchestrator-root .dim { opacity: 0.5; } +.orchestrator-root .violet-accent { color: var(--violet); } +.orchestrator-root .matrix-text { color: var(--matrix); } +.orchestrator-root .alert-text { color: var(--alert); } + +/* Header pills */ +.orchestrator-root .header-line { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.orchestrator-root .status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + font-size: 0.65rem; + letter-spacing: 1.5px; + border: 1px solid var(--border); + background: var(--panel); + text-transform: uppercase; +} +.orchestrator-root .status-pill.live { + border-color: var(--matrix); + color: var(--matrix); + box-shadow: var(--matrix-glow); +} +.orchestrator-root .status-pill.live .dot { + background: var(--matrix); + box-shadow: 0 0 8px var(--matrix); + animation: orch-pulse 1.4s infinite alternate; +} +.orchestrator-root .status-pill.connecting { color: rgba(0, 255, 65, 0.55); } +.orchestrator-root .status-pill.connecting .dot { + background: rgba(0, 255, 65, 0.55); + animation: orch-blink 1s infinite; +} +.orchestrator-root .status-pill.error { + border-color: var(--alert); + color: var(--alert); +} +.orchestrator-root .status-pill.error .dot { background: var(--alert); } +.orchestrator-root .status-pill .dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; +} + +.orchestrator-root .failure-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + font-size: 0.65rem; + letter-spacing: 1.5px; + border: 1px solid var(--alert); + color: var(--alert); + background: rgba(255, 65, 65, 0.06); + text-transform: uppercase; +} + +/* Controls row */ +.orchestrator-root .controls-row { + display: flex; + gap: 12px; + align-items: center; +} + +/* Segmented kind filter — mirrors DeckyFleet's fleet-filter-group */ +.orchestrator-root .seg-group { + display: flex; + border: 1px solid var(--border); + background: var(--panel); +} +.orchestrator-root .seg-group button { + padding: 8px 16px; + font-size: 0.68rem; + letter-spacing: 1.5px; + border: 0; + border-right: 1px solid var(--border); + background: transparent; + color: rgba(0, 255, 65, 0.6); + font-family: inherit; + cursor: pointer; + text-transform: uppercase; +} +.orchestrator-root .seg-group button:last-child { border-right: none; } +.orchestrator-root .seg-group button.active { + background: var(--violet-tint-10); + color: var(--violet); +} +.orchestrator-root .seg-group button:hover:not(.active) { color: var(--matrix); } + +/* Pause toggle — neutral .btn flavour */ +.orchestrator-root .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 1.5px; + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + cursor: pointer; + transition: all 0.2s ease; +} +.orchestrator-root .btn:hover { border-color: var(--matrix); box-shadow: var(--matrix-glow); } +.orchestrator-root .btn.paused { border-color: var(--violet); color: var(--violet); } +.orchestrator-root .btn.paused:hover { box-shadow: var(--violet-glow); } + +/* Section + table */ +.orchestrator-root .logs-section { + border: 1px solid var(--border); + background: var(--panel); +} +.orchestrator-root .section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} +.orchestrator-root .section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.7rem; + letter-spacing: 1.5px; + opacity: 0.7; +} + +/* Pagination */ +.orchestrator-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; } +.orchestrator-root .pager button { + padding: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--matrix); + display: flex; + cursor: pointer; +} +.orchestrator-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; } +.orchestrator-root .pager button:hover:not(:disabled) { border-color: var(--accent); } + +/* Table */ +.orchestrator-root .logs-table-container { overflow-x: auto; } +.orchestrator-root .logs-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.orchestrator-root .logs-table th { + text-align: left; + padding: 9px 14px; + font-size: 0.62rem; + letter-spacing: 1.5px; + opacity: 0.55; + border-bottom: 1px solid var(--border); +} +.orchestrator-root .logs-table td { + padding: 9px 14px; + border-bottom: 1px solid rgba(48, 54, 61, 0.4); + vertical-align: middle; +} +.orchestrator-root .logs-table tr:hover td { background: rgba(0, 255, 65, 0.025); } +.orchestrator-root .logs-table tr.fail td { + background: rgba(255, 65, 65, 0.05); +} +.orchestrator-root .logs-table tr.fail:hover td { background: rgba(255, 65, 65, 0.08); } + +/* Live-prepended row tint — fades back to neutral after a moment via opacity. */ +.orchestrator-root .logs-table tr.fresh td { + background: rgba(238, 130, 238, 0.05); +} + +/* Kind chip */ +.orchestrator-root .kind-chip { + display: inline-block; + padding: 2px 8px; + font-size: 0.62rem; + letter-spacing: 1.5px; + border: 1px solid var(--border); + text-transform: uppercase; +} +.orchestrator-root .kind-chip.traffic { border-color: var(--matrix); color: var(--matrix); } +.orchestrator-root .kind-chip.file { border-color: var(--violet); color: var(--violet); } + +/* OK indicator */ +.orchestrator-root .ok-yes { color: var(--matrix); font-weight: 700; } +.orchestrator-root .ok-no { color: var(--alert); font-weight: 700; } + +/* Mono cells */ +.orchestrator-root .mono { + font-family: var(--font-mono); + font-size: 0.74rem; +} +.orchestrator-root .src-dst { font-family: var(--font-mono); font-size: 0.72rem; opacity: 0.7; } +.orchestrator-root .arrow { opacity: 0.4; padding: 0 4px; } + +.orchestrator-root .payload-cell { + font-family: var(--font-mono); + font-size: 0.72rem; + opacity: 0.6; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Empty state row */ +.orchestrator-root .empty-row td { padding: 0; } + +/* Animations */ +@keyframes orch-pulse { from { opacity: 0.5; } to { opacity: 1; } } +@keyframes orch-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } diff --git a/decnet_web/src/components/Orchestrator.tsx b/decnet_web/src/components/Orchestrator.tsx index bb8a77a0..1aa794e7 100644 --- a/decnet_web/src/components/Orchestrator.tsx +++ b/decnet_web/src/components/Orchestrator.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { - ChevronLeft, ChevronRight, Filter, Cpu, AlertTriangle, + ChevronLeft, ChevronRight, Filter, Cpu, AlertTriangle, Pause, Play, } from '../icons'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; import { useOrchestratorStream, type OrchestratorStreamEvent } from './useOrchestratorStream'; -import './Dashboard.css'; +import './Orchestrator.css'; interface OrchestratorEntry { uuid: string; @@ -25,6 +25,7 @@ type StreamStatus = 'connecting' | 'live' | 'error'; const ROW_CAP = 500; const HOUR_MS = 60 * 60 * 1000; +const FRESH_MS = 5_000; const timeAgo = (dateStr: string | null): string => { if (!dateStr) return '—'; @@ -49,17 +50,26 @@ const Orchestrator: React.FC = () => { const [loading, setLoading] = useState(true); const [status, setStatus] = useState('connecting'); const [paused, setPaused] = useState(false); + const [now, setNow] = useState(Date.now()); const limit = 50; const pausedRef = useRef(paused); useEffect(() => { pausedRef.current = paused; }, [paused]); + // Tick to refresh the "Xs ago" labels and fade the fresh-row tint. + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 5_000); + return () => clearInterval(t); + }, []); + const fetchEvents = async () => { setLoading(true); try { const offset = (page - 1) * limit; const kindQ = kindParam !== 'all' ? `&kind=${kindParam}` : ''; - const res = await api.get(`/orchestrator/events?limit=${limit}&offset=${offset}${kindQ}`); + const res = await api.get( + `/orchestrator/events?limit=${limit}&offset=${offset}${kindQ}`, + ); setRows(res.data.data ?? []); setTotal(res.data.total ?? 0); } catch (err) { @@ -76,7 +86,6 @@ const Orchestrator: React.FC = () => { onStatus: setStatus, onEvent: (ev: OrchestratorStreamEvent) => { if (pausedRef.current) return; - if (ev.name === 'snapshot') return; if (ev.name !== 'traffic' && ev.name !== 'file') return; const p = ev.payload as Partial; const row: OrchestratorEntry = { @@ -108,29 +117,32 @@ const Orchestrator: React.FC = () => { }, [streamRows, rows, kindParam]); const failuresLastHour = useMemo(() => { - const cutoff = Date.now() - HOUR_MS; + const cutoff = now - HOUR_MS; return [...streamRows, ...rows].filter( (r) => !r.success && new Date(r.ts).getTime() >= cutoff, ).length; - }, [streamRows, rows]); + }, [streamRows, rows, now]); - const statusPill = ( - - {status === 'live' ? '● LIVE' : status === 'connecting' ? '● CONNECTING' : '● OFFLINE'} - - ); + const statusLabel = + status === 'live' ? 'LIVE' + : status === 'connecting' ? 'CONNECTING' + : 'OFFLINE'; return ( -
+
-
+

ORCHESTRATOR

- {statusPill} + + + {statusLabel} + {failuresLastHour > 0 && ( - - {failuresLastHour} FAILURES / 1h + + + {failuresLastHour} FAILURES / 1H )}
@@ -140,24 +152,27 @@ const Orchestrator: React.FC = () => {
-
+
+
+ {(['all', 'traffic', 'file'] as KindFilter[]).map((k) => ( + + ))} +
- - - - + > + {paused ? : } + {paused ? 'RESUME STREAM' : 'PAUSE STREAM'} +
@@ -166,16 +181,14 @@ const Orchestrator: React.FC = () => { {visible.length.toLocaleString()} EVENTS SHOWN
-
-
- Page {page} of {totalPages} - - -
+
+ Page {page} of {totalPages} + +
@@ -192,32 +205,32 @@ const Orchestrator: React.FC = () => { - {visible.length > 0 ? visible.map((r) => ( - - {timeAgo(r.ts)} - - - {r.kind.toUpperCase()} - - - - {r.action} - - - {r.src_decky_uuid ? `${r.src_decky_uuid.slice(0, 8)}…` : '—'} - {' → '} - {r.dst_decky_uuid ? `${r.dst_decky_uuid.slice(0, 8)}…` : '—'} - - {r.success ? '✓' : '✗'} - - {r.payload} - - - )) : ( - + {visible.length > 0 ? visible.map((r) => { + const fresh = now - new Date(r.ts).getTime() < FRESH_MS; + const cls = !r.success ? 'fail' : fresh ? 'fresh' : ''; + const kindCls = r.kind === 'traffic' || r.kind === 'file' ? r.kind : ''; + return ( + + {timeAgo(r.ts)} + + {r.kind} + + {r.action} + + {r.src_decky_uuid ? `${r.src_decky_uuid.slice(0, 8)}…` : '—'} + + {r.dst_decky_uuid ? `${r.dst_decky_uuid.slice(0, 8)}…` : '—'} + + + + {r.success ? '✓' : '✗'} + + + {r.payload} + + ); + }) : ( +