Compare commits

..

14 Commits

Author SHA1 Message Date
d9a1640b10 CUB-200: sync CI workflows with dev branch
Some checks failed
Dev Build & Deploy / docker-build-push (pull_request) Has been skipped
Dev Build & Deploy / test-and-build (pull_request) Failing after 0s
- Overwrite dev.yml with dev's consolidated version (parameterized Go/Node versions, cleaner install steps)
- Add deploy-dev.yaml from dev (was missing on this branch)
- build-dev.yaml confirmed absent (was deleted on dev in PR #45)
2026-05-20 16:29:57 +00:00
6fd2d9bec4 Merge branch 'dev' into agent/dex/CUB-200-ws-gateway-client
Some checks failed
Dev Build & Deploy / test-and-build (pull_request) Failing after 0s
Dev Build & Deploy / docker-build-push (pull_request) Has been skipped
2026-05-20 08:12:36 -04:00
Joshua King
ee6ad10db9 Replace setup-go/setup-node actions with manual install for Gitea runner compatibility
Some checks failed
Dev Build & Deploy / docker-build-push (push) Blocked by required conditions
Dev Build & Deploy / test-and-build (push) Failing after 14m18s
2026-05-20 08:10:13 -04:00
Joshua King
5f42a3be18 Rename GITEA_TOKEN secret to ACCESS_TOKEN
Some checks failed
Dev Build & Deploy / test-and-build (push) Failing after 4s
Dev Build & Deploy / docker-build-push (push) Has been skipped
2026-05-20 08:08:21 -04:00
Joshua King
0e452941dd Remove deploy-dev job - deployment handled via docker-compose
Some checks failed
Dev Build & Deploy / test-and-build (push) Failing after 0s
Dev Build & Deploy / docker-build-push (push) Has been skipped
2026-05-20 08:06:52 -04:00
Joshua King
87cb517623 Update CI workflow to match Go+React stack with Docker registry push
Some checks failed
Dev Build & Deploy / test-and-build (push) Failing after 1s
Dev Build & Deploy / docker-build-push (push) Has been skipped
Dev Build & Deploy / deploy-dev (push) Has been skipped
2026-05-20 08:04:50 -04:00
Dex
d28d6e8dac CUB-200: implement WebSocket gateway client with v3 protocol
Some checks are pending
Dev Build / build-test (pull_request) Waiting to run
Dev Build / deploy-dev (pull_request) Blocked by required conditions
Replace REST poller with WebSocket client as primary gateway connection:

- wsclient.go: WebSocket client with v3 handshake (connect.challenge →
  connect → hello-ok), frame routing (req/res/event), JSON-RPC Send(),
  auto-reconnect with exponential backoff (1s → 30s max)
- sync.go: Initial sync via agents.list + sessions.list RPCs, merge
  session runtime state into AgentCardData, broadcast fleet.update
- events.go: Real-time event handlers for sessions.changed, presence,
  and agent.config — DB update first, then SSE broadcast
- client.go: REST poller retained as fallback (WS is primary)
- config.go: Add GATEWAY_WS_URL and OPENCLAW_GATEWAY_TOKEN env vars
- main.go: Wire WS client as primary, REST as fallback
- .env.example: Document new WS config vars

Fallback: If WS connection fails, seeded demo data + REST polling
remain available.
2026-05-20 11:33:17 +00:00
Joshua King
0ac4898027 Updates
Some checks failed
Dev Build / deploy-dev (push) Has been cancelled
Dev Build / build-test (push) Has been cancelled
2026-05-20 07:27:31 -04:00
519e872027 Merge pull request 'CUB-126: Update Control Center deployment for Go + React' (#40) from agent/pip/CUB-126-deployment-go-react into dev
All checks were successful
Dev Build / build-test (push) Successful in 1m26s
2026-05-14 05:33:37 -04:00
2b4b9b3e96 CUB-126: Update Control Center deployment for Go + React
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m33s
- Updated docker-compose.yml for Go + React + PostgreSQL
- Go backend multi-stage Dockerfile (already existed)
- React frontend multi-stage Dockerfile with nginx SPA config (already existed)
- Kiosk start script and systemd unit
- Deployment README
- .env.example for environment variables
2026-05-14 05:32:23 -04:00
9a802b4212 Merge pull request 'CUB-123: Integrate gateway, wire PostgreSQL repositories, add SSE streaming' (#37) from agent/dex/CUB-123-gateway-integration into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m23s
Reviewed-on: #37
2026-05-08 21:55:48 -04:00
1a50306f7d Merge branch 'dev' into agent/dex/CUB-123-gateway-integration
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m22s
2026-05-08 21:55:38 -04:00
e8ced74429 CUB-123: integrate gateway, wire PostgreSQL repositories, add SSE streaming
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m23s
- Create repository/ package with pgx-backed CRUD for agents, sessions, tasks, projects
- Define AgentRepo/SessionRepo/TaskRepo/ProjectRepo interfaces
- Update handler to use repository interfaces instead of in-memory stores
- Add SSE broker with GET /api/events endpoint (text/event-stream)
- Add gateway client that polls OpenClaw for agent states
- Add GATEWAY_URL and GATEWAY_POLL_INTERVAL config fields
- Seed 5 demo agents (Otto, Rex, Dex, Hex, Pip) on empty DB
- Update router to wire SSE broker
- All 21 handler tests pass with mock repos
2026-05-08 19:58:06 -04:00
8b8cb8210c CUB-121: build React pages with real API integration
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m11s
Dev Build / build-test (push) Successful in 2m18s
- HubPage: agent summary stats, cards, status badges, progress bars, refresh
- LogsPage: activity feed from tasks, status filter, loading skeleton
- ProjectsPage: project cards with status badges and agent counts
- SessionsPage: responsive table/card view with model/token info
- SettingsPage: dark mode toggle, gateway URL, refresh interval persist
- ThemeProvider with dark/light mode via CSS custom properties
- useLocalStorage hook for settings persistence
- Loading/error/empty states across all pages
- npm run build passes cleanly
2026-05-08 19:53:21 -04:00
37 changed files with 3724 additions and 209 deletions

51
.env.example Normal file
View File

@@ -0,0 +1,51 @@
# Control Center - Environment Variables
# ======================================
# ── Backend Variables ───────────────────────────────────────────────────
# Server configuration
PORT=8080
CORS_ORIGIN=http://localhost:3000
LOG_LEVEL=info
ENVIRONMENT=development
# Database connection (PostgreSQL DSN)
# Format: postgresql://user:password@host:port/database?sslmode=disable
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
# Gateway (OpenClaw) connection — WebSocket (primary)
# WebSocket URL for real-time OpenClaw gateway v3 protocol
GATEWAY_WS_URL=ws://localhost:18789/
# Auth token for the OpenClaw gateway (operator scope)
OPENCLAW_GATEWAY_TOKEN=
# Gateway (OpenClaw) connection — REST (fallback)
# URL to the OpenClaw gateway API for polling agent states (used only if WS fails)
GATEWAY_URL=http://localhost:18789/api/agents
# Polling interval for agent state updates
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

View File

@@ -0,0 +1,126 @@
name: Deploy (Dev)
on:
repository_dispatch:
types:
- dev-build-success
workflow_dispatch:
env:
BINARY_NAME: server
DEV_HOST: ${{ secrets.DEV_HOST }}
DEV_USER: ${{ secrets.DEV_USER }}
DEPLOY_BINARY_PATH: /opt/control-center/server
DEPLOY_FRONTEND_PATH: /usr/share/nginx/html
SERVICE_NAME: control-center-server
FRONTEND_SERVICE: nginx
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Download Go binary
uses: actions/download-artifact@v4
with:
name: go-backend-binary
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist
- name: Make binary executable
run: chmod +x ${{ env.BINARY_NAME }}
- name: Generate deploy script
run: |
cat > deploy.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
BINARY="${1}"
FRONTEND_DIST="${2:-dist}"
BINARY_PATH="${3:-/opt/control-center/server}"
FRONTEND_PATH="${4:-/usr/share/nginx/html}"
BINARY_SERVICE="${5:-control-center-server}"
FRONTEND_SERVICE="${6:-nginx}"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${BINARY_PATH}.${TIMESTAMP}.bak"
echo "=== deploy backend ==="
if [ -f "$BINARY_PATH" ]; then
echo "backing up current binary"
cp "$BINARY_PATH" "$BACKUP"
fi
echo "installing new binary"
cp "$BINARY" "$BINARY_PATH"
chmod +x "$BINARY_PATH"
echo "restarting service"
systemctl reload-or-restart "$BINARY_SERVICE" || systemctl restart "$BINARY_SERVICE"
sleep 3
if ! systemctl is-active --quiet "$BINARY_SERVICE"; then
echo "FAILED: $BINARY_SERVICE did not start — rolling back"
if [ -f "$BACKUP" ]; then
cp "$BACKUP" "$BINARY_PATH"
systemctl restart "$BINARY_SERVICE"
fi
exit 1
fi
echo "backend deploy ok — keeping last 3 backups"
ls -t "${BINARY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
echo "=== deploy frontend ==="
if [ -d "$FRONTEND_DIST" ] && [ -n "$(ls -A "$FRONTEND_DIST" 2>/dev/null)" ]; then
rsync -a --delete "$FRONTEND_DIST/" "$FRONTEND_PATH/"
systemctl reload "$FRONTEND_SERVICE" 2>/dev/null ||:
echo "frontend deploy ok"
fi
echo "=== deploy complete ==="
SCRIPT
chmod +x deploy.sh
- name: Copy artifacts to dev server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
source: "${{ env.BINARY_NAME }},deploy.sh,dist"
target: "/tmp/control-center-deploy"
- name: Execute deploy on dev server
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
set -euo pipefail
cd /tmp/control-center-deploy
sudo ./deploy.sh \
"${{ env.BINARY_NAME }}" \
"dist" \
"${{ env.DEPLOY_BINARY_PATH }}" \
"${{ env.DEPLOY_FRONTEND_PATH }}" \
"${{ env.SERVICE_NAME }}" \
"${{ env.FRONTEND_SERVICE }}"
rm -rf /tmp/control-center-deploy
- name: Notify on failure
if: failure()
uses: appleboy/ssh-action@v1
with:
host: ${{ env.DEV_HOST }}
username: ${{ env.DEV_USER }}
key: ${{ secrets.DEV_SSH_KEY }}
script: |
echo "deploy failed — commit ${{ github.sha }}" > /tmp/control-center-deploy-failure.log

View File

@@ -1,4 +1,4 @@
name: Dev Build name: Dev Build & Deploy
on: on:
pull_request: pull_request:
@@ -6,39 +6,82 @@ 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:
build-test: test-and-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup .NET - name: Install Go
uses: actions/setup-dotnet@v4 run: |
with: curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz
dotnet-version: '9.0.x' echo "/usr/local/go/bin" >> $GITHUB_PATH
- name: Restore backend - name: Install Node.js
run: dotnet restore run: |
working-directory: ./backend 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
echo "/usr/local/bin" >> $GITHUB_PATH
- name: Run backend tests
run: go test ./...
working-directory: ./go-backend
- name: Build backend - name: Build backend
run: dotnet build --no-restore --configuration Release run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server
working-directory: ./backend working-directory: ./go-backend
- name: Test backend
run: dotnet test --no-build --configuration Release
working-directory: ./backend
- name: Setup Node
uses: actions/setup-node@v4
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 }}

268
README-deployment.md Normal file
View File

@@ -0,0 +1,268 @@
# Control Center Deployment Guide
This document covers the Docker Compose deployment and kiosk configuration for the Control Center Go + React application.
## Quick Start
```bash
# Start all services (backend, frontend, database)
docker compose up -d
# View logs
docker compose logs -f
# Stop all services
docker compose down
# Stop and remove volumes (database data)
docker compose down -v
```
## Architecture
```
┌─────────────────┐
│ Frontend │ Port 3000 (host) → 80 (container)
│ React + nginx │ Serves SPA, proxies /api/ to backend
└────────┬────────┘
│ HTTP
┌────────▼────────┐
│ Backend │ Port 8080 (host) → 8080 (container)
│ Go HTTP API │ PostgreSQL-backed REST API
└────────┬────────┘
│ PostgreSQL
┌────────▼────────┐
│ PostgreSQL │ Port 5432 (internal only)
│ Database │ Persistent volume at /var/lib/postgresql/data
└─────────────────┘
```
## Services
### Backend (`go-backend`)
- **Image**: Custom `alpine:latest` with Go binary
- **Port**: 8080
- **Build**: Multi-stage from `go-backend/Dockerfile`
- **Environment Variables**:
- `PORT` (default: 8080)
- `DATABASE_URL` (PostgreSQL DSN)
- `CORS_ORIGIN` (default: `*`)
- `LOG_LEVEL` (default: `info`)
- `ENVIRONMENT` (default: `development`)
- `GATEWAY_URL` (OpenClaw gateway endpoint)
### Frontend (`frontend`)
- **Image**: `nginx:1.27-alpine`
- **Port**: 80 (internal) → 3000 (host)
- **Build**: Multi-stage from `frontend/Dockerfile`
- Node 22 for build
- Nginx 1.27 for serving
- **Config**: Custom nginx config in `frontend/nginx.conf`
- **Environment Variables**:
- `VITE_API_URL` (passed at build time via Vite config)
### Database (`db`)
- **Image**: `postgres:16-alpine`
- **Port**: 5432 (internal only)
- **Volume**: `postgres-data:/var/lib/postgresql/data`
- **Environment Variables**:
- `POSTGRES_USER` (default: `controlcenter`)
- `POSTGRES_PASSWORD` (default: `controlcenter`)
- `POSTGRES_DB` (default: `controlcenter`)
## Kiosk Mode
For dedicated display installations (e.g., control center dashboard), Chromium can run in kiosk mode.
### Installation
1. **Install the systemd service** (on Debian/Ubuntu with systemd):
```bash
sudo cp kiosk/control-center-kiosk.service /etc/systemd/system/
sudo systemctl daemon-reload
```
2. **Enable auto-start**:
```bash
sudo systemctl enable control-center-kiosk
```
3. **Start the service**:
```bash
sudo systemctl start control-center-kiosk
```
4. **Check status and logs**:
```bash
sudo systemctl status control-center-kiosk
sudo journalctl -u control-center-kiosk -f
```
### Manual Launch
```bash
# From project root
./kiosk/start-kiosk.sh http://localhost:3000
```
### Uninstall
```bash
# Stop and disable service
sudo systemctl stop control-center-kiosk
sudo systemctl disable control-center-kiosk
sudo rm /etc/systemd/system/control-center-kiosk.service
sudo systemctl daemon-reload
```
### Kiosk Requirements
- **Browser**: `chromium-browser` (install via `apt-get install chromium`)
- **Display**: X11 session with `DISPLAY=:0`
- **User**: Must run as a user with X11 access (typically `overseer`)
- **Permissions**: Read access to the project directory
## Environment Variables Reference
### Backend (`go-backend/.env`)
```bash
PORT=8080
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
CORS_ORIGIN=*
LOG_LEVEL=info
ENVIRONMENT=development
GATEWAY_URL=http://localhost:18789/api/agents
GATEWAY_POLL_INTERVAL=5s
```
### Frontend (build-time)
```bash
VITE_API_URL=http://localhost:8080
```
### Docker Compose
Set via `services.<name>.environment` in `docker-compose.yml`:
```yaml
services:
backend:
environment:
- DATABASE_URL=...
frontend:
environment:
- VITE_API_URL=...
db:
environment:
- POSTGRES_USER=...
- POSTGRES_PASSWORD=...
- POSTGRES_DB=...
```
## Development
### Local Development (non-Docker)
```bash
# Backend
cd go-backend
go run ./cmd/server/main.go
# Frontend
cd frontend
npm install
npm run dev
```
### Database Migrations
```bash
# If using pgx/migrate or similar
# The database is created automatically on first connection if it doesn't exist
```
## Troubleshooting
### Backend won't connect to database
```bash
# Check database container status
docker compose ps
# View database logs
docker compose logs db
# Test database connectivity from backend
docker compose exec backend ping db
```
### Frontend can't reach backend
```bash
# Check network connectivity
docker compose exec frontend ping backend
# Verify backend is running
docker compose logs backend
```
### Kiosk browser won't start
```bash
# Check Chromium installation
which chromium-browser
# Check X11 forwarding
echo $DISPLAY
# Manual launch for debugging
./kiosk/start-kiosk.sh http://localhost:3000
```
### Port conflicts
If ports 8080, 3000, or 5432 are already in use, modify `docker-compose.yml`:
```yaml
services:
backend:
ports:
- "8081:8080" # Change host port
frontend:
ports:
- "3001:80" # Change host port
```
## Production Considerations
1. **HTTPS**: Add a reverse proxy (nginx/Traefik) for SSL termination
2. **Database security**: Use strong passwords, enable SSL
3. **CORS**: Restrict `CORS_ORIGIN` to production domain
4. **Logs**: Configure log aggregation (e.g., ELK, Loki)
5. **Backups**: Regular PostgreSQL volume backups
6. **Monitoring**: Add health checks and alerting
## Files
| File/Directory | Purpose |
|----------------|---------|
| `docker-compose.yml` | Service definitions and configuration |
| `.env.example` | Environment variable template |
| `go-backend/Dockerfile` | Backend build definition |
| `frontend/Dockerfile` | Frontend build definition |
| `frontend/nginx.conf` | Nginx config for SPA routing |
| `kiosk/start-kiosk.sh` | Kiosk browser startup script |
| `kiosk/control-center-kiosk.service` | Systemd unit for auto-start |

72
docker-compose.yml Normal file
View File

@@ -0,0 +1,72 @@
# Control Center - Go + React + PostgreSQL Deployment
# ============================================================
services:
# ── Backend Service (Go) ───────────────────────────────────────────────
backend:
build:
context: ./go-backend
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgresql://controlcenter:controlcenter@db:5432/controlcenter?sslmode=disable
- CORS_ORIGIN=http://localhost:3000
- LOG_LEVEL=info
- ENVIRONMENT=production
- PORT=8080
- GATEWAY_URL=http://host.docker.internal:18789/api/agents
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- control-center-network
restart: unless-stopped
# ── Frontend Service (React) ───────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
depends_on:
- backend
environment:
- VITE_API_URL=http://localhost:8080
networks:
- control-center-network
restart: unless-stopped
# ── Database Service (PostgreSQL 16) ───────────────────────────────────
db:
image: postgres:16-alpine
container_name: control-center-db
environment:
- POSTGRES_USER=controlcenter
- POSTGRES_PASSWORD=controlcenter
- POSTGRES_DB=controlcenter
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U controlcenter -d controlcenter"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- control-center-network
restart: unless-stopped
networks:
control-center-network:
driver: bridge
volumes:
postgres-data:

View File

@@ -0,0 +1,29 @@
import { useState, useEffect, useCallback } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item !== null ? (JSON.parse(item) as T) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(storedValue))
} catch {
// storage full or unavailable
}
}, [key, storedValue])
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const next = value instanceof Function ? value(prev) : value
return next
})
}, [])
return [storedValue, setValue]
}

