Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e6115bf8 |
+25
-42
@@ -1,50 +1,33 @@
|
|||||||
# Control Center - Environment Variables
|
# =============================================================================
|
||||||
# ======================================
|
# Control Center — Environment Configuration Template
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to `.env` and fill in real values.
|
||||||
|
# Never commit `.env` — it is in .gitignore by default.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# ── Backend Variables ───────────────────────────────────────────────────
|
# ── Database ────────────────────────────────────────────────────────────────
|
||||||
# Server configuration
|
POSTGRES_DB=controlcenter
|
||||||
PORT=8080
|
POSTGRES_USER=controlcenter
|
||||||
CORS_ORIGIN=http://localhost:3000
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||||
|
BACKEND_PORT=8080
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|
||||||
# Database connection (PostgreSQL DSN)
|
# ── Frontend ────────────────────────────────────────────────────────────────
|
||||||
# Format: postgresql://user:password@host:port/database?sslmode=disable
|
FRONTEND_PORT=3000
|
||||||
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
|
||||||
|
|
||||||
# Gateway (OpenClaw) connection
|
# ── CORS ────────────────────────────────────────────────────────────────────
|
||||||
# WebSocket gateway config (primary path)
|
# Comma-separated allowed origins. Use "*" for local dev.
|
||||||
WS_GATEWAY_URL=ws://host.docker.internal:18789/
|
CORS_ORIGIN=http://localhost:3000
|
||||||
# Gateway auth token — same as OPENCLAW_GATEWAY_TOKEN (set in environment)
|
|
||||||
GATEWAY_TOKEN=
|
|
||||||
|
|
||||||
# REST poller config (fallback, only used if WS fails to connect)
|
# ── OpenClaw Gateway ──────────────────────────────────────────────────────────
|
||||||
|
# URL to the OpenClaw gateway agent status endpoint.
|
||||||
|
# In Docker Compose, use the container name or host.docker.internal for the
|
||||||
|
# gateway running outside Compose.
|
||||||
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||||
# Polling interval for agent state updates (fallback only)
|
|
||||||
|
# How often to poll the gateway for agent state updates.
|
||||||
GATEWAY_POLL_INTERVAL=5s
|
GATEWAY_POLL_INTERVAL=5s
|
||||||
|
|
||||||
# ── Frontend Variables (via Vite) ───────────────────────────────────────
|
|
||||||
# The Vite config exposes these as import.meta.env.VITE_*
|
|
||||||
# Set via environment variable when building: VITE_API_URL
|
|
||||||
# VITE_API_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# ── Docker Compose Specific ─────────────────────────────────────────────
|
|
||||||
# When using docker-compose, these are set in the services section
|
|
||||||
# See docker-compose.yml for service-specific environment variables
|
|
||||||
|
|
||||||
# ── Database Configuration ─────────────────────────────────────────────
|
|
||||||
# Set in the db service environment section of docker-compose.yml
|
|
||||||
# POSTGRES_USER=controlcenter
|
|
||||||
# POSTGRES_PASSWORD=controlcenter
|
|
||||||
# POSTGRES_DB=controlcenter
|
|
||||||
|
|
||||||
# ── Development Notes ───────────────────────────────────────────────────
|
|
||||||
# For local development without Docker:
|
|
||||||
# 1. Start PostgreSQL locally
|
|
||||||
# 2. Run: go run ./cmd/server/main.go
|
|
||||||
# 3. Run: npm run dev in frontend/
|
|
||||||
#
|
|
||||||
# For Docker deployment:
|
|
||||||
# 1. Copy .env.example to .env (backend only)
|
|
||||||
# 2. Run: docker compose up -d
|
|
||||||
# 3. Access frontend at http://localhost:3000
|
|
||||||
|
|||||||
+14
-61
@@ -1,4 +1,4 @@
|
|||||||
name: Dev Build & Deploy
|
name: Dev Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -6,82 +6,35 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches: [dev]
|
||||||
|
|
||||||
env:
|
|
||||||
GO_VERSION: "1.23"
|
|
||||||
NODE_VERSION: "22"
|
|
||||||
REGISTRY: code.cubecraftcreations.com
|
|
||||||
BACKEND_IMAGE: ${{ gitea.repository }}/backend
|
|
||||||
FRONTEND_IMAGE: ${{ gitea.repository }}/frontend
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-and-build:
|
build-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Go
|
- name: Setup Go
|
||||||
run: |
|
uses: actions/setup-go@v5
|
||||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz
|
with:
|
||||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
go-version: '1.23'
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Build go-backend
|
||||||
run: |
|
run: go build ./cmd/...
|
||||||
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | sudo tar -C /usr/local --strip-components=1 -xJ
|
working-directory: ./go-backend
|
||||||
echo "/usr/local/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Test go-backend
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
working-directory: ./go-backend
|
working-directory: ./go-backend
|
||||||
|
|
||||||
- name: Build backend
|
- name: Setup Node
|
||||||
run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server
|
uses: actions/setup-node@v4
|
||||||
working-directory: ./go-backend
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|
||||||
- name: Lint frontend
|
|
||||||
run: npm run lint
|
|
||||||
working-directory: ./frontend
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|
||||||
docker-build-push:
|
|
||||||
needs: test-and-build
|
|
||||||
if: gitea.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build & push backend image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./go-backend
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:dev
|
|
||||||
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ gitea.sha }}
|
|
||||||
|
|
||||||
- name: Build & push frontend image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./frontend
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:dev
|
|
||||||
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ gitea.sha }}
|
|
||||||
|
|||||||
@@ -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
-11
@@ -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