Compare commits
1 Commits
agent/rex/
...
agent/Dex/
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e6115bf8 |
@@ -13,22 +13,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup Go
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
go-version: '1.23'
|
||||||
|
|
||||||
- name: Restore backend
|
- name: Build go-backend
|
||||||
run: dotnet restore
|
run: go build ./cmd/...
|
||||||
working-directory: ./backend
|
working-directory: ./go-backend
|
||||||
|
|
||||||
- name: Build backend
|
- name: Test go-backend
|
||||||
run: dotnet build --no-restore --configuration Release
|
run: go test ./...
|
||||||
working-directory: ./backend
|
working-directory: ./go-backend
|
||||||
|
|
||||||
- name: Test backend
|
|
||||||
run: dotnet test --no-build --configuration Release
|
|
||||||
working-directory: ./backend
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react'
|
import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react'
|
||||||
import { useSSEContext } from '../contexts/SSEContext'
|
|
||||||
import type { SSEStatus } from '../hooks/useSSE'
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: Command, label: 'Hub' },
|
{ to: '/', icon: Command, label: 'Hub' },
|
||||||
@@ -12,29 +10,9 @@ const navItems = [
|
|||||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Small status pill shown in the sidebar footer and mobile header. */
|
|
||||||
function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) {
|
|
||||||
const cfg = {
|
|
||||||
connected: { icon: Wifi, color: 'text-green-500', label: 'Live' },
|
|
||||||
connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' },
|
|
||||||
reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' },
|
|
||||||
error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
|
|
||||||
}[status]
|
|
||||||
|
|
||||||
const Icon = cfg.icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5" title={cfg.label}>
|
|
||||||
<Icon size={14} className={cfg.color} />
|
|
||||||
{showLabel && <span className={`text-xs ${cfg.color}`}>{cfg.label}</span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const { sseStatus } = useSSEContext()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
||||||
@@ -68,15 +46,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
{/* SSE connection status — footer of sidebar */}
|
|
||||||
<div className="px-4 py-3 border-t border-surface-light flex items-center gap-2">
|
|
||||||
<SSEStatusBadge status={sseStatus} />
|
|
||||||
{expanded && (
|
|
||||||
<span className="text-xs text-on-surface-muted whitespace-nowrap">
|
|
||||||
{sseStatus === 'connected' ? 'Live updates on' : sseStatus}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile Header + Bottom Nav */}
|
{/* Mobile Header + Bottom Nav */}
|
||||||
@@ -85,7 +54,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Command size={22} className="text-primary" />
|
<Command size={22} className="text-primary" />
|
||||||
<span className="font-bold">Control Center</span>
|
<span className="font-bold">Control Center</span>
|
||||||
<SSEStatusBadge status={sseStatus} />
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
||||||
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* SSEContext — provides SSE connection status throughout the component tree.
|
|
||||||
* Mount <SSEProvider> once inside QueryClientProvider.
|
|
||||||
*/
|
|
||||||
import { createContext, useContext, type ReactNode } from 'react'
|
|
||||||
import { useRealtimeSync } from '../hooks/useRealtimeSync'
|
|
||||||
import type { SSEStatus } from '../hooks/useSSE'
|
|
||||||
|
|
||||||
interface SSEContextValue {
|
|
||||||
sseStatus: SSEStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
const SSEContext = createContext<SSEContextValue>({ sseStatus: 'connecting' })
|
|
||||||
|
|
||||||
export function SSEProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { sseStatus } = useRealtimeSync()
|
|
||||||
return <SSEContext.Provider value={{ sseStatus }}>{children}</SSEContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Access the SSE connection status from any component. */
|
|
||||||
export function useSSEContext(): SSEContextValue {
|
|
||||||
return useContext(SSEContext)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* useRealtimeSync — mounts the SSE connection once at the app level and
|
|
||||||
* wires incoming events to React Query cache invalidation.
|
|
||||||
*
|
|
||||||
* Event → query key mapping:
|
|
||||||
* agent.status → ['agents']
|
|
||||||
* agent.task → ['tasks'], ['agents']
|
|
||||||
* agent.progress → ['tasks'], ['agents']
|
|
||||||
* fleet.update → ['agents'], ['sessions'], ['tasks']
|
|
||||||
*/
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useCallback } from 'react'
|
|
||||||
import { useSSE, type SSEMessage, type SSEStatus } from './useSSE'
|
|
||||||
|
|
||||||
export function useRealtimeSync(): { sseStatus: SSEStatus } {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
|
||||||
(msg: SSEMessage) => {
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'agent.status':
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'agent.task':
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'agent.progress':
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'fleet.update':
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['sessions'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
// 'connected' and unknown events — no action needed
|
|
||||||
break
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { status: sseStatus } = useSSE({ onMessage: handleMessage })
|
|
||||||
|
|
||||||
return { sseStatus }
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
|
||||||
|
|
||||||
/** SSE connection state reported to consumers. */
|
|
||||||
export type SSEStatus = 'connecting' | 'connected' | 'reconnecting' | 'error'
|
|
||||||
|
|
||||||
/** Typed SSE event received from the backend. */
|
|
||||||
export interface SSEMessage {
|
|
||||||
/** event: field from the SSE frame */
|
|
||||||
type: string
|
|
||||||
/** parsed JSON from the data: field */
|
|
||||||
data: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSSEOptions {
|
|
||||||
/** Endpoint URL — defaults to /api/events */
|
|
||||||
url?: string
|
|
||||||
/** Called for every SSE message (all event types) */
|
|
||||||
onMessage?: (msg: SSEMessage) => void
|
|
||||||
/** Called when connection opens or reconnects */
|
|
||||||
onOpen?: () => void
|
|
||||||
/** Called on unrecoverable error */
|
|
||||||
onError?: (err: Event) => void
|
|
||||||
/** Base delay in ms before the first reconnect attempt (default 1 000) */
|
|
||||||
reconnectBaseMs?: number
|
|
||||||
/** Maximum reconnect delay in ms (default 30 000) */
|
|
||||||
reconnectMaxMs?: number
|
|
||||||
/** Set false to disable auto-connect (useful in tests) */
|
|
||||||
enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const SSE_EVENTS = ['agent.status', 'agent.task', 'agent.progress', 'fleet.update', 'connected'] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSSE — mounts a persistent SSE connection to the Control Center backend.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - Initial connection on mount
|
|
||||||
* - Exponential back-off reconnection on drop
|
|
||||||
* - Cleanup on unmount
|
|
||||||
* - All four event types: agent.status, agent.task, agent.progress, fleet.update
|
|
||||||
*/
|
|
||||||
export function useSSE({
|
|
||||||
url = '/api/events',
|
|
||||||
onMessage,
|
|
||||||
onOpen,
|
|
||||||
onError,
|
|
||||||
reconnectBaseMs = 1_000,
|
|
||||||
reconnectMaxMs = 30_000,
|
|
||||||
enabled = true,
|
|
||||||
}: UseSSEOptions = {}): { status: SSEStatus } {
|
|
||||||
const [status, setStatus] = useState<SSEStatus>('connecting')
|
|
||||||
|
|
||||||
// Stable refs so the effect doesn't need to re-run when callbacks change
|
|
||||||
const onMessageRef = useRef(onMessage)
|
|
||||||
const onOpenRef = useRef(onOpen)
|
|
||||||
const onErrorRef = useRef(onError)
|
|
||||||
onMessageRef.current = onMessage
|
|
||||||
onOpenRef.current = onOpen
|
|
||||||
onErrorRef.current = onError
|
|
||||||
|
|
||||||
const reconnectAttemptRef = useRef(0)
|
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const esRef = useRef<EventSource | null>(null)
|
|
||||||
const mountedRef = useRef(true)
|
|
||||||
|
|
||||||
const clearReconnectTimer = useCallback(() => {
|
|
||||||
if (reconnectTimerRef.current !== null) {
|
|
||||||
clearTimeout(reconnectTimerRef.current)
|
|
||||||
reconnectTimerRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
|
||||||
if (!mountedRef.current || !enabled) return
|
|
||||||
|
|
||||||
// Clean up any existing connection
|
|
||||||
if (esRef.current) {
|
|
||||||
esRef.current.close()
|
|
||||||
esRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(reconnectAttemptRef.current === 0 ? 'connecting' : 'reconnecting')
|
|
||||||
|
|
||||||
const es = new EventSource(url)
|
|
||||||
esRef.current = es
|
|
||||||
|
|
||||||
es.onopen = () => {
|
|
||||||
if (!mountedRef.current) return
|
|
||||||
reconnectAttemptRef.current = 0
|
|
||||||
setStatus('connected')
|
|
||||||
onOpenRef.current?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
es.onerror = (evt) => {
|
|
||||||
if (!mountedRef.current) return
|
|
||||||
|
|
||||||
// EventSource auto-retries but we manage our own to get back-off control
|
|
||||||
es.close()
|
|
||||||
esRef.current = null
|
|
||||||
|
|
||||||
onErrorRef.current?.(evt)
|
|
||||||
|
|
||||||
// Exponential back-off: 1s, 2s, 4s … capped at reconnectMaxMs
|
|
||||||
const delay = Math.min(
|
|
||||||
reconnectBaseMs * 2 ** reconnectAttemptRef.current,
|
|
||||||
reconnectMaxMs,
|
|
||||||
)
|
|
||||||
reconnectAttemptRef.current += 1
|
|
||||||
setStatus('reconnecting')
|
|
||||||
|
|
||||||
clearReconnectTimer()
|
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
|
||||||
if (mountedRef.current) connect()
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register listeners for all known event types
|
|
||||||
for (const eventType of SSE_EVENTS) {
|
|
||||||
es.addEventListener(eventType, (evt: MessageEvent) => {
|
|
||||||
if (!mountedRef.current) return
|
|
||||||
let data: unknown = evt.data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(evt.data as string)
|
|
||||||
} catch {
|
|
||||||
// leave as raw string
|
|
||||||
}
|
|
||||||
onMessageRef.current?.({ type: eventType, data })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch-all for unnamed events (type == 'message')
|
|
||||||
es.onmessage = (evt: MessageEvent) => {
|
|
||||||
if (!mountedRef.current) return
|
|
||||||
let data: unknown = evt.data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(evt.data as string)
|
|
||||||
} catch {
|
|
||||||
// leave as raw string
|
|
||||||
}
|
|
||||||
onMessageRef.current?.({ type: 'message', data })
|
|
||||||
}
|
|
||||||
}, [url, enabled, reconnectBaseMs, reconnectMaxMs, clearReconnectTimer])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mountedRef.current = true
|
|
||||||
if (enabled) connect()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false
|
|
||||||
clearReconnectTimer()
|
|
||||||
if (esRef.current) {
|
|
||||||
esRef.current.close()
|
|
||||||
esRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [connect, enabled, clearReconnectTimer])
|
|
||||||
|
|
||||||
return { status }
|
|
||||||
}
|
|
||||||
@@ -4,16 +4,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
import { ThemeProvider } from './hooks/useTheme'
|
import { ThemeProvider } from './hooks/useTheme'
|
||||||
import { SSEProvider } from './contexts/SSEContext'
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
// No polling — real-time updates come through SSE.
|
staleTime: 30_000,
|
||||||
// staleTime is kept high; data is pushed, not pulled.
|
|
||||||
staleTime: 60_000,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
@@ -25,13 +22,9 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{/* SSEProvider must live inside QueryClientProvider so it can call
|
<BrowserRouter>
|
||||||
useQueryClient() to invalidate caches on incoming events. */}
|
<App />
|
||||||
<SSEProvider>
|
</BrowserRouter>
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
|
||||||
</BrowserRouter>
|
|
||||||
</SSEProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,36 +1,18 @@
|
|||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||||
import { useSSEContext } from '../contexts/SSEContext'
|
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
|
||||||
import { Sun, Moon, Monitor, Zap, Radio } from 'lucide-react'
|
|
||||||
|
|
||||||
const SSE_STATUS_COPY: Record<string, { label: string; description: string; color: string }> = {
|
const REFRESH_PRESETS = [
|
||||||
connected: {
|
{ label: '5s', value: 5_000 },
|
||||||
label: 'Connected',
|
{ label: '10s', value: 10_000 },
|
||||||
description: 'Real-time updates are active. Agent status, tasks, and progress stream live.',
|
{ label: '30s', value: 30_000 },
|
||||||
color: 'text-green-500',
|
{ label: '60s', value: 60_000 },
|
||||||
},
|
]
|
||||||
connecting: {
|
|
||||||
label: 'Connecting…',
|
|
||||||
description: 'Establishing SSE connection to the backend.',
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
},
|
|
||||||
reconnecting: {
|
|
||||||
label: 'Reconnecting…',
|
|
||||||
description: 'Connection lost. Retrying with exponential back-off.',
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
label: 'Disconnected',
|
|
||||||
description: 'Could not connect to the SSE endpoint. Check that the backend is reachable.',
|
|
||||||
color: 'text-red-500',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||||
const { sseStatus } = useSSEContext()
|
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
|
||||||
const sseInfo = SSE_STATUS_COPY[sseStatus] ?? SSE_STATUS_COPY.error
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-2xl">
|
<div className="space-y-8 max-w-2xl">
|
||||||
@@ -98,31 +80,45 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Real-time connection status */}
|
{/* Refresh */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
<Radio size={20} className="text-primary" />
|
<Clock size={20} className="text-primary" />
|
||||||
Real-time Updates
|
Auto Refresh
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
|
||||||
<div>
|
|
||||||
<p className="font-medium">SSE Connection</p>
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm text-on-surface-variant mt-0.5">{sseInfo.description}</p>
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="3"
|
||||||
|
step="1"
|
||||||
|
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const idx = parseInt(e.target.value)
|
||||||
|
setRefreshInterval(REFRESH_PRESETS[idx].value)
|
||||||
|
}}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-on-surface-muted">
|
||||||
|
{REFRESH_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
onClick={() => setRefreshInterval(p.value)}
|
||||||
|
className={`px-3 py-1 rounded-lg transition-colors ${
|
||||||
|
refreshInterval === p.value
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'hover:bg-surface-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-semibold whitespace-nowrap ${sseInfo.color}`}>
|
|
||||||
{sseInfo.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-muted">
|
|
||||||
Endpoint: <code className="bg-surface-light px-1.5 py-0.5 rounded text-on-surface-variant">/api/events</code>
|
|
||||||
· Events: agent.status, agent.task, agent.progress, fleet.update
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-on-surface-muted">
|
|
||||||
Polling is disabled. All status updates are pushed from the server over a persistent SSE connection.
|
|
||||||
The client reconnects automatically with exponential back-off on drop.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* SSE event payload types matching the Go backend (internal/handler/sse.go).
|
|
||||||
*
|
|
||||||
* Event format on the wire:
|
|
||||||
* event: <eventType>
|
|
||||||
* data: <json>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AgentStatus } from '../types'
|
|
||||||
|
|
||||||
/** agent.status — agent came online, went offline, changed state */
|
|
||||||
export interface AgentStatusEvent {
|
|
||||||
agentId: string
|
|
||||||
status: AgentStatus
|
|
||||||
/** Optional human-readable reason (e.g. error message) */
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** agent.task — a task was assigned to or completed by an agent */
|
|
||||||
export interface AgentTaskEvent {
|
|
||||||
agentId: string
|
|
||||||
taskId: string
|
|
||||||
title: string
|
|
||||||
action: 'assigned' | 'completed' | 'failed'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** agent.progress — incremental progress update for a running task */
|
|
||||||
export interface AgentProgressEvent {
|
|
||||||
agentId: string
|
|
||||||
taskId: string
|
|
||||||
progress: number
|
|
||||||
/** Optional description of what is currently happening */
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fleet.update — bulk refresh of all agents (e.g. after a deployment).
|
|
||||||
* The backend may send partial or complete agent state.
|
|
||||||
*/
|
|
||||||
export interface FleetUpdateEvent {
|
|
||||||
/** ISO timestamp of when the snapshot was taken */
|
|
||||||
timestamp: string
|
|
||||||
/** Number of agents in the fleet */
|
|
||||||
agentCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Union of all SSE data payloads keyed by event type. */
|
|
||||||
export type SSEPayloadMap = {
|
|
||||||
'agent.status': AgentStatusEvent
|
|
||||||
'agent.task': AgentTaskEvent
|
|
||||||
'agent.progress': AgentProgressEvent
|
|
||||||
'fleet.update': FleetUpdateEvent
|
|
||||||
connected: { clientCount: number }
|
|
||||||
message: unknown
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user