Compare commits

..

1 Commits

Author SHA1 Message Date
overseer e4e6115bf8 CUB-119: Fix CI workflow — replace .NET with Go backend build
Dev Build / build-test (pull_request) Successful in 2m53s
2026-05-10 09:52:28 -04:00
9 changed files with 84 additions and 480 deletions
+25 -42
View File
@@ -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
View File
@@ -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 -33
View File
@@ -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} />}
-23
View File
@@ -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)
}
-52
View File
@@ -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 }
}
-159
View File
@@ -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
View File
@@ -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>
+40 -44
View File
@@ -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>
&nbsp;·&nbsp;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>
-55
View File
@@ -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
}