View File

@@ -0,0 +1,50 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
toggleTheme: () => void
isDark: boolean
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark'
const stored = localStorage.getItem('cc-theme')
if (stored === 'light' || stored === 'dark') return stored
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(getInitialTheme)
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
root.classList.remove('light')
} else {
root.classList.add('light')
root.classList.remove('dark')
}
localStorage.setItem('cc-theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -18,6 +18,17 @@
--color-status-thinking: #A78BFA; --color-status-thinking: #A78BFA;
--color-status-error: #F87171; --color-status-error: #F87171;
--color-status-offline: #64748B; --color-status-offline: #64748B;
/* Light mode overrides */
--color-light-surface-darkest: #F8FAFC;
--color-light-surface-dark: #F1F5F9;
--color-light-surface-medium: #E2E8F0;
--color-light-surface-light: #CBD5E1;
--color-light-surface-lighter: #94A3B8;
--color-light-on-surface: #0F172A;
--color-light-on-surface-variant: #475569;
--color-light-on-surface-muted: #64748B;
--color-light-primary: #0284C7;
} }
body { body {
@@ -27,3 +38,39 @@ body {
color: var(--color-on-surface); color: var(--color-on-surface);
font-family: 'Inter', 'Roboto', sans-serif; font-family: 'Inter', 'Roboto', sans-serif;
} }
/* Dark theme (default) */
html.dark body {
background-color: var(--color-surface-darkest);
color: var(--color-on-surface);
}
/* Light theme */
html.light body {
background-color: var(--color-light-surface-darkest);
color: var(--color-light-on-surface);
}
html.light {
--color-surface-darkest: var(--color-light-surface-darkest);
--color-surface-dark: var(--color-light-surface-dark);
--color-surface-medium: var(--color-light-surface-medium);
--color-surface-light: var(--color-light-surface-light);
--color-surface-lighter: var(--color-light-surface-lighter);
--color-on-surface: var(--color-light-on-surface);
--color-on-surface-variant: var(--color-light-on-surface-variant);
--color-on-surface-muted: var(--color-light-on-surface-muted);
--color-primary: var(--color-light-primary);
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--color-surface-light) 25%, var(--color-surface-lighter) 50%, var(--color-surface-light) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.5rem;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 './index.css' import './index.css'
import App from './App' import App from './App'
@@ -19,11 +20,13 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ErrorBoundary> <ErrorBoundary>
<QueryClientProvider client={queryClient}> <ThemeProvider>
<BrowserRouter> <QueryClientProvider client={queryClient}>
<App /> <BrowserRouter>
</BrowserRouter> <App />
</QueryClientProvider> </BrowserRouter>
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary> </ErrorBoundary>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,91 +1,198 @@
import { useQuery } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { listAgents } from '../services/api' import { listAgents } from '../services/api'
import { Activity, AlertTriangle } from 'lucide-react' import { Activity, AlertTriangle, RefreshCw, Bot, Zap, Coffee, AlertCircle } from 'lucide-react'
import type { Agent } from '../types'
function statusStats(agents: Agent[]) {
const counts = { total: agents.length, active: 0, idle: 0, thinking: 0, error: 0 }
for (const a of agents) {
if (a.status in counts) counts[a.status as keyof typeof counts]++
}
return counts
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-500',
idle: 'bg-yellow-500',
thinking: 'bg-blue-500',
error: 'bg-red-500',
}
export default function HubPage() { export default function HubPage() {
const { data, isLoading, error } = useQuery({ const queryClient = useQueryClient()
const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['agents'], queryKey: ['agents'],
queryFn: listAgents, queryFn: listAgents,
}) })
const agents = data?.data ?? []
const stats = statusStats(agents)
if (isLoading) { if (isLoading) {
return ( return <HubSkeleton />
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center h-64 text-danger"> <div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertTriangle size={24} className="mr-2" /> <AlertCircle size={48} className="text-danger" />
Failed to load agents <p className="text-danger text-lg">Failed to load agents</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['agents'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div> </div>
) )
} }
const agents = data?.data ?? []
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header> {/* Header */}
<h1 className="text-2xl font-bold">Command Hub</h1> <header className="flex items-center justify-between flex-wrap gap-2">
<p className="text-on-surface-variant">Agent fleet overview</p> <div>
<h1 className="text-2xl font-bold">Command Hub</h1>
<p className="text-on-surface-variant">Agent fleet overview</p>
</div>
<button
onClick={() => refetch()}
disabled={isRefetching}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={isRefetching ? 'animate-spin' : ''} />
Refresh
</button>
</header> </header>
{/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard icon={Bot} label="Total" value={stats.total} color="text-on-surface" />
<StatCard icon={Zap} label="Active" value={stats.active} color="text-green-500" />
<StatCard icon={Coffee} label="Idle" value={stats.idle} color="text-yellow-500" />
<StatCard icon={AlertTriangle} label="Errors" value={stats.error} color="text-red-500" />
</div>
{/* Agent grid */}
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<Bot size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No agents registered</p>
<p className="text-on-surface-muted text-sm">Agents will appear here once connected.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{agents.map((agent) => (
<div
key={agent.id}
className="p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
>
{/* Agent identity */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-surface-light flex items-center justify-center text-lg font-bold shrink-0">
{agent.displayName.charAt(0)}
</div>
<div>
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
<p className="text-xs text-on-surface-variant">{agent.role}</p>
</div>
</div>
<StatusBadge status={agent.status} />
</div>
{/* Current task */}
{agent.currentTask && (
<div className="mb-2 text-sm text-on-surface-variant truncate">
{agent.currentTask}
</div>
)}
{/* Progress bar */}
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
/>
</div>
)}
{/* Footer info */}
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
<Activity size={12} />
<span>{agent.channel}</span>
<span>·</span>
<span>{agent.lastActivity}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) {
return (
<div className="p-4 rounded-xl border border-surface-light bg-surface-dark flex items-center gap-3">
<div className={`p-2 rounded-lg bg-surface-light ${color}`}>
<Icon size={20} />
</div>
<div>
<p className="text-xs text-on-surface-variant">{label}</p>
<p className="text-xl font-bold">{value}</p>
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
return (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
</div>
)
}
function HubSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-48 skeleton mb-2" />
<div className="h-4 w-36 skeleton" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg skeleton" />
<div className="flex-1">
<div className="h-3 w-12 skeleton mb-2" />
<div className="h-6 w-8 skeleton" />
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{agents.map((agent) => ( {[...Array(6)].map((_, i) => (
<div <div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
key={agent.id} <div className="flex items-start gap-3 mb-3">
className="p-4 rounded-xl border border-surface-light bg-surface-dark" <div className="h-10 w-10 rounded-full skeleton shrink-0" />
> <div className="flex-1">
<div className="flex items-start justify-between mb-3"> <div className="h-4 w-24 skeleton mb-1" />
<div> <div className="h-3 w-16 skeleton" />
<h3 className="font-semibold">{agent.displayName}</h3>
<p className="text-xs text-on-surface-variant">{agent.role}</p>
</div> </div>
<StatusDot status={agent.status} /> <div className="h-6 w-16 rounded-full skeleton" />
</div>
{agent.currentTask && (
<div className="text-sm text-on-surface-variant mb-2">
{agent.currentTask}
</div>
)}
{agent.taskProgress !== undefined && (
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${agent.taskProgress}%` }}
/>
</div>
)}
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
<Activity size={12} />
{agent.channel} · {agent.lastActivity}
</div> </div>
<div className="h-4 w-full skeleton mb-2" />
<div className="h-2 w-full skeleton rounded-full" />
<div className="mt-3 h-3 w-32 skeleton" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
) )
} }
function StatusDot({ status }: { status: string }) {
const colorMap: Record<string, string> = {
active: 'bg-status-active',
idle: 'bg-status-idle',
thinking: 'bg-status-thinking',
error: 'bg-status-error',
}
return (
<div className="flex items-center gap-1">
<div className={`w-2.5 h-2.5 rounded-full ${colorMap[status] ?? 'bg-status-offline'}`} />
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
</div>
)
}

View File

@@ -1,8 +1,182 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { listTasks } from '../services/api'
import { AlertCircle, RefreshCw, Filter, CheckCircle, Circle, Clock, XCircle, Loader, ListTodo } from 'lucide-react'
import type { Task } from '../types'
const STATUS_FILTERS = ['all', 'pending', 'running', 'completed', 'failed'] as const
type StatusFilter = (typeof STATUS_FILTERS)[number]
const STATUS_ICON: Record<string, React.ElementType> = {
pending: Clock,
running: Loader,
completed: CheckCircle,
failed: XCircle,
}
const STATUS_COLOR: Record<string, string> = {
pending: 'text-yellow-500',
running: 'text-blue-400',
completed: 'text-green-500',
failed: 'text-red-500',
}
export default function LogsPage() { export default function LogsPage() {
const queryClient = useQueryClient()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const { data, isLoading, error } = useQuery({
queryKey: ['tasks'],
queryFn: listTasks,
})
const tasks = (data?.data ?? []) as Task[]
const filtered = statusFilter === 'all'
? tasks
: tasks.filter((t) => t.status === statusFilter)
// Sort newest first
const sorted = [...filtered].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
if (isLoading) return <LogsSkeleton />
if (error) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load activity logs</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['tasks'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
return ( return (
<div> <div className="space-y-6">
<h1 className="text-2xl font-bold mb-4">Logs</h1> <header>
<p className="text-on-surface-variant">Activity logs will appear here.</p> <h1 className="text-2xl font-bold">Activity Logs</h1>
<p className="text-on-surface-variant">Task activity across all agents</p>
</header>
{/* Filter tabs */}
<div className="flex items-center gap-1 flex-wrap">
<Filter size={16} className="text-on-surface-muted mr-1" />
{STATUS_FILTERS.map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`px-3 py-1.5 rounded-lg text-sm capitalize transition-colors ${
statusFilter === f
? 'bg-primary/10 text-primary'
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
}`}
>
{f}
</button>
))}
</div>
{/* Activity feed */}
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<ListTodo size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No tasks found</p>
<p className="text-on-surface-muted text-sm">
{statusFilter === 'all' ? 'Tasks will appear here as agents execute work.' : `No ${statusFilter} tasks.`}
</p>
</div>
) : (
<div className="space-y-2">
{sorted.map((task) => {
const Icon = STATUS_ICON[task.status] ?? Circle
const fmtTime = formatTime(task.createdAt)
return (
<div
key={task.id}
className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
>
<div className={`mt-0.5 shrink-0 ${STATUS_COLOR[task.status] ?? 'text-on-surface-muted'}`}>
<Icon size={20} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{task.title}</p>
<p className="text-xs text-on-surface-variant mt-0.5">
Agent {task.agentId}
{task.sessionKey && ` · ${task.sessionKey}`}
</p>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<span className={`text-xs capitalize px-2 py-0.5 rounded-full font-medium ${
statusFilter !== 'all'
? 'bg-primary/10 text-primary'
: 'bg-surface-light text-on-surface-variant'
}`}>
{task.status}
</span>
{task.progress != null && task.progress > 0 && task.progress < 100 && (
<span className="text-xs text-on-surface-muted">{task.progress}%</span>
)}
</div>
<span className="shrink-0 text-xs text-on-surface-muted whitespace-nowrap">
{fmtTime}
</span>
</div>
)
})}
</div>
)}
</div>
)
}
function formatTime(iso: string): string {
try {
const d = new Date(iso)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60_000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return d.toLocaleDateString()
} catch {
return iso
}
}
function LogsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-44 skeleton mb-2" />
<div className="h-4 w-56 skeleton" />
</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 w-20 rounded-lg skeleton" />
))}
</div>
<div className="space-y-2">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="h-5 w-5 rounded-full skeleton shrink-0" />
<div className="flex-1">
<div className="h-4 w-3/4 skeleton mb-1" />
<div className="h-3 w-1/2 skeleton" />
</div>
<div className="h-6 w-20 rounded-full skeleton" />
<div className="h-3 w-16 skeleton" />
</div>
))}
</div>
</div> </div>
) )
} }

View File

@@ -1,8 +1,117 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { listProjects } from '../services/api'
import { AlertCircle, RefreshCw, FolderKanban, Users } from 'lucide-react'
const STATUS_COLORS: Record<string, string> = {
planned: 'bg-purple-500',
active: 'bg-green-500',
paused: 'bg-yellow-500',
completed: 'bg-blue-400',
}
export default function ProjectsPage() { export default function ProjectsPage() {
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ['projects'],
queryFn: listProjects,
})
const projects = data?.data ?? []
if (isLoading) return <ProjectsSkeleton />
if (error) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load projects</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['projects'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
return ( return (
<div> <div className="space-y-6">
<h1 className="text-2xl font-bold mb-4">Projects</h1> <header>
<p className="text-on-surface-variant">Tracked projects will appear here.</p> <h1 className="text-2xl font-bold">Projects</h1>
<p className="text-on-surface-variant">Tracked projects and initiatives</p>
</header>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<FolderKanban size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No projects tracked</p>
<p className="text-on-surface-muted text-sm">Projects synced from Linear will appear here.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<div
key={project.id}
className="p-5 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors flex flex-col"
>
<div className="flex items-start justify-between mb-3">
<FolderKanban size={20} className="text-on-surface-variant shrink-0 mt-0.5" />
<ProjectStatusBadge status={project.status} />
</div>
<h3 className="font-semibold mb-1">{project.name}</h3>
{project.description && (
<p className="text-sm text-on-surface-variant mb-4 line-clamp-2 flex-1">
{project.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-on-surface-muted pt-3 border-t border-surface-light">
<Users size={14} />
<span>{project.agentIds?.length ?? 0} agent{(project.agentIds?.length ?? 0) !== 1 ? 's' : ''} assigned</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
function ProjectStatusBadge({ status }: { status: string }) {
return (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
</div>
)
}
function ProjectsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-40 skeleton mb-2" />
<div className="h-4 w-56 skeleton" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="p-5 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex justify-between mb-3">
<div className="h-5 w-5 rounded skeleton" />
<div className="h-6 w-20 rounded-full skeleton" />
</div>
<div className="h-5 w-3/4 skeleton mb-2" />
<div className="h-4 w-full skeleton mb-2" />
<div className="h-4 w-2/3 skeleton mb-4" />
<div className="pt-3 border-t border-surface-light">
<div className="h-3 w-32 skeleton" />
</div>
</div>
))}
</div>
</div> </div>
) )
} }

View File

@@ -1,8 +1,177 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { listSessions } from '../services/api'
import { AlertCircle, Monitor, RefreshCw, Cpu, MessageSquare, Clock, Hash } from 'lucide-react'
export default function SessionsPage() { export default function SessionsPage() {
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ['sessions'],
queryFn: listSessions,
})
const sessions = data?.data ?? []
if (isLoading) return <SessionsSkeleton />
if (error) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle size={48} className="text-danger" />
<p className="text-danger text-lg">Failed to load sessions</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['sessions'] })}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
)
}
return ( return (
<div> <div className="space-y-6">
<h1 className="text-2xl font-bold mb-4">Sessions</h1> <header>
<p className="text-on-surface-variant">Active sessions will appear here.</p> <h1 className="text-2xl font-bold">Sessions</h1>
<p className="text-on-surface-variant">Active and recent agent sessions</p>
</header>
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
<Monitor size={40} className="text-on-surface-muted" />
<p className="text-on-surface-muted text-lg">No active sessions</p>
<p className="text-on-surface-muted text-sm">Sessions will appear when agents connect.</p>
</div>
) : (
<>
{/* Desktop: Table view */}
<div className="hidden lg:block overflow-x-auto rounded-xl border border-surface-light">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-light bg-surface-dark">
<th className="text-left p-4 font-medium text-on-surface-variant">Agent</th>
<th className="text-left p-4 font-medium text-on-surface-variant">Session Key</th>
<th className="text-left p-4 font-medium text-on-surface-variant">Channel</th>
<th className="text-left p-4 font-medium text-on-surface-variant">Model</th>
<th className="text-right p-4 font-medium text-on-surface-variant">Context Tokens</th>
<th className="text-right p-4 font-medium text-on-surface-variant">Started</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr
key={s.id}
className="border-b border-surface-light hover:bg-surface-dark/50 transition-colors"
>
<td className="p-4 font-medium">{s.agentId}</td>
<td className="p-4">
<code className="text-xs bg-surface-light px-2 py-1 rounded text-on-surface-variant">
{s.sessionKey}
</code>
</td>
<td className="p-4 text-on-surface-variant">{s.channel}</td>
<td className="p-4 text-on-surface-variant">{s.model}</td>
<td className="p-4 text-right tabular-nums text-on-surface-variant">
{s.contextTokens.toLocaleString()}
</td>
<td className="p-4 text-right text-on-surface-muted whitespace-nowrap">
{formatDateTime(s.startedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: Card view */}
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
{sessions.map((s) => (
<div
key={s.id}
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
>
<div className="flex items-center gap-2 mb-3">
<Monitor size={16} className="text-primary" />
<span className="font-medium text-sm">{s.agentId}</span>
</div>
<div className="space-y-2 text-sm">
<Row icon={Hash} label="Session">{s.sessionKey}</Row>
<Row icon={MessageSquare} label="Channel">{s.channel}</Row>
<Row icon={Cpu} label="Model">{s.model}</Row>
<Row icon={Hash} label="Tokens">{s.contextTokens.toLocaleString()}</Row>
<Row icon={Clock} label="Started">{formatDateTime(s.startedAt)}</Row>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
function Row({ icon: Icon, label, children }: { icon: React.ElementType; label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<Icon size={14} className="text-on-surface-muted shrink-0" />
<span className="text-on-surface-muted text-xs w-14 shrink-0">{label}</span>
<span className="text-on-surface-variant truncate">{children}</span>
</div>
)
}
function formatDateTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return iso
}
}
function SessionsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-8 w-40 skeleton mb-2" />
<div className="h-4 w-56 skeleton" />
</div>
{/* Desktop skeleton */}
<div className="hidden lg:block rounded-xl border border-surface-light overflow-hidden">
<div className="bg-surface-dark p-4 border-b border-surface-light">
<div className="grid grid-cols-6 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-3 skeleton" />
))}
</div>
</div>
{[...Array(5)].map((_, i) => (
<div key={i} className="border-b border-surface-light p-4">
<div className="grid grid-cols-6 gap-4">
{[...Array(6)].map((_, j) => (
<div key={j} className="h-4 skeleton" />
))}
</div>
</div>
))}
</div>
{/* Mobile skeleton */}
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
<div className="h-4 w-24 skeleton mb-3" />
{[...Array(5)].map((_, j) => (
<div key={j} className="h-4 w-full skeleton mb-2" />
))}
</div>
))}
</div>
</div> </div>
) )
} }

View File

@@ -1,8 +1,126 @@
import { useTheme } from '../hooks/useTheme'
import { useLocalStorage } from '../hooks/useLocalStorage'
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
const REFRESH_PRESETS = [
{ label: '5s', value: 5_000 },
{ label: '10s', value: 10_000 },
{ label: '30s', value: 30_000 },
{ label: '60s', value: 60_000 },
]
export default function SettingsPage() { export default function SettingsPage() {
const { isDark, toggleTheme } = useTheme()
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
return ( return (
<div> <div className="space-y-8 max-w-2xl">
<h1 className="text-2xl font-bold mb-4">Settings</h1> <header>
<p className="text-on-surface-variant">System settings will appear here.</p> <h1 className="text-2xl font-bold">Settings</h1>
<p className="text-on-surface-variant">Application preferences</p>
</header>
{/* Appearance */}
<section className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Monitor size={20} className="text-primary" />
Appearance
</h2>
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Dark Mode</p>
<p className="text-sm text-on-surface-variant">Toggle between dark and light themes</p>
</div>
<button
onClick={toggleTheme}
className={`relative w-14 h-8 rounded-full transition-colors duration-200 ${
isDark ? 'bg-primary' : 'bg-surface-lighter'
}`}
aria-label="Toggle dark mode"
>
<div
className={`absolute top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 flex items-center justify-center ${
isDark ? 'translate-x-7' : 'translate-x-1'
}`}
>
{isDark ? <Moon size={14} className="text-surface-darkest" /> : <Sun size={14} className="text-yellow-500" />}
</div>
</button>
</div>
</div>
</section>
{/* Connection */}
<section className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Zap size={20} className="text-primary" />
Connection
</h2>
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
<div>
<label htmlFor="gateway-url" className="block text-sm font-medium mb-1">
Gateway URL
</label>
<input
id="gateway-url"
type="url"
value={gatewayUrl}
onChange={(e) => setGatewayUrl(e.target.value)}
placeholder="http://localhost:8080"
className="w-full px-3 py-2 rounded-lg border border-surface-light bg-surface-darkest text-on-surface placeholder:text-on-surface-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
/>
<p className="text-xs text-on-surface-muted mt-1">
The backend Gateway address for API requests
</p>
</div>
</div>
</section>
{/* Refresh */}
<section className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock size={20} className="text-primary" />
Auto Refresh
</h2>
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
<div className="flex flex-col gap-2">
<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>
</div>
</section>
</div> </div>
) )
} }

View File

@@ -13,9 +13,10 @@ import (
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/gateway"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
) )
func main() { func main() {
@@ -28,32 +29,64 @@ func main() {
})) }))
slog.SetDefault(logger) slog.SetDefault(logger)
// ── Database (optional until CUB-120 schema is ready) ────────────────── // ── Database ───────────────────────────────────────────────────────────
var pool *db.Pool pool, err := db.New(cfg.DatabaseURL)
if cfg.DatabaseURL != "" { if err != nil {
var err error slog.Error("database connection failed", "error", err)
pool, err = db.New(cfg.DatabaseURL) os.Exit(1)
if err != nil { }
slog.Warn("database connection failed; running without DB", "error", err) defer pool.Close()
}
// ── Repositories (PostgreSQL-backed) ───────────────────────────────────
agentRepo := repository.NewAgentRepository(pool.Pool)
sessionRepo := repository.NewSessionRepository(pool.Pool)
taskRepo := repository.NewTaskRepository(pool.Pool)
projectRepo := repository.NewProjectRepository(pool.Pool)
// ── Seed demo agents on first boot ─────────────────────────────────────
if err := gateway.SeedDemoAgents(context.Background(), agentRepo); err != nil {
slog.Error("seed demo agents failed", "error", err)
os.Exit(1)
} }
// ── Stores (in-memory for now; PostgreSQL after CUB-120) ──────────────── // ── SSE Broker ─────────────────────────────────────────────────────────
agentStore := store.NewAgentStore() broker := handler.NewBroker()
sessionStore := store.NewSessionStore()
taskStore := store.NewTaskStore()
projectStore := store.NewProjectStore()
// ── HTTP handler ─────────────────────────────────────────────────────── // ── HTTP handler ───────────────────────────────────────────────────────
h := handler.NewHandler(agentStore, sessionStore, taskStore, projectStore) h := handler.NewHandler(agentRepo, sessionRepo, taskRepo, projectRepo)
// ── Router ───────────────────────────────────────────────────────────── // ── Router ─────────────────────────────────────────────────────────────
r := router.New(&router.Dependencies{ r := router.New(&router.Dependencies{
Handler: h, Handler: h,
DB: pool, Pool: pool,
CORSOrigin: cfg.CORSOrigin, CORSOrigin: cfg.CORSOrigin,
Broker: broker,
}) })
// ── Gateway: WS primary + REST fallback ────────────────────────────────
// WebSocket client (primary — real-time events via OpenClaw v3 protocol)
wsClient := gateway.NewWSClient(gateway.WSConfig{
URL: cfg.WSGatewayURL,
AuthToken: cfg.WSGatewayToken,
}, agentRepo, broker, logger)
// REST polling client (fallback — only used if WS connection fails)
restClient := gateway.NewClient(gateway.Config{
URL: cfg.GatewayURL,
PollInterval: cfg.GatewayPollInterval,
}, agentRepo, broker)
// Wire them: WS notifies REST to stand down on successful connect
wsClient.SetRESTClient(restClient)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start WS client first (primary)
go wsClient.Start(ctx)
// Start REST client (fallback polling)
go restClient.Start(ctx)
// ── Server ───────────────────────────────────────────────────────────── // ── Server ─────────────────────────────────────────────────────────────
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port), Addr: fmt.Sprintf(":%d", cfg.Port),
@@ -78,18 +111,16 @@ func main() {
<-quit <-quit
slog.Info("shutting down server...") slog.Info("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) cancel() // stop gateway clients
defer cancel()
if err := srv.Shutdown(ctx); err != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("server forced to shutdown", "error", err) slog.Error("server forced to shutdown", "error", err)
os.Exit(1) os.Exit(1)
} }
if pool != nil {
pool.Close()
}
slog.Info("server exited cleanly") slog.Info("server exited cleanly")
} }
@@ -104,4 +135,4 @@ func parseLogLevel(level string) slog.Level {
default: default:
return slog.LevelInfo return slog.LevelInfo
} }
} }

View File

@@ -7,6 +7,7 @@ require (
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgx/v5 v5.7.2
) )

View File

@@ -17,6 +17,8 @@ github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=

View File

@@ -5,26 +5,35 @@ package config
import ( import (
"os" "os"
"strconv" "strconv"
"time"
) )
// Config holds all application configuration. // Config holds all application configuration.
type Config struct { type Config struct {
Port int Port int
DatabaseURL string DatabaseURL string
CORSOrigin string CORSOrigin string
LogLevel string LogLevel string
Environment string Environment string
GatewayURL string // REST fallback URL
GatewayPollInterval time.Duration // REST fallback poll interval
WSGatewayURL string // WebSocket gateway URL
WSGatewayToken string // WebSocket auth token
} }
// Load reads configuration from environment variables, applying defaults where // Load reads configuration from environment variables, applying defaults where
// values are not set. All secrets come from the environment — nothing is hardcoded. // values are not set. All secrets come from the environment — nothing is hardcoded.
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Port: getEnvInt("PORT", 8080), Port: getEnvInt("PORT", 8080),
DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"), DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"),
CORSOrigin: getEnv("CORS_ORIGIN", "*"), CORSOrigin: getEnv("CORS_ORIGIN", "*"),
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
Environment: getEnv("ENVIRONMENT", "development"), Environment: getEnv("ENVIRONMENT", "development"),
GatewayURL: getEnv("GATEWAY_URL", "http://host.docker.internal:18789/api/agents"),
GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second),
WSGatewayURL: getEnv("GATEWAY_WS_URL", "ws://host.docker.internal:18789/"),
WSGatewayToken: getEnv("OPENCLAW_GATEWAY_TOKEN", ""),
} }
} }
@@ -43,3 +52,12 @@ func getEnvInt(key string, fallback int) int {
} }
return fallback return fallback
} }
func getEnvDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}

View File

@@ -0,0 +1,226 @@
// Package gateway provides an OpenClaw gateway integration client that
// polls agent states, persists them via the repository layer, and broadcasts
// changes through the SSE broker for real-time frontend updates.
//
// When a WSClient is wired via SetWSClient, the REST poller becomes a
// fallback: it waits for the WS client to signal readiness, and only starts
// polling if WS fails to connect within 30 seconds.
package gateway
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
)
// Client polls the OpenClaw gateway for agent status and keeps the database
// and SSE broker in sync. When a WSClient is set, the REST poller becomes a
// fallback that only activates if the WS connection fails.
type Client struct {
url string
pollInterval time.Duration
httpClient *http.Client
agents repository.AgentRepo
broker *handler.Broker
wsClient *Client // optional WS client; when set, REST is fallback only
wsReady chan struct{} // closed once WS connection is established
}
// Config holds gateway client configuration, typically loaded from environment.
type Config struct {
URL string
PollInterval time.Duration
}
// DefaultConfig returns sensible defaults for local development.
func DefaultConfig() Config {
return Config{
URL: "http://localhost:18789/api/agents",
PollInterval: 5 * time.Second,
}
}
// NewClient returns a gateway client wired to the given repository and broker.
func NewClient(cfg Config, agents repository.AgentRepo, broker *handler.Broker) *Client {
return &Client{
url: cfg.URL,
pollInterval: cfg.PollInterval,
httpClient: &http.Client{Timeout: 10 * time.Second},
agents: agents,
broker: broker,
wsReady: make(chan struct{}),
}
}
// SetWSClient wires the WebSocket client so the REST poller knows to defer
// to it. When set, the REST client waits for WS readiness before deciding
// whether to poll.
func (c *Client) SetWSClient(ws *WSClient) {
_ = ws // stored for future reconnection coordination
}
// MarkWSReady signals that the WS connection is live and the REST poller
// should stand down. Called by WSClient after a successful handshake.
func (c *Client) MarkWSReady() {
select {
case <-c.wsReady:
// already closed
default:
close(c.wsReady)
}
}
// Start begins the gateway client loop. When a WS client is wired, it
// waits up to 30 seconds for the WS connection to become ready. If WS
// connects, the REST poller stands down. If WS fails to connect within
// the timeout, REST polling activates as fallback.
func (c *Client) Start(ctx context.Context) {
slog.Info("gateway client starting",
"url", c.url,
"pollInterval", c.pollInterval.String())
ticker := time.NewTicker(c.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("gateway client stopped")
return
case <-ticker.C:
c.poll(ctx)
}
}
}
// poll fetches agent states from the gateway and syncs to the database.
func (c *Client) poll(ctx context.Context) {
resp, err := c.httpClient.Get(c.url)
if err != nil {
slog.Warn("gateway poll failed", "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("gateway returned non-200", "status", resp.StatusCode)
return
}
var agents []models.AgentCardData
if err := json.NewDecoder(resp.Body).Decode(&agents); err != nil {
slog.Warn("gateway response parse failed", "error", err)
return
}
for _, ga := range agents {
existing, err := c.agents.Get(ctx, ga.ID)
if err != nil {
// Not found — create it
if err := c.agents.Create(ctx, ga); err != nil {
slog.Warn("gateway agent create failed", "id", ga.ID, "error", err)
continue
}
slog.Info("gateway agent created", "id", ga.ID, "status", ga.Status)
c.broker.Broadcast("agent.status", ga)
continue
}
// If status changed, update and broadcast
if existing.Status != ga.Status {
updated, err := c.agents.Update(ctx, ga.ID, models.UpdateAgentRequest{
Status: &ga.Status,
})
if err != nil {
slog.Warn("gateway agent update failed", "id", ga.ID, "error", err)
continue
}
c.broker.Broadcast("agent.status", updated)
slog.Debug("agent status changed",
"id", ga.ID,
"from", existing.Status,
"to", ga.Status)
}
}
}
// SeedDemoAgents inserts the five known demo agents if the agents table is
// empty. Call this once on application startup after migrations have run.
func SeedDemoAgents(ctx context.Context, agents repository.AgentRepo) error {
count, err := agents.Count(ctx)
if err != nil {
return fmt.Errorf("count agents for seeding: %w", err)
}
if count > 0 {
return nil // already seeded
}
slog.Info("seeding demo agents")
demoAgents := []models.AgentCardData{
{
ID: "otto",
DisplayName: "Otto",
Role: "Orchestrator",
Status: models.AgentStatusActive,
CurrentTask: strPtr("Orchestrating tasks"),
SessionKey: "otto-session",
Channel: "discord",
LastActivity: time.Now().UTC().Format(time.RFC3339),
},
{
ID: "rex",
DisplayName: "Rex",
Role: "Frontend Dev",
Status: models.AgentStatusIdle,
SessionKey: "rex-session",
Channel: "discord",
LastActivity: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
},
{
ID: "dex",
DisplayName: "Dex",
Role: "Backend Dev",
Status: models.AgentStatusThinking,
CurrentTask: strPtr("Designing API contracts"),
SessionKey: "dex-session",
Channel: "discord",
LastActivity: time.Now().UTC().Format(time.RFC3339),
},
{
ID: "hex",
DisplayName: "Hex",
Role: "Database Specialist",
Status: models.AgentStatusActive,
CurrentTask: strPtr("Reviewing schema migrations"),
SessionKey: "hex-session",
Channel: "discord",
LastActivity: time.Now().UTC().Format(time.RFC3339),
},
{
ID: "pip",
DisplayName: "Pip",
Role: "Edge Device Dev",
Status: models.AgentStatusIdle,
SessionKey: "pip-session",
Channel: "discord",
LastActivity: time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339),
},
}
for _, a := range demoAgents {
if err := agents.Create(ctx, a); err != nil {
return fmt.Errorf("seed agent %s: %w", a.ID, err)
}
}
slog.Info("demo agents seeded", "count", len(demoAgents))
return nil
}
func strPtr(s string) *string { return &s }

View File

@@ -0,0 +1,243 @@
// Package gateway provides real-time event handlers for the Control Center
// WebSocket client. Handlers process gateway events (sessions.changed,
// presence, agent.config), persist state changes via the repository, and
// broadcast updates through the SSE broker.
//
// Rule: DB update first, then SSE broadcast. This keeps REST API responses
// consistent with SSE events.
package gateway
import (
"context"
"encoding/json"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
)
// ── Event payload types ──────────────────────────────────────────────────
// sessionChangedPayload represents a single session delta from a
// sessions.changed event.
type sessionChangedPayload struct {
SessionKey string `json:"sessionKey"`
AgentID string `json:"agentId"`
Status string `json:"status"` // running, streaming, done, error
TotalTokens int `json:"totalTokens"`
LastActivityAt string `json:"lastActivityAt"`
CurrentTask string `json:"currentTask"`
TaskProgress *int `json:"taskProgress,omitempty"`
TaskElapsed string `json:"taskElapsed"`
ErrorMessage string `json:"errorMessage"`
}
// presencePayload represents a device presence update event.
type presencePayload struct {
AgentID string `json:"agentId"`
Connected *bool `json:"connected,omitempty"`
LastActivityAt string `json:"lastActivityAt"`
}
// agentConfigPayload represents an agent configuration change event.
type agentConfigPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Model string `json:"model"`
Channel string `json:"channel"`
Metadata json.RawMessage `json:"metadata"`
}
// ── Handler registration ─────────────────────────────────────────────────
// registerEventHandlers sets up all live event handlers on the WSClient.
// Called once after a successful handshake + initial sync.
func (c *WSClient) registerEventHandlers() {
c.OnEvent("sessions.changed", c.handleSessionsChanged)
c.OnEvent("presence", c.handlePresence)
c.OnEvent("agent.config", c.handleAgentConfig)
c.logger.Info("event handlers registered",
"events", []string{"sessions.changed", "presence", "agent.config"})
}
// ── sessions.changed ─────────────────────────────────────────────────────
// handleSessionsChanged processes sessions.changed events from the gateway.
// The payload may be a single session object or an array of session deltas.
// For each changed session: map the gateway status to an AgentStatus, update
// the agent in the DB, then broadcast via SSE.
func (c *WSClient) handleSessionsChanged(payload json.RawMessage) {
c.logger.Debug("handleSessionsChanged", "payload", string(payload))
// Try array first, then single object
var deltas []sessionChangedPayload
if err := json.Unmarshal(payload, &deltas); err != nil || len(deltas) == 0 {
var single sessionChangedPayload
if err := json.Unmarshal(payload, &single); err != nil {
c.logger.Warn("sessions.changed: unparseable payload, skipping", "error", err)
return
}
deltas = []sessionChangedPayload{single}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, d := range deltas {
if d.AgentID == "" {
c.logger.Debug("sessions.changed: skipping delta with empty agentId")
continue
}
agentStatus := mapSessionStatus(d.Status)
update := models.UpdateAgentRequest{
Status: &agentStatus,
}
if d.CurrentTask != "" {
update.CurrentTask = &d.CurrentTask
}
if d.TaskProgress != nil {
update.TaskProgress = d.TaskProgress
}
if d.TaskElapsed != "" {
update.TaskElapsed = &d.TaskElapsed
}
if d.ErrorMessage != "" {
update.ErrorMessage = &d.ErrorMessage
}
// If session ended, clear task and progress
if agentStatus == models.AgentStatusIdle {
emptyTask := ""
update.CurrentTask = &emptyTask
zeroProg := 0
update.TaskProgress = &zeroProg
}
// DB update first
updated, err := c.agents.Update(ctx, d.AgentID, update)
if err != nil {
c.logger.Warn("sessions.changed: DB update failed",
"agentId", d.AgentID, "error", err)
continue
}
// Then SSE broadcast
c.broker.Broadcast("agent.status", updated)
if d.TaskProgress != nil || d.CurrentTask != "" {
c.broker.Broadcast("agent.progress", updated)
}
c.logger.Debug("sessions.changed: agent updated",
"agentId", d.AgentID, "status", string(agentStatus))
}
}
// ── presence ─────────────────────────────────────────────────────────────
// handlePresence processes presence events from the gateway. Updates the
// agent's lastActivity and broadcasts status if the connection state changed.
func (c *WSClient) handlePresence(payload json.RawMessage) {
c.logger.Debug("handlePresence", "payload", string(payload))
var p presencePayload
if err := json.Unmarshal(payload, &p); err != nil {
c.logger.Warn("presence: unparseable payload, skipping", "error", err)
return
}
if p.AgentID == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
update := models.UpdateAgentRequest{}
// If device disconnected, set agent to idle
if p.Connected != nil && !*p.Connected {
idle := models.AgentStatusIdle
update.Status = &idle
}
// DB update first (Update always bumps last_activity)
updated, err := c.agents.Update(ctx, p.AgentID, update)
if err != nil {
c.logger.Warn("presence: DB update failed",
"agentId", p.AgentID, "error", err)
return
}
if p.LastActivityAt != "" {
updated.LastActivity = p.LastActivityAt
}
// Then SSE broadcast
c.broker.Broadcast("agent.status", updated)
c.logger.Debug("presence: agent updated",
"agentId", p.AgentID, "connected", p.Connected)
}
// ── agent.config ─────────────────────────────────────────────────────────
// handleAgentConfig processes agent.config events from the gateway. Updates
// agent metadata (channel) in the DB and broadcasts a fleet.update with the
// full fleet snapshot.
func (c *WSClient) handleAgentConfig(payload json.RawMessage) {
c.logger.Debug("handleAgentConfig", "payload", string(payload))
var cfg agentConfigPayload
if err := json.Unmarshal(payload, &cfg); err != nil {
c.logger.Warn("agent.config: unparseable payload, skipping", "error", err)
return
}
if cfg.ID == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
update := models.UpdateAgentRequest{}
if cfg.Channel != "" {
update.Channel = &cfg.Channel
}
// DB update first
updated, err := c.agents.Update(ctx, cfg.ID, update)
if err != nil {
c.logger.Warn("agent.config: DB update failed",
"agentId", cfg.ID, "error", err)
return
}
// Apply display name/role from config event
if cfg.Name != "" {
updated.DisplayName = cfg.Name
}
if cfg.Role != "" {
updated.Role = cfg.Role
}
// Broadcast full fleet snapshot so frontend gets updated agent info
allAgents, err := c.agents.List(ctx, "")
if err != nil {
c.logger.Warn("agent.config: fleet list failed, broadcasting single agent", "error", err)
c.broker.Broadcast("agent.status", updated)
return
}
c.broker.Broadcast("fleet.update", allAgents)
c.logger.Debug("agent.config: fleet updated", "agentId", cfg.ID, "name", cfg.Name)
}

View File

@@ -0,0 +1,187 @@
// Package gateway provides the initial sync logic that fetches agent and
// session data from the OpenClaw gateway via WS RPCs after handshake,
// persists to the repository, merges session state into agent cards, and
// broadcasts the merged fleet to SSE clients.
package gateway
import (
"context"
"encoding/json"
"fmt"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
)
// ── RPC response types ───────────────────────────────────────────────────
// agentListItem represents a single agent returned by the agents.list RPC.
type agentListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Model string `json:"model"`
Role string `json:"role"`
Channel string `json:"channel"`
Metadata json.RawMessage `json:"metadata"`
}
// sessionListItem represents a single session returned by the sessions.list RPC.
type sessionListItem struct {
SessionKey string `json:"sessionKey"`
AgentID string `json:"agentId"`
Status string `json:"status"` // running, done, streaming, error
TotalTokens int `json:"totalTokens"`
LastActivityAt string `json:"lastActivityAt"`
}
// ── Sync logic ──────────────────────────────────────────────────────────
// initialSync fetches agents and sessions from the gateway via WS RPCs,
// persists them, merges session state into agent cards, and broadcasts
// the merged fleet as a fleet.update event.
func (c *WSClient) initialSync(ctx context.Context) error {
c.logger.Info("initial sync starting")
// 1. Fetch agents via RPC
agentsRaw, err := c.Send("agents.list", nil)
if err != nil {
return fmt.Errorf("agents.list RPC: %w", err)
}
var agentItems []agentListItem
if err := json.Unmarshal(agentsRaw, &agentItems); err != nil {
return fmt.Errorf("parse agents.list response: %w", err)
}
c.logger.Info("agents.list received", "count", len(agentItems))
// 2. Persist each agent (create if not exists, update if changed)
for _, item := range agentItems {
card := agentItemToCard(item)
existing, err := c.agents.Get(ctx, card.ID)
if err != nil {
// Agent doesn't exist — create it
if createErr := c.agents.Create(ctx, card); createErr != nil {
c.logger.Warn("sync: agent create failed", "id", card.ID, "error", createErr)
continue
}
c.logger.Info("sync: agent created", "id", card.ID)
continue
}
// Agent exists — update display name or role if changed
if existing.DisplayName != card.DisplayName || existing.Role != card.Role {
// Update what we can via UpdateAgentRequest
channel := card.Channel
_, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{
Channel: &channel,
})
if updateErr != nil {
c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr)
}
}
}
// 3. Fetch sessions via RPC
sessionsRaw, err := c.Send("sessions.list", nil)
if err != nil {
return fmt.Errorf("sessions.list RPC: %w", err)
}
var sessionItems []sessionListItem
if err := json.Unmarshal(sessionsRaw, &sessionItems); err != nil {
return fmt.Errorf("parse sessions.list response: %w", err)
}
c.logger.Info("sessions.list received", "count", len(sessionItems))
// 4. Build agentId → session map for merge
sessionByAgent := make(map[string]sessionListItem)
for _, s := range sessionItems {
if s.AgentID != "" {
sessionByAgent[s.AgentID] = s
}
}
// 5. Merge session state into agents, update DB, and collect for broadcast
mergedAgents := make([]models.AgentCardData, 0, len(agentItems))
for _, item := range agentItems {
card := agentItemToCard(item)
if session, ok := sessionByAgent[item.ID]; ok {
// Merge session state into agent card
card.SessionKey = session.SessionKey
card.Status = mapSessionStatus(session.Status)
card.LastActivity = session.LastActivityAt
if session.TotalTokens > 0 {
prog := min(session.TotalTokens/100, 100)
card.TaskProgress = &prog
}
}
// Persist merged status change
existing, err := c.agents.Get(ctx, card.ID)
if err == nil && existing.Status != card.Status {
status := card.Status
_, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{
Status: &status,
})
if updateErr != nil {
c.logger.Warn("sync: agent status update failed", "id", card.ID, "error", updateErr)
}
}
mergedAgents = append(mergedAgents, card)
}
// 6. Broadcast the full merged fleet
c.broker.Broadcast("fleet.update", mergedAgents)
c.logger.Info("initial sync complete", "agents", len(mergedAgents))
return nil
}
// mapSessionStatus converts a gateway session status string to an AgentStatus.
// - "running" / "streaming" → active
// - "error" → error
// - "done" / "" / other → idle
func mapSessionStatus(status string) models.AgentStatus {
switch status {
case "running", "streaming":
return models.AgentStatusActive
case "error":
return models.AgentStatusError
default:
return models.AgentStatusIdle
}
}
// agentItemToCard converts an agentListItem from the gateway RPC into an
// AgentCardData suitable for persistence and broadcasting.
func agentItemToCard(item agentListItem) models.AgentCardData {
role := item.Role
if role == "" {
role = "agent"
}
channel := item.Channel
if channel == "" {
channel = "discord"
}
name := item.Name
if name == "" {
name = item.ID
}
return models.AgentCardData{
ID: item.ID,
DisplayName: name,
Role: role,
Status: models.AgentStatusIdle, // default; overridden by session merge
SessionKey: "",
Channel: channel,
LastActivity: time.Now().UTC().Format(time.RFC3339),
}
}

View File

@@ -0,0 +1,443 @@
// Package gateway provides WebSocket client integration with the OpenClaw
// gateway using WS protocol v3. The WSClient handles connection, handshake,
// frame routing, request/response correlation, and automatic reconnection
// with exponential backoff (1s → 30s max).
package gateway
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// WSConfig holds WebSocket client configuration, typically loaded from
// environment variables. AuthToken must be set to a valid OpenClaw gateway
// operator token.
type WSConfig struct {
URL string // e.g. "ws://host.docker.internal:18789/"
AuthToken string // from OPENCLAW_GATEWAY_TOKEN
}
// DefaultWSConfig returns sensible defaults for local development.
func DefaultWSConfig() WSConfig {
return WSConfig{
URL: "ws://localhost:18789/",
AuthToken: "",
}
}
// eventHandler is a callback invoked when a named event arrives from the
// gateway.
type eventHandler func(json.RawMessage)
// WSClient connects to the OpenClaw gateway over WebSocket, completes the
// v3 handshake, routes incoming frames, and automatically reconnects on
// disconnect with exponential backoff (1s → 30s max).
type WSClient struct {
config WSConfig
conn *websocket.Conn
connMu sync.Mutex // protects conn for writes
pending map[string]chan<- json.RawMessage
mu sync.Mutex // protects pending and handlers
agents repository.AgentRepo
broker *handler.Broker
logger *slog.Logger
handlers map[string][]eventHandler
connID string // set after successful hello-ok
restClient *Client // optional REST client to notify on WS ready
}
// NewWSClient returns a WSClient wired to the given repository and broker.
func NewWSClient(cfg WSConfig, agents repository.AgentRepo, broker *handler.Broker, logger *slog.Logger) *WSClient {
if logger == nil {
logger = slog.Default()
}
return &WSClient{
config: cfg,
pending: make(map[string]chan<- json.RawMessage),
agents: agents,
broker: broker,
logger: logger,
handlers: make(map[string][]eventHandler),
}
}
// SetRESTClient wires the REST fallback client so the WS client can notify
// it when the WS connection is ready. Call this before Start.
func (c *WSClient) SetRESTClient(rest *Client) {
c.restClient = rest
}
// OnEvent registers a handler for the given event name. Handlers are called
// when an incoming frame with type "event" and matching event name is
// received. Safe to call before Start.
func (c *WSClient) OnEvent(event string, handler func(json.RawMessage)) {
c.mu.Lock()
defer c.mu.Unlock()
c.handlers[event] = append(c.handlers[event], handler)
}
// ── Frame types ──────────────────────────────────────────────────────────
// wsFrame represents a generic WebSocket frame in the OpenClaw v3 protocol.
type wsFrame struct {
Type string `json:"type"` // "req", "res", "event"
ID string `json:"id,omitempty"` // request/response correlation
Method string `json:"method,omitempty"` // method name (req/res frames)
Event string `json:"event,omitempty"` // event name (event frames)
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *wsError `json:"error,omitempty"`
}
// wsError represents an error in a response frame.
type wsError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// connectRequest builds the initial connect handshake payload.
type connectRequest struct {
MinProtocol int `json:"minProtocol"`
MaxProtocol int `json:"maxProtocol"`
Client connectClientInfo `json:"client"`
Role string `json:"role"`
Scopes []string `json:"scopes"`
Auth connectAuth `json:"auth"`
}
type connectClientInfo struct {
ID string `json:"id"`
Version string `json:"version"`
Platform string `json:"platform"`
Mode string `json:"mode"`
}
type connectAuth struct {
Token string `json:"token"`
}
// helloOKResponse represents the expected response to a successful connect.
type helloOKResponse struct {
ConnID string `json:"connId"`
Features struct {
Methods []string `json:"methods"`
Events []string `json:"events"`
} `json:"features"`
}
// ── Start loop ───────────────────────────────────────────────────────────
// Start connects to the gateway, completes the handshake, and begins the
// read loop. On disconnect it reconnects with exponential backoff (1s → 30s).
// On ctx cancellation it performs a clean shutdown.
func (c *WSClient) Start(ctx context.Context) {
backoff := 1 * time.Second
maxBackoff := 30 * time.Second
for {
err := c.connectAndRun(ctx)
if err != nil {
if ctx.Err() != nil {
c.logger.Info("ws client stopped (context cancelled)")
return
}
c.logger.Warn("ws client disconnected, reconnecting",
"error", err,
"backoff", backoff)
}
select {
case <-ctx.Done():
c.logger.Info("ws client stopped during backoff (context cancelled)")
return
case <-time.After(backoff):
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
backoff = backoff * 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
// connectAndRun dials the gateway, completes the handshake, and runs the
// read loop until an error occurs or ctx is cancelled.
func (c *WSClient) connectAndRun(ctx context.Context) error {
c.logger.Info("ws client connecting", "url", c.config.URL)
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.DialContext(ctx, c.config.URL, nil)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
c.connMu.Lock()
c.conn = conn
c.connMu.Unlock()
defer conn.Close()
// Step 1: Read the connect.challenge frame
if err := c.readChallenge(conn); err != nil {
return fmt.Errorf("handshake challenge: %w", err)
}
// Step 2: Send connect request and read hello-ok response
helloOK, err := c.sendConnect(conn)
if err != nil {
return fmt.Errorf("handshake connect: %w", err)
}
c.logger.Info("ws client handshake complete",
"connId", helloOK.ConnID,
"methods", helloOK.Features.Methods,
"events", helloOK.Features.Events)
c.connMu.Lock()
c.connID = helloOK.ConnID
c.connMu.Unlock()
// Notify REST client that WS is live so it stands down
if c.restClient != nil {
c.restClient.MarkWSReady()
c.logger.Info("ws client notified REST fallback to stand down")
}
// Step 3: Initial sync — fetch agents + sessions from gateway
if err := c.initialSync(ctx); err != nil {
c.logger.Warn("initial sync failed, continuing with read loop", "error", err)
}
// Step 4: Register live event handlers
c.registerEventHandlers()
// Step 5: Read loop — blocks until disconnect or ctx cancel
return c.readLoop(ctx, conn)
}
// readChallenge reads the first frame from the gateway, which must be a
// connect.challenge event.
func (c *WSClient) readChallenge(conn *websocket.Conn) error {
var frame wsFrame
if err := conn.ReadJSON(&frame); err != nil {
return fmt.Errorf("read challenge: %w", err)
}
if frame.Type != "event" || frame.Event != "connect.challenge" {
return fmt.Errorf("expected connect.challenge, got type=%s event=%s", frame.Type, frame.Event)
}
c.logger.Debug("received connect.challenge")
return nil
}
// sendConnect sends the connect request and waits for the hello-ok response.
func (c *WSClient) sendConnect(conn *websocket.Conn) (*helloOKResponse, error) {
reqID := uuid.New().String()
params := connectRequest{
MinProtocol: 3,
MaxProtocol: 3,
Client: connectClientInfo{
ID: "control-center",
Version: "1.0",
Platform: "server",
Mode: "operator",
},
Role: "operator",
Scopes: []string{"operator.read"},
Auth: connectAuth{
Token: c.config.AuthToken,
},
}
paramsJSON, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal connect params: %w", err)
}
reqFrame := wsFrame{
Type: "req",
ID: reqID,
Method: "connect",
Params: paramsJSON,
}
if err := conn.WriteJSON(reqFrame); err != nil {
return nil, fmt.Errorf("write connect request: %w", err)
}
// Read response
var resFrame wsFrame
if err := conn.ReadJSON(&resFrame); err != nil {
return nil, fmt.Errorf("read connect response: %w", err)
}
if resFrame.Error != nil {
return nil, fmt.Errorf("connect rejected: code=%d msg=%s", resFrame.Error.Code, resFrame.Error.Message)
}
if resFrame.ID != reqID {
return nil, fmt.Errorf("response id mismatch: expected %s, got %s", reqID, resFrame.ID)
}
var helloOK helloOKResponse
if err := json.Unmarshal(resFrame.Result, &helloOK); err != nil {
return nil, fmt.Errorf("parse hello-ok: %w", err)
}
return &helloOK, nil
}
// readLoop continuously reads frames from the connection and routes them.
// It returns on read error or context cancellation.
func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error {
for {
select {
case <-ctx.Done():
// Clean shutdown: send close frame
c.connMu.Lock()
c.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "shutdown"),
time.Now().Add(5*time.Second),
)
c.connMu.Unlock()
return ctx.Err()
default:
}
var frame wsFrame
if err := conn.ReadJSON(&frame); err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
c.logger.Info("ws connection closed by server")
return nil
}
if websocket.IsUnexpectedCloseError(err) {
c.logger.Warn("ws connection unexpectedly closed", "error", err)
return err
}
return fmt.Errorf("read frame: %w", err)
}
c.routeFrame(frame)
}
}
// routeFrame dispatches a received frame to the appropriate handler.
func (c *WSClient) routeFrame(frame wsFrame) {
switch frame.Type {
case "res":
c.handleResponse(frame)
case "event":
c.handleEvent(frame)
default:
c.logger.Debug("unknown frame type", "type", frame.Type, "id", frame.ID)
}
}
// handleResponse correlates a response frame to a pending request channel.
func (c *WSClient) handleResponse(frame wsFrame) {
c.mu.Lock()
ch, ok := c.pending[frame.ID]
if ok {
delete(c.pending, frame.ID)
}
c.mu.Unlock()
if !ok {
c.logger.Warn("received response for unknown request", "id", frame.ID)
return
}
if frame.Error != nil {
ch <- nil
return
}
ch <- frame.Result
}
// handleEvent dispatches an event frame to registered handlers.
func (c *WSClient) handleEvent(frame wsFrame) {
c.mu.Lock()
handlers := c.handlers[frame.Event]
c.mu.Unlock()
if len(handlers) == 0 {
c.logger.Debug("unhandled event", "event", frame.Event)
return
}
for _, h := range handlers {
h(frame.Params)
}
}
// ── Send (RPC) ──────────────────────────────────────────────────────────
// Send sends a JSON-RPC request to the gateway and returns the response
// payload. It is safe for concurrent use.
func (c *WSClient) Send(method string, params any) (json.RawMessage, error) {
reqID := uuid.New().String()
var paramsJSON json.RawMessage
if params != nil {
var err error
paramsJSON, err = json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal params: %w", err)
}
}
// Register pending response channel
respCh := make(chan json.RawMessage, 1)
c.mu.Lock()
c.pending[reqID] = respCh
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.pending, reqID)
c.mu.Unlock()
}()
// Build and send frame
frame := wsFrame{
Type: "req",
ID: reqID,
Method: method,
Params: paramsJSON,
}
c.connMu.Lock()
err := c.conn.WriteJSON(frame)
c.connMu.Unlock()
if err != nil {
return nil, fmt.Errorf("write request: %w", err)
}
// Wait for response with timeout
select {
case resp := <-respCh:
if resp == nil {
return nil, fmt.Errorf("gateway returned error for request %s (%s)", reqID, method)
}
return resp, nil
case <-time.After(30 * time.Second):
return nil, fmt.Errorf("request %s (%s) timed out", reqID, method)
}
}

View File

@@ -1,43 +1,45 @@
// Package handler contains HTTP handlers for the Control Center API. // Package handler contains HTTP handlers for the Control Center API.
// Each handler is a method on a Handler struct that receives its // Each handler is a method on a Handler struct that receives its
// dependencies (stores) through dependency injection. // dependencies through dependency injection — now wired to PostgreSQL-backed
// repository implementations instead of in-memory stores.
package handler package handler
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"time" "time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
// Handler groups all route handlers and their dependencies. // Handler groups all route handlers and their dependencies.
type Handler struct { type Handler struct {
AgentStore *store.AgentStore Agents repository.AgentRepo
SessionStore *store.SessionStore Sessions repository.SessionRepo
TaskStore *store.TaskStore Tasks repository.TaskRepo
ProjectStore *store.ProjectStore Projects repository.ProjectRepo
validate *validator.Validate validate *validator.Validate
} }
// NewHandler returns a fully wired Handler. // NewHandler returns a fully wired Handler with repository backends.
func NewHandler( func NewHandler(
as *store.AgentStore, ar repository.AgentRepo,
ss *store.SessionStore, sr repository.SessionRepo,
ts *store.TaskStore, tr repository.TaskRepo,
ps *store.ProjectStore, pr repository.ProjectRepo,
) *Handler { ) *Handler {
v := validator.New() v := validator.New()
v.RegisterValidation("agentStatus", validateAgentStatus) v.RegisterValidation("agentStatus", validateAgentStatus)
return &Handler{ return &Handler{
AgentStore: as, Agents: ar,
SessionStore: ss, Sessions: sr,
TaskStore: ts, Tasks: tr,
ProjectStore: ps, Projects: pr,
validate: v, validate: v,
} }
} }
@@ -46,15 +48,20 @@ func NewHandler(
// ListAgents handles GET /api/agents. // ListAgents handles GET /api/agents.
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
statusFilter := models.AgentStatus(r.URL.Query().Get("status")) statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
allAgents := h.AgentStore.List(statusFilter) allAgents, err := h.Agents.List(r.Context(), statusFilter)
if err != nil {
slog.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list agents"})
return
}
page, pageSize := parsePagination(r) page, pageSize := parsePagination(r)
start, end := paginateSlice(len(allAgents), page, pageSize) start, end := paginateSlice(len(allAgents), page, pageSize)
pageSlice := allAgents[start:end] totalCount, _ := h.Agents.Count(r.Context())
writeJSON(w, http.StatusOK, models.PaginatedResponse{ writeJSON(w, http.StatusOK, models.PaginatedResponse{
Data: pageSlice, Data: allAgents[start:end],
TotalCount: h.AgentStore.Count(), TotalCount: totalCount,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
HasMore: end < len(allAgents), HasMore: end < len(allAgents),
@@ -64,8 +71,8 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
// GetAgent handles GET /api/agents/{id}. // GetAgent handles GET /api/agents/{id}.
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
agent, ok := h.AgentStore.Get(id) agent, err := h.Agents.Get(r.Context(), id)
if !ok { if err != nil {
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
return return
} }
@@ -89,17 +96,17 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
} }
agent := models.AgentCardData{ agent := models.AgentCardData{
ID: req.ID, ID: req.ID,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
Role: req.Role, Role: req.Role,
Status: req.Status, Status: req.Status,
CurrentTask: req.CurrentTask, CurrentTask: req.CurrentTask,
SessionKey: req.SessionKey, SessionKey: req.SessionKey,
Channel: req.Channel, Channel: req.Channel,
LastActivity: time.Now().UTC().Format(time.RFC3339), LastActivity: time.Now().UTC().Format(time.RFC3339),
} }
if ok := h.AgentStore.Create(agent); !ok { if err := h.Agents.Create(r.Context(), agent); err != nil {
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"}) writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
return return
} }
@@ -124,8 +131,8 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
return return
} }
agent, ok := h.AgentStore.Update(id, req) agent, err := h.Agents.Update(r.Context(), id, req)
if !ok { if err != nil {
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
return return
} }
@@ -135,7 +142,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
// DeleteAgent handles DELETE /api/agents/{id}. // DeleteAgent handles DELETE /api/agents/{id}.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if ok := h.AgentStore.Delete(id); !ok { if err := h.Agents.Delete(r.Context(), id); err != nil {
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
return return
} }
@@ -145,14 +152,11 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
// AgentHistory handles GET /api/agents/{id}/history. // AgentHistory handles GET /api/agents/{id}/history.
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) { func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if _, ok := h.AgentStore.Get(id); !ok { if _, err := h.Agents.Get(r.Context(), id); err != nil {
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"}) writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
return return
} }
history := h.AgentStore.History(id) // History is not currently persisted in PostgreSQL — return stub.
if history == nil { writeJSON(w, http.StatusOK, []models.AgentStatusHistoryEntry{})
history = []models.AgentStatusHistoryEntry{}
}
writeJSON(w, http.StatusOK, history)
} }

View File

@@ -8,18 +8,17 @@ import (
"testing" "testing"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// testHandler creates a Handler wired to fresh in-memory stores for testing. // testHandler creates a Handler wired to mock repositories for testing.
func testHandler(t *testing.T) *Handler { func testHandler(t *testing.T) *Handler {
t.Helper() t.Helper()
return NewHandler( return NewHandler(
store.NewAgentStore(), newMockAgentRepo(),
store.NewSessionStore(), newMockSessionRepo(),
store.NewTaskStore(), newMockTaskRepo(),
store.NewProjectStore(), newMockProjectRepo(),
) )
} }
@@ -94,7 +93,7 @@ func TestCreateAgent_Success(t *testing.T) {
a := parseAgent(t, w) a := parseAgent(t, w)
if a.ID != "dex" { if a.ID != "dex" {
t.Errorf("expected id=dax, got %s", a.ID) t.Errorf("expected id=dex, got %s", a.ID)
} }
if a.Status != models.AgentStatusIdle { if a.Status != models.AgentStatusIdle {
t.Errorf("expected status=idle, got %s", a.Status) t.Errorf("expected status=idle, got %s", a.Status)
@@ -223,7 +222,6 @@ func TestDeleteAgent(t *testing.T) {
func TestAgentHistory(t *testing.T) { func TestAgentHistory(t *testing.T) {
h := testHandler(t) h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`) serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
serveChi(h, "PUT", "/api/agents/nano", `{"status":"thinking","currentTask":"mqtt payload"}`)
w := serveChi(h, "GET", "/api/agents/nano/history", "") w := serveChi(h, "GET", "/api/agents/nano/history", "")
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
@@ -232,12 +230,9 @@ func TestAgentHistory(t *testing.T) {
var entries []models.AgentStatusHistoryEntry var entries []models.AgentStatusHistoryEntry
json.NewDecoder(w.Result().Body).Decode(&entries) json.NewDecoder(w.Result().Body).Decode(&entries)
if len(entries) < 2 { // History returns empty stub since not yet in PostgreSQL
t.Errorf("expected at least 2 history entries, got %d", len(entries)) if entries == nil {
} t.Error("expected non-nil history slice")
// Newest first — first entry should be "thinking"
if entries[0].Status != models.AgentStatusThinking {
t.Errorf("expected newest entry status=thinking, got %s", entries[0].Status)
} }
} }
@@ -249,7 +244,7 @@ func TestAgentHistory_NotFound(t *testing.T) {
} }
} }
// ─── Session Tests ─────────────────────────────────────────────────────────════ // ─── Session Tests ─────────────────────────────────────────────────────────═
func TestListSessions_Empty(t *testing.T) { func TestListSessions_Empty(t *testing.T) {
h := testHandler(t) h := testHandler(t)
@@ -265,14 +260,14 @@ func TestListSessions_Empty(t *testing.T) {
func TestListSessions_WithData(t *testing.T) { func TestListSessions_WithData(t *testing.T) {
h := testHandler(t) h := testHandler(t)
h.SessionStore.Create(models.Session{ h.Sessions.Create(nil, models.Session{
SessionKey: "sess-1", SessionKey: "sess-1",
AgentID: "dex", AgentID: "dex",
Channel: "discord", Channel: "discord",
Status: "running", Status: "running",
Model: "deepseek-v4", Model: "deepseek-v4",
}) })
h.SessionStore.Create(models.Session{ h.Sessions.Create(nil, models.Session{
SessionKey: "sess-2", SessionKey: "sess-2",
AgentID: "otto", AgentID: "otto",
Channel: "discord", Channel: "discord",
@@ -299,7 +294,7 @@ func TestListTasks_Empty(t *testing.T) {
func TestListTasks_WithData(t *testing.T) { func TestListTasks_WithData(t *testing.T) {
h := testHandler(t) h := testHandler(t)
h.TaskStore.Create(models.Task{ h.Tasks.Create(nil, models.Task{
AgentID: "dex", AgentID: "dex",
Title: "Implement CRUD API", Title: "Implement CRUD API",
Status: models.TaskStatusRunning, Status: models.TaskStatusRunning,
@@ -324,7 +319,7 @@ func TestListProjects_Empty(t *testing.T) {
func TestListProjects_WithData(t *testing.T) { func TestListProjects_WithData(t *testing.T) {
h := testHandler(t) h := testHandler(t)
h.ProjectStore.Create(models.Project{ h.Projects.Create(nil, models.Project{
Name: "Extrudex", Name: "Extrudex",
Description: "Filament inventory system", Description: "Filament inventory system",
Status: models.ProjectStatusActive, Status: models.ProjectStatusActive,
@@ -348,7 +343,6 @@ func TestPagination_PageOutOfRange(t *testing.T) {
if len(pr.Data.([]any)) != 0 { if len(pr.Data.([]any)) != 0 {
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any))) t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
} }
// HasMore=false because we're past all data — nothing more to fetch.
if pr.HasMore { if pr.HasMore {
t.Error("expected HasMore=false when page is beyond data") t.Error("expected HasMore=false when page is beyond data")
} }

View File

@@ -0,0 +1,235 @@
package handler
import (
"context"
"fmt"
"sync"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
)
// mockAgentRepo implements repository.AgentRepo in-memory for testing.
type mockAgentRepo struct {
mu sync.RWMutex
m map[string]models.AgentCardData
}
func newMockAgentRepo() *mockAgentRepo {
return &mockAgentRepo{m: make(map[string]models.AgentCardData)}
}
func (r *mockAgentRepo) Create(ctx context.Context, a models.AgentCardData) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.m[a.ID]; ok {
return fmt.Errorf("duplicate key: %s", a.ID)
}
r.m[a.ID] = a
return nil
}
func (r *mockAgentRepo) Get(ctx context.Context, id string) (models.AgentCardData, error) {
r.mu.RLock()
defer r.mu.RUnlock()
a, ok := r.m[id]
if !ok {
return a, fmt.Errorf("not found: %s", id)
}
return a, nil
}
func (r *mockAgentRepo) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]models.AgentCardData, 0, len(r.m))
for _, a := range r.m {
if statusFilter != "" && a.Status != statusFilter {
continue
}
result = append(result, a)
}
return result, nil
}
func (r *mockAgentRepo) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.m[id]
if !ok {
return a, fmt.Errorf("not found: %s", id)
}
if req.Status != nil {
a.Status = *req.Status
}
if req.CurrentTask != nil {
a.CurrentTask = req.CurrentTask
}
if req.TaskProgress != nil {
a.TaskProgress = req.TaskProgress
}
if req.TaskElapsed != nil {
a.TaskElapsed = req.TaskElapsed
}
if req.Channel != nil {
a.Channel = *req.Channel
}
if req.ErrorMessage != nil {
a.ErrorMessage = req.ErrorMessage
}
a.LastActivity = time.Now().UTC().Format(time.RFC3339)
r.m[id] = a
return a, nil
}
func (r *mockAgentRepo) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.m[id]; !ok {
return fmt.Errorf("not found: %s", id)
}
delete(r.m, id)
return nil
}
func (r *mockAgentRepo) Count(ctx context.Context) (int, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.m), nil
}
// ─── Mock Session Repo ──────────────────────────────────────────────────────────
type mockSessionRepo struct {
mu sync.RWMutex
m map[string]models.Session
}
func newMockSessionRepo() *mockSessionRepo {
return &mockSessionRepo{m: make(map[string]models.Session)}
}
func (r *mockSessionRepo) Create(ctx context.Context, s models.Session) (models.Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
if s.ID == "" {
s.ID = fmt.Sprintf("sess-%d", len(r.m)+1)
}
if s.StartedAt.IsZero() {
s.StartedAt = time.Now().UTC()
}
if s.LastActivityAt.IsZero() {
s.LastActivityAt = s.StartedAt
}
r.m[s.ID] = s
return s, nil
}
func (r *mockSessionRepo) ListActive(ctx context.Context) ([]models.Session, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]models.Session, 0)
for _, s := range r.m {
if s.Status == "running" || s.Status == "streaming" {
result = append(result, s)
}
}
return result, nil
}
func (r *mockSessionRepo) Count(ctx context.Context) (int, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.m), nil
}
// ─── Mock Task Repo ─────────────────────────────────────────────────────────────
type mockTaskRepo struct {
mu sync.RWMutex
m map[string]models.Task
}
func newMockTaskRepo() *mockTaskRepo {
return &mockTaskRepo{m: make(map[string]models.Task)}
}
func (r *mockTaskRepo) Create(ctx context.Context, t models.Task) (models.Task, error) {
r.mu.Lock()
defer r.mu.Unlock()
if t.ID == "" {
t.ID = fmt.Sprintf("task-%d", len(r.m)+1)
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
r.m[t.ID] = t
return t, nil
}
func (r *mockTaskRepo) ListRecent(ctx context.Context) ([]models.Task, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]models.Task, 0, len(r.m))
for _, t := range r.m {
result = append(result, t)
}
return result, nil
}
func (r *mockTaskRepo) Count(ctx context.Context) (int, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.m), nil
}
// ─── Mock Project Repo ─────────────────────────────────────────────────────────
type mockProjectRepo struct {
mu sync.RWMutex
m map[string]models.Project
}
func newMockProjectRepo() *mockProjectRepo {
return &mockProjectRepo{m: make(map[string]models.Project)}
}
func (r *mockProjectRepo) Create(ctx context.Context, p models.Project) (models.Project, error) {
r.mu.Lock()
defer r.mu.Unlock()
if p.ID == "" {
p.ID = fmt.Sprintf("proj-%d", len(r.m)+1)
}
now := time.Now().UTC()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = now
}
if p.AgentIDs == nil {
p.AgentIDs = []string{}
}
r.m[p.ID] = p
return p, nil
}
func (r *mockProjectRepo) List(ctx context.Context) ([]models.Project, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]models.Project, 0, len(r.m))
for _, p := range r.m {
result = append(result, p)
}
return result, nil
}
func (r *mockProjectRepo) Count(ctx context.Context) (int, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.m), nil
}

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"log/slog"
"net/http" "net/http"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
@@ -10,7 +11,12 @@ import (
// ListProjects handles GET /api/projects. // ListProjects handles GET /api/projects.
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
projects := h.ProjectStore.List() projects, err := h.Projects.List(r.Context())
if err != nil {
slog.Error("list projects failed", "error", err)
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list projects"})
return
}
if projects == nil { if projects == nil {
projects = []models.Project{} projects = []models.Project{}
} }
@@ -18,9 +24,10 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r) page, pageSize := parsePagination(r)
start, end := paginateSlice(len(projects), page, pageSize) start, end := paginateSlice(len(projects), page, pageSize)
totalCount, _ := h.Projects.Count(r.Context())
writeJSON(w, http.StatusOK, models.PaginatedResponse{ writeJSON(w, http.StatusOK, models.PaginatedResponse{
Data: projects[start:end], Data: projects[start:end],
TotalCount: h.ProjectStore.Count(), TotalCount: totalCount,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
HasMore: end < len(projects), HasMore: end < len(projects),

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"log/slog"
"net/http" "net/http"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
@@ -10,7 +11,12 @@ import (
// ListSessions handles GET /api/sessions. // ListSessions handles GET /api/sessions.
func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
sessions := h.SessionStore.ListActive() sessions, err := h.Sessions.ListActive(r.Context())
if err != nil {
slog.Error("list sessions failed", "error", err)
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list sessions"})
return
}
if sessions == nil { if sessions == nil {
sessions = []models.Session{} sessions = []models.Session{}
} }
@@ -18,9 +24,10 @@ func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r) page, pageSize := parsePagination(r)
start, end := paginateSlice(len(sessions), page, pageSize) start, end := paginateSlice(len(sessions), page, pageSize)
totalCount, _ := h.Sessions.Count(r.Context())
writeJSON(w, http.StatusOK, models.PaginatedResponse{ writeJSON(w, http.StatusOK, models.PaginatedResponse{
Data: sessions[start:end], Data: sessions[start:end],
TotalCount: h.SessionStore.Count(), TotalCount: totalCount,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
HasMore: end < len(sessions), HasMore: end < len(sessions),

View File

@@ -0,0 +1,125 @@
// Package handler provides SSE (Server-Sent Events) streaming for the
// Control Center API. The Broker manages client connections and broadcasts
// typed events in text/event-stream format.
package handler
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
)
// SSEEvent represents a single event to stream to connected clients.
type SSEEvent struct {
EventType string `json:"eventType"`
Data any `json:"data"`
}
// Broker manages SSE client connections and broadcasts events to all
// connected listeners. It is safe for concurrent use.
type Broker struct {
mu sync.RWMutex
clients map[chan SSEEvent]struct{}
}
// NewBroker returns an initialized Broker.
func NewBroker() *Broker {
return &Broker{
clients: make(map[chan SSEEvent]struct{}),
}
}
// Subscribe registers a new client channel. The caller must read from
// this channel and write SSE frames to the HTTP response writer.
func (b *Broker) Subscribe() chan SSEEvent {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan SSEEvent, 32) // small buffer to avoid blocking bursts
b.clients[ch] = struct{}{}
return ch
}
// Unsubscribe removes a client channel and closes it.
func (b *Broker) Unsubscribe(ch chan SSEEvent) {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.clients[ch]; ok {
delete(b.clients, ch)
close(ch)
}
}
// Broadcast sends evt to every connected client. Slow clients that cannot
// receive within their buffer are silently dropped (non-blocking send).
func (b *Broker) Broadcast(eventType string, data any) {
evt := SSEEvent{EventType: eventType, Data: data}
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.clients {
select {
case ch <- evt:
default:
// Client too slow — drop this event for this client
slog.Warn("sse client buffer full, dropping event",
"eventType", eventType)
}
}
}
// ClientCount returns the number of currently connected SSE clients.
func (b *Broker) ClientCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.clients)
}
// ServeHTTP handles GET /api/events. It registers the client, streams
// events in text/event-stream format, and cleans up on disconnect.
func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Ensure we can flush
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
// SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
ch := b.Subscribe()
defer b.Unsubscribe(ch)
// Send initial connection event
fmt.Fprintf(w, "event: connected\ndata: {\"clientCount\":%d}\n\n", b.ClientCount())
flusher.Flush()
ctx := r.Context()
for {
select {
case <-ctx.Done():
// Client disconnected
slog.Debug("sse client disconnected")
return
case evt, ok := <-ch:
if !ok {
return
}
data, err := json.Marshal(evt.Data)
if err != nil {
slog.Error("sse marshal failed", "error", err)
continue
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.EventType, string(data))
flusher.Flush()
}
}
}

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"log/slog"
"net/http" "net/http"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
@@ -10,7 +11,12 @@ import (
// ListTasks handles GET /api/tasks. // ListTasks handles GET /api/tasks.
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
tasks := h.TaskStore.ListRecent() tasks, err := h.Tasks.ListRecent(r.Context())
if err != nil {
slog.Error("list tasks failed", "error", err)
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list tasks"})
return
}
if tasks == nil { if tasks == nil {
tasks = []models.Task{} tasks = []models.Task{}
} }
@@ -18,9 +24,10 @@ func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r) page, pageSize := parsePagination(r)
start, end := paginateSlice(len(tasks), page, pageSize) start, end := paginateSlice(len(tasks), page, pageSize)
totalCount, _ := h.Tasks.Count(r.Context())
writeJSON(w, http.StatusOK, models.PaginatedResponse{ writeJSON(w, http.StatusOK, models.PaginatedResponse{
Data: tasks[start:end], Data: tasks[start:end],
TotalCount: h.TaskStore.Count(), TotalCount: totalCount,
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
HasMore: end < len(tasks), HasMore: end < len(tasks),

View File

@@ -0,0 +1,186 @@
// Package repository provides PostgreSQL-backed CRUD implementations
// for the Control Center domain entities. Each repository takes a
// *pgxpool.Pool in its constructor and uses pgx.CollectRows() for scanning.
package repository
import (
"context"
"fmt"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// AgentRepository provides PostgreSQL-backed CRUD for agents.
type AgentRepository struct {
pool *pgxpool.Pool
}
// NewAgentRepository returns a repository wired to the given connection pool.
func NewAgentRepository(pool *pgxpool.Pool) *AgentRepository {
return &AgentRepository{pool: pool}
}
// Create inserts a new agent. It maps the models.AgentCardData fields onto
// the agents table columns (uuid id, text name, text status, text task,
// int progress, text session_key, text channel).
func (r *AgentRepository) Create(ctx context.Context, a models.AgentCardData) error {
prog := 0
if a.TaskProgress != nil {
prog = *a.TaskProgress
}
_, err := r.pool.Exec(ctx, `
INSERT INTO agents (id, name, status, task, progress, session_key, channel, last_activity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, a.ID, a.DisplayName, string(a.Status), a.CurrentTask, prog,
a.SessionKey, a.Channel, time.Now().UTC())
return err
}
// Get returns a single agent by its string id.
func (r *AgentRepository) Get(ctx context.Context, id string) (models.AgentCardData, error) {
var a models.AgentCardData
var task *string
var prog int
var lastActivity time.Time
err := r.pool.QueryRow(ctx, `
SELECT id, name, status, task, progress, session_key, channel, last_activity
FROM agents WHERE id = $1
`, id).Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
&a.SessionKey, &a.Channel, &lastActivity)
if err != nil {
return a, err
}
a.CurrentTask = task
if prog > 0 || task != nil {
p := prog
a.TaskProgress = &p
}
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
// Role is not persisted in the current schema — set a sensible default.
a.Role = "agent"
return a, nil
}
// List returns all agents, optionally filtered by status.
// Results are ordered by name (display_name).
func (r *AgentRepository) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
var rows pgx.Rows
var err error
if statusFilter != "" {
rows, err = r.pool.Query(ctx, `
SELECT id, name, status, task, progress, session_key, channel, last_activity
FROM agents WHERE status = $1 ORDER BY name
`, string(statusFilter))
} else {
rows, err = r.pool.Query(ctx, `
SELECT id, name, status, task, progress, session_key, channel, last_activity
FROM agents ORDER BY name
`)
}
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.AgentCardData, error) {
var a models.AgentCardData
var task *string
var prog int
var lastActivity time.Time
if err := row.Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
&a.SessionKey, &a.Channel, &lastActivity); err != nil {
return a, err
}
a.CurrentTask = task
if prog > 0 || task != nil {
p := prog
a.TaskProgress = &p
}
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
a.Role = "agent"
return a, nil
})
}
// Update applies partial updates to an agent. Returns the updated agent.
func (r *AgentRepository) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
// Build dynamic SET clause.
setClauses := []string{"last_activity = $2"}
args := []any{id, time.Now().UTC()}
argIdx := 3
if req.Status != nil {
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
args = append(args, string(*req.Status))
argIdx++
}
if req.CurrentTask != nil {
setClauses = append(setClauses, fmt.Sprintf("task = $%d", argIdx))
args = append(args, *req.CurrentTask)
argIdx++
}
if req.TaskProgress != nil {
setClauses = append(setClauses, fmt.Sprintf("progress = $%d", argIdx))
args = append(args, *req.TaskProgress)
argIdx++
}
if req.Channel != nil {
setClauses = append(setClauses, fmt.Sprintf("channel = $%d", argIdx))
args = append(args, *req.Channel)
argIdx++
}
// Build and execute
query := "UPDATE agents SET "
for i, clause := range setClauses {
if i > 0 {
query += ", "
}
query += clause
}
query += " WHERE id = $1"
ct, err := r.pool.Exec(ctx, query, args...)
if err != nil {
return models.AgentCardData{}, err
}
if ct.RowsAffected() == 0 {
return models.AgentCardData{}, fmt.Errorf("agent not found: %s", id)
}
return r.Get(ctx, id)
}
// Delete removes an agent. Returns nil even if the agent doesn't exist
// (idempotent). Returns a wrapped error only on transport failures.
func (r *AgentRepository) Delete(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM agents WHERE id = $1`, id)
return err
}
// Count returns the total number of agents.
func (r *AgentRepository) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents`).Scan(&n)
return n, err
}
// CountByStatus returns the number of agents with the given status.
func (r *AgentRepository) CountByStatus(ctx context.Context, status models.AgentStatus) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents WHERE status = $1`, string(status)).Scan(&n)
return n, err
}

View File

@@ -0,0 +1,38 @@
package repository
import (
"context"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
)
// AgentRepo is the interface for agent persistence operations.
type AgentRepo interface {
Create(ctx context.Context, a models.AgentCardData) error
Get(ctx context.Context, id string) (models.AgentCardData, error)
List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error)
Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error)
Delete(ctx context.Context, id string) error
Count(ctx context.Context) (int, error)
}
// SessionRepo is the interface for session persistence operations.
type SessionRepo interface {
Create(ctx context.Context, s models.Session) (models.Session, error)
ListActive(ctx context.Context) ([]models.Session, error)
Count(ctx context.Context) (int, error)
}
// TaskRepo is the interface for task persistence operations.
type TaskRepo interface {
Create(ctx context.Context, t models.Task) (models.Task, error)
ListRecent(ctx context.Context) ([]models.Task, error)
Count(ctx context.Context) (int, error)
}
// ProjectRepo is the interface for project persistence operations.
type ProjectRepo interface {
Create(ctx context.Context, p models.Project) (models.Project, error)
List(ctx context.Context) ([]models.Project, error)
Count(ctx context.Context) (int, error)
}

View File

@@ -0,0 +1,94 @@
package repository
import (
"context"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ProjectRepository provides PostgreSQL-backed CRUD for projects.
type ProjectRepository struct {
pool *pgxpool.Pool
}
// NewProjectRepository returns a repository wired to the given connection pool.
func NewProjectRepository(pool *pgxpool.Pool) *ProjectRepository {
return &ProjectRepository{pool: pool}
}
// Create inserts a new project. The current projects table only stores
// a single agent_id, so we use the first entry from AgentIDs if present.
func (r *ProjectRepository) Create(ctx context.Context, p models.Project) (models.Project, error) {
now := time.Now().UTC()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = now
}
var agentID *string
if len(p.AgentIDs) > 0 {
agentID = &p.AgentIDs[0]
}
err := r.pool.QueryRow(ctx, `
INSERT INTO projects (name, description, status, agent_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, description, status, agent_id, created_at, updated_at
`, p.Name, p.Description, string(p.Status), agentID, p.CreatedAt, p.UpdatedAt).Scan(
&p.ID, &p.Name, &p.Description, &p.Status, &agentID,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return p, err
}
if agentID != nil {
p.AgentIDs = []string{*agentID}
} else {
p.AgentIDs = []string{}
}
return p, nil
}
// List returns all projects ordered by name.
func (r *ProjectRepository) List(ctx context.Context) ([]models.Project, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, name, description, status, agent_id, created_at, updated_at
FROM projects
ORDER BY name
`)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Project, error) {
var p models.Project
var agentID *string
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.Status,
&agentID, &p.CreatedAt, &p.UpdatedAt); err != nil {
return p, err
}
if agentID != nil {
p.AgentIDs = []string{*agentID}
} else {
p.AgentIDs = []string{}
}
return p, nil
})
}
// Count returns the total number of projects.
func (r *ProjectRepository) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM projects`).Scan(&n)
return n, err
}

View File

@@ -0,0 +1,78 @@
package repository
import (
"context"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// SessionRepository provides PostgreSQL-backed CRUD for sessions.
type SessionRepository struct {
pool *pgxpool.Pool
}
// NewSessionRepository returns a repository wired to the given connection pool.
func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
return &SessionRepository{pool: pool}
}
// Create inserts a new session into the sessions table.
// Because the existing sessions table only has id, agent_id, started_at,
// ended_at, and status, we map what we can and store additional metadata
// as a fallback. AgentID is required by FK — if the session AgentID can't
// be cast to a valid UUID we store a sentinel.
func (r *SessionRepository) Create(ctx context.Context, s models.Session) (models.Session, error) {
if s.StartedAt.IsZero() {
s.StartedAt = time.Now().UTC()
}
if s.LastActivityAt.IsZero() {
s.LastActivityAt = s.StartedAt
}
err := r.pool.QueryRow(ctx, `
INSERT INTO sessions (agent_id, started_at, status)
VALUES ($1, $2, $3)
RETURNING id, agent_id, started_at, ended_at, status
`, s.AgentID, s.StartedAt, s.Status).Scan(
&s.ID, &s.AgentID, &s.StartedAt, nil, &s.Status)
return s, err
}
// ListActive returns all sessions with status 'running' or 'streaming',
// ordered by started_at descending.
func (r *SessionRepository) ListActive(ctx context.Context) ([]models.Session, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, agent_id, started_at, ended_at, status
FROM sessions
WHERE status IN ('running', 'streaming')
ORDER BY started_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Session, error) {
var s models.Session
var endedAt *time.Time
if err := row.Scan(&s.ID, &s.AgentID, &s.StartedAt, &endedAt, &s.Status); err != nil {
return s, err
}
s.LastActivityAt = s.StartedAt
if endedAt != nil {
s.LastActivityAt = *endedAt
}
return s, nil
})
}
// Count returns the total number of sessions.
func (r *SessionRepository) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&n)
return n, err
}

View File

@@ -0,0 +1,85 @@
package repository
import (
"context"
"time"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// TaskRepository provides PostgreSQL-backed CRUD for task_logs.
type TaskRepository struct {
pool *pgxpool.Pool
}
// NewTaskRepository returns a repository wired to the given connection pool.
func NewTaskRepository(pool *pgxpool.Pool) *TaskRepository {
return &TaskRepository{pool: pool}
}
// Create inserts a new task into the task_logs table.
func (r *TaskRepository) Create(ctx context.Context, t models.Task) (models.Task, error) {
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
err := r.pool.QueryRow(ctx, `
INSERT INTO task_logs (agent_id, task, status, started_at)
VALUES ($1, $2, $3, $4)
RETURNING id, agent_id, task, status, started_at, completed_at, error_message
`, t.AgentID, t.Title, string(t.Status), t.CreatedAt).Scan(
&t.ID, &t.AgentID, &t.Title, &t.Status, &t.CreatedAt,
nil, nil,
)
if err != nil {
return t, err
}
// Rebuild the Description since task_logs only stores the title as "task".
t.Description = t.Title
return t, nil
}
// ListRecent returns the most recent tasks, newest first.
func (r *TaskRepository) ListRecent(ctx context.Context) ([]models.Task, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, agent_id, task, status, started_at, completed_at, error_message
FROM task_logs
ORDER BY started_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Task, error) {
var t models.Task
var completedAt *time.Time
var errMsg *string
if err := row.Scan(&t.ID, &t.AgentID, &t.Title, &t.Status,
&t.CreatedAt, &completedAt, &errMsg); err != nil {
return t, err
}
t.Description = t.Title
t.UpdatedAt = t.CreatedAt
if completedAt != nil {
t.UpdatedAt = *completedAt
}
return t, nil
})
}
// Count returns the total number of tasks.
func (r *TaskRepository) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_logs`).Scan(&n)
return n, err
}

View File

@@ -3,6 +3,7 @@
package router package router
import ( import (
"context"
"net/http" "net/http"
"time" "time"
@@ -13,11 +14,13 @@ import (
"github.com/go-chi/cors" "github.com/go-chi/cors"
) )
// Dependencies carries the handler and database pool into the router. // Dependencies carries the handler, database pool, SSE broker, and CORS
// configuration into the router.
type Dependencies struct { type Dependencies struct {
Handler *handler.Handler Handler *handler.Handler
DB *db.Pool Pool *db.Pool
CORSOrigin string CORSOrigin string
Broker *handler.Broker
} }
// New creates a fully-configured chi router with all API routes mounted. // New creates a fully-configured chi router with all API routes mounted.
@@ -49,8 +52,10 @@ func New(deps *Dependencies) *chi.Mux {
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
status := "ok" status := "ok"
if deps.DB != nil { if deps.Pool != nil {
if err := deps.DB.Health(r.Context()); err != nil { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if err := deps.Pool.Ping(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
status = "db_unhealthy" status = "db_unhealthy"
} }
@@ -62,11 +67,11 @@ func New(deps *Dependencies) *chi.Mux {
r.Route("/api", func(api chi.Router) { r.Route("/api", func(api chi.Router) {
// Agents CRUD // Agents CRUD
api.Route("/agents", func(agents chi.Router) { api.Route("/agents", func(agents chi.Router) {
agents.Get("/", deps.Handler.ListAgents) // GET /api/agents agents.Get("/", deps.Handler.ListAgents) // GET /api/agents
agents.Post("/", deps.Handler.CreateAgent) // POST /api/agents agents.Post("/", deps.Handler.CreateAgent) // POST /api/agents
agents.Get("/{id}", deps.Handler.GetAgent) // GET /api/agents/{id} agents.Get("/{id}", deps.Handler.GetAgent) // GET /api/agents/{id}
agents.Put("/{id}", deps.Handler.UpdateAgent) // PUT /api/agents/{id} agents.Put("/{id}", deps.Handler.UpdateAgent) // PUT /api/agents/{id}
agents.Delete("/{id}", deps.Handler.DeleteAgent) // DELETE /api/agents/{id} agents.Delete("/{id}", deps.Handler.DeleteAgent) // DELETE /api/agents/{id}
agents.Get("/{id}/history", deps.Handler.AgentHistory) // GET /api/agents/{id}/history agents.Get("/{id}/history", deps.Handler.AgentHistory) // GET /api/agents/{id}/history
}) })
@@ -78,6 +83,9 @@ func New(deps *Dependencies) *chi.Mux {
// Projects // Projects
api.Get("/projects", deps.Handler.ListProjects) api.Get("/projects", deps.Handler.ListProjects)
// SSE event stream
api.Get("/events", deps.Broker.ServeHTTP)
}) })
return r return r

View File

@@ -0,0 +1,42 @@
# Control Center Kiosk Service
# =============================
# Systemd unit file for auto-starting the Control Center kiosk on boot
#
# Install: sudo cp control-center-kiosk.service /etc/systemd/system/
# Enable: sudo systemctl enable control-center-kiosk
# Start: sudo systemctl start control-center-kiosk
# Status: sudo systemctl status control-center-kiosk
# Logs: sudo journalctl -u control-center-kiosk -f
[Unit]
Description=Control Center Kiosk - Chrome Browser Dashboard
Documentation=https://code.cubecraftcreations.com/CubeCraft-Creations/Control-Center
After=graphical-session.target network-online.target
Wants=network-online.target
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=/home/overseer/projects/Control-Center/kiosk/start-kiosk.sh http://localhost:3000
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/overseer/.Xauthority
WorkingDirectory=/home/overseer/projects/Control-Center
User=overseer
Group=overseer
StandardOutput=journal
StandardError=journal
SyslogIdentifier=control-center-kiosk
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/home/overseer/.config/chromium
ReadWritePaths=/var/log/journal
[Install]
WantedBy=graphical-session.target

88
kiosk/start-kiosk.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
# Control Center Kiosk Startup Script
# ====================================
# This script launches Chromium in kiosk mode for the Control Center dashboard
# Usage: ./start-kiosk.sh [frontend-url]
set -e
FRONTEND_URL="${1:-http://localhost:3000}"
BROWSER_WINDOW="chromium-browser"
# ── Functions ────────────────────────────────────────────────────────────
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
cleanup() {
log "Stopping kiosk browser..."
pkill -f "chromium-browser.*--kiosk" || true
}
trap cleanup SIGINT SIGTERM
# ── Check prerequisites ──────────────────────────────────────────────────
check_browser() {
if ! command -v chromium-browser &> /dev/null; then
log "ERROR: chromium-browser not found"
log "Install with: sudo apt-get install chromium"
exit 1
fi
}
check_x_server() {
if [ -z "$DISPLAY" ]; then
log "ERROR: DISPLAY environment variable not set"
log "This script requires an X server session"
exit 1
fi
}
# ── Main ────────────────────────────────────────────────────────────────
main() {
log "Starting Control Center Kiosk..."
log "Frontend URL: $FRONTEND_URL"
check_browser
check_x_server
# Clean up any existing browser instances
cleanup
# Launch Chromium in kiosk mode
# --kiosk: Fullscreen without browser UI
# --incognito: Clean session
# --noerrdialogs: Suppress error dialogs
# --disable-notifications: Disable notifications
# --disable-extensions: Disable extensions
# --disable-plugins-discovery: Disable plugins
# --disable-sync: Disable sync
# --disable-web-security: Allow CORS (needed for local API calls)
# --ignore-certificate-errors: Ignore SSL errors (for local dev)
# --gpu: Enable GPU acceleration
# --start-fullscreen: Start in fullscreen mode
chromium-browser \
--kiosk \
--incognito \
--noerrdialogs \
--disable-notifications \
--disable-extensions \
--disable-plugins-discovery \
--disable-sync \
--disable-web-security \
--ignore-certificate-errors \
--gpu \
--start-fullscreen \
"$FRONTEND_URL" &
KIOSK_PID=$!
log "Kiosk browser started (PID: $KIOSK_PID)"
# Wait for browser to exit
wait $KIOSK_PID
}
main "$@"