Compare commits
16 Commits
4a2e660a4a
...
agent/dex/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439741e55f | ||
|
|
3c26b8deba | ||
|
|
4569fef11d | ||
|
|
7a93d43b7e | ||
| efcedde649 | |||
|
|
0ac4898027 | ||
|
|
e131798f3b | ||
|
|
9062f8fa8d | ||
|
|
60ba3e5b4f | ||
|
|
70d39b87d1 | ||
| 519e872027 | |||
| 2b4b9b3e96 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| e8ced74429 | |||
| 8b8cb8210c |
50
.env.example
Normal file
50
.env.example
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 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 gateway config (primary path)
|
||||||
|
WS_GATEWAY_URL=ws://host.docker.internal:18789/
|
||||||
|
# Gateway auth token — same as OPENCLAW_GATEWAY_TOKEN (set in environment)
|
||||||
|
GATEWAY_TOKEN=
|
||||||
|
|
||||||
|
# REST poller config (fallback, only used if WS fails to connect)
|
||||||
|
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||||
|
# Polling interval for agent state updates (fallback only)
|
||||||
|
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
|
||||||
85
.gitea/workflows/build-dev.yaml
Normal file
85
.gitea/workflows/build-dev.yaml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: Build (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.23"
|
||||||
|
NODE_VERSION: "22"
|
||||||
|
BINARY_NAME: server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-go-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Test Go backend
|
||||||
|
working-directory: go-backend
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build Go binary
|
||||||
|
working-directory: go-backend
|
||||||
|
run: |
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -ldflags="-s -w -X main.version=${GITHUB_SHA:0:8}" \
|
||||||
|
-o ${{ env.BINARY_NAME }} ./cmd/server
|
||||||
|
|
||||||
|
- name: Upload Go binary
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: go-backend-binary
|
||||||
|
path: go-backend/${{ env.BINARY_NAME }}
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Install and build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Upload frontend dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: frontend/dist/
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
trigger-deploy:
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
needs: [build-go-backend, build-frontend]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger deploy workflow
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
await github.rest.repos.createDispatchEvent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
event_type: 'dev-build-success',
|
||||||
|
client_payload: {
|
||||||
|
sha: context.sha,
|
||||||
|
ref: context.ref
|
||||||
|
}
|
||||||
|
})
|
||||||
126
.gitea/workflows/deploy-dev.yaml
Normal file
126
.gitea/workflows/deploy-dev.yaml
Normal 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
|
||||||
@@ -8,27 +8,19 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test:
|
build-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-dotnet
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '9.0.x'
|
|
||||||
|
|
||||||
- name: Restore backend
|
- name: Restore backend
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
working-directory: ./backend
|
|
||||||
|
|
||||||
- name: Build backend
|
- name: Build backend
|
||||||
run: dotnet build --no-restore --configuration Release
|
run: dotnet build --no-restore --configuration Release
|
||||||
working-directory: ./backend
|
|
||||||
|
|
||||||
- name: Test backend
|
- name: Test backend
|
||||||
run: dotnet test --no-build --configuration Release
|
run: dotnet test --no-build --configuration Release
|
||||||
working-directory: ./backend
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -42,3 +34,17 @@ jobs:
|
|||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
deploy-dev:
|
||||||
|
needs: build-test
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy dev
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.DEV_DEPLOY_SSH_KEY }}" > /tmp/dev_key
|
||||||
|
chmod 600 /tmp/dev_key
|
||||||
|
ssh -i /tmp/dev_key -o StrictHostKeyChecking=no \
|
||||||
|
${{ secrets.DEV_DEPLOY_USER }}@${{ secrets.DEV_DEPLOY_HOST }} \
|
||||||
|
"${{ secrets.DEV_DEPLOY_PATH }}/deploy.sh"
|
||||||
268
README-deployment.md
Normal file
268
README-deployment.md
Normal 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 |
|
||||||
74
docker-compose.yml
Normal file
74
docker-compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 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
|
||||||
|
- WS_GATEWAY_URL=ws://host.docker.internal:18789/
|
||||||
|
- GATEWAY_TOKEN=${GATEWAY_TOKEN:-}
|
||||||
|
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:
|
||||||
29
frontend/src/hooks/useLocalStorage.ts
Normal file
29
frontend/src/hooks/useLocalStorage.ts
Normal 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]
|
||||||
|
}
|
||||||
50
frontend/src/hooks/useTheme.tsx
Normal file
50
frontend/src/hooks/useTheme.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,65 @@ 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 clients (WS primary, REST fallback) ───────────────────
|
||||||
|
// WS gateway client (primary path)
|
||||||
|
wsClient := gateway.NewWSClient(gateway.WSConfig{
|
||||||
|
URL: cfg.WSGatewayURL,
|
||||||
|
AuthToken: cfg.WSGatewayToken,
|
||||||
|
}, agentRepo, broker, logger)
|
||||||
|
|
||||||
|
// REST gateway client (fallback — only polls if WS fails to connect)
|
||||||
|
gwClient := gateway.NewClient(gateway.Config{
|
||||||
|
URL: cfg.GatewayRestURL,
|
||||||
|
PollInterval: cfg.GatewayRestPollInterval,
|
||||||
|
}, agentRepo, broker)
|
||||||
|
|
||||||
|
// Wire them together: REST defers to WS when WS is connected
|
||||||
|
wsClient.SetRESTClient(gwClient)
|
||||||
|
gwClient.SetWSClient(wsClient)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start WS client first (primary)
|
||||||
|
go wsClient.Start(ctx)
|
||||||
|
// Start REST client (will wait for WS, then stand down or fall back)
|
||||||
|
go gwClient.Start(ctx)
|
||||||
|
|
||||||
// ── Server ─────────────────────────────────────────────────────────────
|
// ── Server ─────────────────────────────────────────────────────────────
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
@@ -78,18 +112,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 polling
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
GatewayRestURL string
|
||||||
|
GatewayRestPollInterval time.Duration
|
||||||
|
WSGatewayURL string
|
||||||
|
WSGatewayToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"),
|
||||||
|
GatewayRestURL: getEnv("GATEWAY_URL", "http://host.docker.internal:18789/api/agents"),
|
||||||
|
GatewayRestPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second),
|
||||||
|
WSGatewayURL: getEnv("WS_GATEWAY_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
|
||||||
|
}
|
||||||
|
|||||||
246
go-backend/internal/gateway/client.go
Normal file
246
go-backend/internal/gateway/client.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// 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.
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"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: it waits for the WS client to signal readiness, and only starts
|
||||||
|
// polling if WS fails to connect after initial backoff retries.
|
||||||
|
type Client struct {
|
||||||
|
url string
|
||||||
|
pollInterval time.Duration
|
||||||
|
httpClient *http.Client
|
||||||
|
agents repository.AgentRepo
|
||||||
|
broker *handler.Broker
|
||||||
|
wsClient *WSClient // optional WS client; when set, REST is fallback only
|
||||||
|
wsReady chan struct{} // closed once WS connection is established
|
||||||
|
wsReadyOnce sync.Once // protects wsReady close from double-close race
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
c.wsClient = ws
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
c.wsReadyOnce.Do(func() {
|
||||||
|
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 and only logs periodically. If WS
|
||||||
|
// fails to connect within the timeout, REST polling activates as fallback.
|
||||||
|
func (c *Client) Start(ctx context.Context) {
|
||||||
|
if c.wsClient != nil {
|
||||||
|
slog.Info("gateway client waiting for WS connection", "timeout", "30s")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.wsReady:
|
||||||
|
slog.Info("gateway client using WS — REST poller standing down")
|
||||||
|
// WS is live; keep this goroutine alive but idle. If WS
|
||||||
|
// disconnects later, we could re-enter polling, but for now
|
||||||
|
// the WS client handles its own reconnection.
|
||||||
|
<-ctx.Done()
|
||||||
|
slog.Info("gateway client stopped (WS mode)")
|
||||||
|
return
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
slog.Warn("gateway client: WS not ready after 30s — falling back to REST polling",
|
||||||
|
"url", c.url,
|
||||||
|
"pollInterval", c.pollInterval.String())
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("gateway client stopped while waiting for WS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Info("gateway client using REST polling (no WS client configured)",
|
||||||
|
"url", c.url,
|
||||||
|
"pollInterval", c.pollInterval.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST fallback polling
|
||||||
|
ticker := time.NewTicker(c.pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("gateway client stopped (REST fallback)")
|
||||||
|
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 {
|
||||||
|
// Check if agent already exists; if so, update; otherwise create.
|
||||||
|
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 }
|
||||||
287
go-backend/internal/gateway/events.go
Normal file
287
go-backend/internal/gateway/events.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// 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.
|
||||||
|
// Call this once after a successful handshake + initial sync.
|
||||||
|
func (c *WSClient) registerEventHandlers() {
|
||||||
|
if c.agents == nil || c.broker == nil {
|
||||||
|
c.logger.Info("event handlers skipped (no repository or broker)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing handlers to prevent duplicates on reconnect
|
||||||
|
c.mu.Lock()
|
||||||
|
c.handlers = make(map[string][]eventHandler)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
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 start", "payload", string(payload))
|
||||||
|
|
||||||
|
// Try array first, then single object
|
||||||
|
var deltas []sessionChangedPayload
|
||||||
|
if err := json.Unmarshal(payload, &deltas); err == nil && len(deltas) > 0 {
|
||||||
|
// Array of deltas
|
||||||
|
} else {
|
||||||
|
// Try single object
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Build partial update
|
||||||
|
update := models.UpdateAgentRequest{
|
||||||
|
Status: &agentStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session key
|
||||||
|
if d.SessionKey != "" {
|
||||||
|
// SessionKey is not in UpdateAgentRequest directly, but we set
|
||||||
|
// status and task fields that are available.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current task
|
||||||
|
if d.CurrentTask != "" {
|
||||||
|
update.CurrentTask = &d.CurrentTask
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task progress
|
||||||
|
if d.TaskProgress != nil {
|
||||||
|
update.TaskProgress = d.TaskProgress
|
||||||
|
} else if d.TotalTokens > 0 {
|
||||||
|
// Derive progress from token count as fallback
|
||||||
|
prog := min(d.TotalTokens/100, 100)
|
||||||
|
update.TaskProgress = &prog
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task elapsed
|
||||||
|
if d.TaskElapsed != "" {
|
||||||
|
update.TaskElapsed = &d.TaskElapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if d.ErrorMessage != "" {
|
||||||
|
update.ErrorMessage = &d.ErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// If session ended (done or empty status), set agent to idle and
|
||||||
|
// clear the current task
|
||||||
|
if agentStatus == models.AgentStatusIdle {
|
||||||
|
emptyTask := ""
|
||||||
|
update.CurrentTask = &emptyTask
|
||||||
|
zeroProg := 0
|
||||||
|
update.TaskProgress = &zeroProg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB 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 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Debug("handleSessionsChanged end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── presence ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handlePresence processes presence events from the gateway. Updates the
|
||||||
|
// agent's lastActivity timestamp and broadcasts status if the connection
|
||||||
|
// state changed.
|
||||||
|
func (c *WSClient) handlePresence(payload json.RawMessage) {
|
||||||
|
c.logger.Debug("handlePresence start", "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 == "" {
|
||||||
|
c.logger.Debug("presence: skipping event with empty agentId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// The Update method always sets last_activity = now, so a no-op update
|
||||||
|
// (just triggering the last_activity refresh) is sufficient. We send
|
||||||
|
// an empty-ish update — the repo always bumps last_activity.
|
||||||
|
// If connection state is reported, also update status.
|
||||||
|
update := models.UpdateAgentRequest{}
|
||||||
|
|
||||||
|
if p.Connected != nil && !*p.Connected {
|
||||||
|
// Device disconnected — set agent to idle
|
||||||
|
idle := models.AgentStatusIdle
|
||||||
|
update.Status = &idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass lastActivityAt from the event so DB and SSE stay consistent
|
||||||
|
if p.LastActivityAt != "" {
|
||||||
|
update.LastActivityAt = &p.LastActivityAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB first
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then 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 (name, 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 start", "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 == "" {
|
||||||
|
c.logger.Debug("agent.config: skipping event with empty id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Build partial update with available fields.
|
||||||
|
update := models.UpdateAgentRequest{}
|
||||||
|
|
||||||
|
if cfg.Name != "" {
|
||||||
|
update.DisplayName = &cfg.Name
|
||||||
|
}
|
||||||
|
if cfg.Role != "" {
|
||||||
|
update.Role = &cfg.Role
|
||||||
|
}
|
||||||
|
if cfg.Channel != "" {
|
||||||
|
update.Channel = &cfg.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then broadcast fleet snapshot
|
||||||
|
allAgents, err := c.agents.List(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("agent.config: failed to list fleet for broadcast",
|
||||||
|
"error", err)
|
||||||
|
// Still broadcast the single agent update as fallback
|
||||||
|
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)
|
||||||
|
}
|
||||||
516
go-backend/internal/gateway/events_test.go
Normal file
516
go-backend/internal/gateway/events_test.go
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Mock AgentRepo ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type mockAgentRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
agents map[string]models.AgentCardData
|
||||||
|
updateCalls []updateCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateCall struct {
|
||||||
|
id string
|
||||||
|
req models.UpdateAgentRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAgentRepo) Get(_ context.Context, id string) (models.AgentCardData, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
a, ok := m.agents[id]
|
||||||
|
if !ok {
|
||||||
|
return models.AgentCardData{}, errNotFound
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAgentRepo) Update(_ context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
a, ok := m.agents[id]
|
||||||
|
if !ok {
|
||||||
|
return models.AgentCardData{}, errNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != nil {
|
||||||
|
a.Status = *req.Status
|
||||||
|
}
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
a.DisplayName = *req.DisplayName
|
||||||
|
}
|
||||||
|
if req.Role != nil {
|
||||||
|
a.Role = *req.Role
|
||||||
|
}
|
||||||
|
if req.Channel != nil {
|
||||||
|
a.Channel = *req.Channel
|
||||||
|
}
|
||||||
|
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.ErrorMessage != nil {
|
||||||
|
a.ErrorMessage = req.ErrorMessage
|
||||||
|
}
|
||||||
|
if req.LastActivityAt != nil {
|
||||||
|
a.LastActivity = *req.LastActivityAt
|
||||||
|
}
|
||||||
|
|
||||||
|
m.agents[id] = a
|
||||||
|
m.updateCalls = append(m.updateCalls, updateCall{id, req})
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAgentRepo) Create(_ context.Context, a models.AgentCardData) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.agents[a.ID] = a
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAgentRepo) List(_ context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
var result []models.AgentCardData
|
||||||
|
for _, a := range m.agents {
|
||||||
|
if statusFilter == "" || a.Status == statusFilter {
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAgentRepo) Delete(_ context.Context, id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.agents, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAgentRepo) Count(_ context.Context) (int, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.agents), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNotFound is returned by the mock repo when an agent is not found.
|
||||||
|
var errNotFound = fmt.Errorf("not found")
|
||||||
|
|
||||||
|
// ── Broadcast capture helper ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// broadcastCapture wraps a real Broker and captures all broadcasts
|
||||||
|
// via a subscribed channel. Use captured() to retrieve events that have
|
||||||
|
// been received so far. Call close() to unsubscribe when done.
|
||||||
|
type broadcastCapture struct {
|
||||||
|
broker *handler.Broker
|
||||||
|
ch chan handler.SSEEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBroadcastCapture(broker *handler.Broker) *broadcastCapture {
|
||||||
|
return &broadcastCapture{
|
||||||
|
broker: broker,
|
||||||
|
ch: broker.Subscribe(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captured drains all pending events from the subscription channel
|
||||||
|
// and returns them. This is synchronous — it only returns events that
|
||||||
|
// have already been sent to the channel.
|
||||||
|
func (bc *broadcastCapture) captured() []handler.SSEEvent {
|
||||||
|
var events []handler.SSEEvent
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case evt := <-bc.ch:
|
||||||
|
events = append(events, evt)
|
||||||
|
default:
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *broadcastCapture) close() {
|
||||||
|
bc.broker.Unsubscribe(bc.ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// newTestWSClient creates a WSClient wired to a mock repo and a real broker.
|
||||||
|
// Returns the client, the mock repo, and a broadcast capture.
|
||||||
|
func newTestWSClient() (*WSClient, *mockAgentRepo, *handler.Broker, *broadcastCapture) {
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
client := NewWSClient(WSConfig{}, repo, broker, slog.Default())
|
||||||
|
return client, repo, broker, capture
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleSessionsChanged_Active(t *testing.T) {
|
||||||
|
client, repo, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
repo.agents["otto"] = models.AgentCardData{
|
||||||
|
ID: "otto",
|
||||||
|
DisplayName: "Otto",
|
||||||
|
Status: models.AgentStatusIdle,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{
|
||||||
|
"sessionKey": "s1",
|
||||||
|
"agentId": "otto",
|
||||||
|
"status": "running",
|
||||||
|
"totalTokens": 500,
|
||||||
|
"currentTask": "Orchestrating tasks"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
client.handleSessionsChanged(payload)
|
||||||
|
|
||||||
|
// Verify: agent status updated to active
|
||||||
|
repo.mu.Lock()
|
||||||
|
agent := repo.agents["otto"]
|
||||||
|
calls := make([]updateCall, len(repo.updateCalls))
|
||||||
|
copy(calls, repo.updateCalls)
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
if agent.Status != models.AgentStatusActive {
|
||||||
|
t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: update was called
|
||||||
|
if len(calls) == 0 {
|
||||||
|
t.Fatal("expected at least one update call")
|
||||||
|
}
|
||||||
|
if calls[0].id != "otto" {
|
||||||
|
t.Errorf("update call agentId = %q, want %q", calls[0].id, "otto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: broker broadcast "agent.status"
|
||||||
|
events := capture.captured()
|
||||||
|
found := false
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "agent.status" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected broker broadcast with event type 'agent.status'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessionsChanged_Idle(t *testing.T) {
|
||||||
|
client, repo, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
repo.agents["dex"] = models.AgentCardData{
|
||||||
|
ID: "dex",
|
||||||
|
DisplayName: "Dex",
|
||||||
|
Status: models.AgentStatusActive,
|
||||||
|
CurrentTask: strPtr("Writing API"),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{
|
||||||
|
"sessionKey": "s2",
|
||||||
|
"agentId": "dex",
|
||||||
|
"status": "done",
|
||||||
|
"totalTokens": 1000
|
||||||
|
}`)
|
||||||
|
|
||||||
|
client.handleSessionsChanged(payload)
|
||||||
|
|
||||||
|
repo.mu.Lock()
|
||||||
|
agent := repo.agents["dex"]
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
// Verify: agent goes idle
|
||||||
|
if agent.Status != models.AgentStatusIdle {
|
||||||
|
t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusIdle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: current task cleared (set to empty string)
|
||||||
|
if agent.CurrentTask != nil && *agent.CurrentTask != "" {
|
||||||
|
t.Errorf("current task = %q, want empty (cleared on idle)", *agent.CurrentTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: broker fires "agent.status"
|
||||||
|
events := capture.captured()
|
||||||
|
found := false
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "agent.status" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected broker broadcast with event type 'agent.status'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessionsChanged_ArrayPayload(t *testing.T) {
|
||||||
|
client, repo, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusIdle}
|
||||||
|
repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle}
|
||||||
|
|
||||||
|
payload := json.RawMessage(`[
|
||||||
|
{"sessionKey":"s1","agentId":"otto","status":"running","totalTokens":100},
|
||||||
|
{"sessionKey":"s2","agentId":"dex","status":"streaming","totalTokens":200}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
client.handleSessionsChanged(payload)
|
||||||
|
|
||||||
|
repo.mu.Lock()
|
||||||
|
otto := repo.agents["otto"]
|
||||||
|
dex := repo.agents["dex"]
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
if otto.Status != models.AgentStatusActive {
|
||||||
|
t.Errorf("otto status = %q, want active", otto.Status)
|
||||||
|
}
|
||||||
|
if dex.Status != models.AgentStatusActive {
|
||||||
|
t.Errorf("dex status = %q, want active", dex.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both should produce broadcasts
|
||||||
|
events := capture.captured()
|
||||||
|
statusCount := 0
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "agent.status" {
|
||||||
|
statusCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if statusCount < 2 {
|
||||||
|
t.Errorf("expected at least 2 agent.status broadcasts, got %d", statusCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessionsChanged_SkipsEmptyAgentID(t *testing.T) {
|
||||||
|
client, _, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{"sessionKey":"s1","agentId":"","status":"running"}`)
|
||||||
|
client.handleSessionsChanged(payload)
|
||||||
|
|
||||||
|
events := capture.captured()
|
||||||
|
if len(events) > 0 {
|
||||||
|
t.Errorf("expected no broadcasts for empty agentId, got %d", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSessionsChanged_UnparseablePayload(t *testing.T) {
|
||||||
|
client, _, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
payload := json.RawMessage(`not json at all`)
|
||||||
|
client.handleSessionsChanged(payload)
|
||||||
|
|
||||||
|
events := capture.captured()
|
||||||
|
if len(events) > 0 {
|
||||||
|
t.Errorf("expected no broadcasts for unparseable payload, got %d", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePresence(t *testing.T) {
|
||||||
|
client, repo, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
repo.agents["pip"] = models.AgentCardData{
|
||||||
|
ID: "pip",
|
||||||
|
DisplayName: "Pip",
|
||||||
|
Status: models.AgentStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{
|
||||||
|
"agentId": "pip",
|
||||||
|
"connected": true,
|
||||||
|
"lastActivityAt": "2025-01-01T00:00:00Z"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
client.handlePresence(payload)
|
||||||
|
|
||||||
|
repo.mu.Lock()
|
||||||
|
agent := repo.agents["pip"]
|
||||||
|
calls := make([]updateCall, len(repo.updateCalls))
|
||||||
|
copy(calls, repo.updateCalls)
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
// Agent should still be active (connected=true doesn't change status)
|
||||||
|
if agent.Status != models.AgentStatusActive {
|
||||||
|
t.Errorf("agent status = %q, want active", agent.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update should have been called (for lastActivityAt)
|
||||||
|
if len(calls) == 0 {
|
||||||
|
t.Fatal("expected at least one update call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify broadcast
|
||||||
|
events := capture.captured()
|
||||||
|
found := false
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "agent.status" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected broker broadcast with event type 'agent.status'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePresence_Disconnect(t *testing.T) {
|
||||||
|
client, repo, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
repo.agents["pip"] = models.AgentCardData{
|
||||||
|
ID: "pip",
|
||||||
|
DisplayName: "Pip",
|
||||||
|
Status: models.AgentStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{
|
||||||
|
"agentId": "pip",
|
||||||
|
"connected": false
|
||||||
|
}`)
|
||||||
|
|
||||||
|
client.handlePresence(payload)
|
||||||
|
|
||||||
|
repo.mu.Lock()
|
||||||
|
agent := repo.agents["pip"]
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
// Agent should go idle on disconnect
|
||||||
|
if agent.Status != models.AgentStatusIdle {
|
||||||
|
t.Errorf("agent status = %q, want idle after disconnect", agent.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := capture.captured()
|
||||||
|
found := false
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "agent.status" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected broker broadcast with event type 'agent.status' on disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePresence_EmptyAgentID(t *testing.T) {
|
||||||
|
client, _, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{"agentId":"","connected":true}`)
|
||||||
|
client.handlePresence(payload)
|
||||||
|
|
||||||
|
events := capture.captured()
|
||||||
|
if len(events) > 0 {
|
||||||
|
t.Errorf("expected no broadcasts for empty agentId, got %d", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgentConfig(t *testing.T) {
|
||||||
|
client, repo, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
repo.agents["rex"] = models.AgentCardData{
|
||||||
|
ID: "rex",
|
||||||
|
DisplayName: "Rex",
|
||||||
|
Role: "Frontend Dev",
|
||||||
|
Status: models.AgentStatusIdle,
|
||||||
|
Channel: "discord",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{
|
||||||
|
"id": "rex",
|
||||||
|
"name": "Rex the Dev",
|
||||||
|
"role": "Senior Frontend",
|
||||||
|
"channel": "telegram"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
client.handleAgentConfig(payload)
|
||||||
|
|
||||||
|
repo.mu.Lock()
|
||||||
|
agent := repo.agents["rex"]
|
||||||
|
calls := make([]updateCall, len(repo.updateCalls))
|
||||||
|
copy(calls, repo.updateCalls)
|
||||||
|
repo.mu.Unlock()
|
||||||
|
|
||||||
|
// Verify DisplayName and Role updated
|
||||||
|
if agent.DisplayName != "Rex the Dev" {
|
||||||
|
t.Errorf("displayName = %q, want %q", agent.DisplayName, "Rex the Dev")
|
||||||
|
}
|
||||||
|
if agent.Role != "Senior Frontend" {
|
||||||
|
t.Errorf("role = %q, want %q", agent.Role, "Senior Frontend")
|
||||||
|
}
|
||||||
|
if agent.Channel != "telegram" {
|
||||||
|
t.Errorf("channel = %q, want %q", agent.Channel, "telegram")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update was called
|
||||||
|
if len(calls) == 0 {
|
||||||
|
t.Fatal("expected at least one update call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify broker fires "fleet.update"
|
||||||
|
events := capture.captured()
|
||||||
|
found := false
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "fleet.update" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected broker broadcast with event type 'fleet.update'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgentConfig_EmptyID(t *testing.T) {
|
||||||
|
client, _, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{"id":"","name":"Ghost"}`)
|
||||||
|
client.handleAgentConfig(payload)
|
||||||
|
|
||||||
|
events := capture.captured()
|
||||||
|
if len(events) > 0 {
|
||||||
|
t.Errorf("expected no broadcasts for empty id, got %d", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAgentConfig_NotFound(t *testing.T) {
|
||||||
|
client, _, _, capture := newTestWSClient()
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{"id":"unknown","name":"Ghost","role":"Phantom"}`)
|
||||||
|
client.handleAgentConfig(payload)
|
||||||
|
|
||||||
|
// Agent doesn't exist in repo, so Update will fail → handler logs warning, returns early
|
||||||
|
events := capture.captured()
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "fleet.update" {
|
||||||
|
t.Error("fleet.update should not be broadcast when agent update fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
go-backend/internal/gateway/sync.go
Normal file
196
go-backend/internal/gateway/sync.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// 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.
|
||||||
|
// Fields are extracted gracefully from json.RawMessage so unknown fields
|
||||||
|
// from the gateway are silently ignored.
|
||||||
|
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 {
|
||||||
|
if c.agents == nil {
|
||||||
|
c.logger.Info("initial sync skipped (no repository)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("initial sync starting")
|
||||||
|
|
||||||
|
// 1. Fetch agents
|
||||||
|
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
|
||||||
|
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 if display name or role changed
|
||||||
|
if existing.DisplayName != card.DisplayName || existing.Role != card.Role {
|
||||||
|
newName := card.DisplayName
|
||||||
|
newRole := card.Role
|
||||||
|
_, updateErr := c.agents.Update(ctx, card.ID, models.UpdateAgentRequest{
|
||||||
|
DisplayName: &newName,
|
||||||
|
Role: &newRole,
|
||||||
|
})
|
||||||
|
if updateErr != nil {
|
||||||
|
c.logger.Warn("sync: agent update failed", "id", card.ID, "error", updateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch sessions
|
||||||
|
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 a map of agentId → session for merge
|
||||||
|
sessionByAgent := make(map[string]sessionListItem)
|
||||||
|
for _, s := range sessionItems {
|
||||||
|
if s.AgentID != "" {
|
||||||
|
sessionByAgent[s.AgentID] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Merge session state into agents and update + 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
|
||||||
|
card.SessionKey = session.SessionKey
|
||||||
|
card.Status = mapSessionStatus(session.Status)
|
||||||
|
card.LastActivity = session.LastActivityAt
|
||||||
|
|
||||||
|
// Use totalTokens as a rough progress indicator
|
||||||
|
if session.TotalTokens > 0 {
|
||||||
|
prog := min(session.TotalTokens/100, 100) // normalize to 0-100
|
||||||
|
card.TaskProgress = &prog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist merged state
|
||||||
|
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 = "unknown"
|
||||||
|
}
|
||||||
|
name := item.Name
|
||||||
|
if name == "" {
|
||||||
|
name = item.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.AgentCardData{
|
||||||
|
ID: item.ID,
|
||||||
|
DisplayName: name,
|
||||||
|
Role: role,
|
||||||
|
Status: models.AgentStatusIdle, // default; will be overridden by session merge
|
||||||
|
SessionKey: "",
|
||||||
|
Channel: channel,
|
||||||
|
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
236
go-backend/internal/gateway/sync_test.go
Normal file
236
go-backend/internal/gateway/sync_test.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitialSync(t *testing.T) {
|
||||||
|
_ = &mockAgentRepo{agents: make(map[string]models.AgentCardData)} // verify mock compiles
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
// --- Test agentItemToCard + session merge (the core of initialSync) ---
|
||||||
|
|
||||||
|
agentItems := []agentListItem{
|
||||||
|
{ID: "otto", Name: "Otto", Role: "Orchestrator", Channel: "discord"},
|
||||||
|
{ID: "dex", Name: "Dex", Role: "Backend Dev", Channel: "telegram"},
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionItems := []sessionListItem{
|
||||||
|
{SessionKey: "s1", AgentID: "otto", Status: "running", TotalTokens: 500, LastActivityAt: "2025-05-20T12:00:00Z"},
|
||||||
|
{SessionKey: "s2", AgentID: "dex", Status: "done", TotalTokens: 1000, LastActivityAt: "2025-05-20T11:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sessionByAgent map (mirrors initialSync logic)
|
||||||
|
sessionByAgent := make(map[string]sessionListItem)
|
||||||
|
for _, s := range sessionItems {
|
||||||
|
if s.AgentID != "" {
|
||||||
|
sessionByAgent[s.AgentID] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and verify
|
||||||
|
merged := make([]models.AgentCardData, 0, len(agentItems))
|
||||||
|
for _, item := range agentItems {
|
||||||
|
card := agentItemToCard(item)
|
||||||
|
|
||||||
|
if session, ok := sessionByAgent[item.ID]; ok {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify otto: running → active
|
||||||
|
if merged[0].ID != "otto" {
|
||||||
|
t.Errorf("merged[0].ID = %q, want %q", merged[0].ID, "otto")
|
||||||
|
}
|
||||||
|
if merged[0].Status != models.AgentStatusActive {
|
||||||
|
t.Errorf("otto status = %q, want %q (running → active)", merged[0].Status, models.AgentStatusActive)
|
||||||
|
}
|
||||||
|
if merged[0].SessionKey != "s1" {
|
||||||
|
t.Errorf("otto sessionKey = %q, want %q", merged[0].SessionKey, "s1")
|
||||||
|
}
|
||||||
|
if merged[0].TaskProgress == nil || *merged[0].TaskProgress != 5 {
|
||||||
|
t.Errorf("otto taskProgress = %v, want 5", merged[0].TaskProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dex: done → idle
|
||||||
|
if merged[1].ID != "dex" {
|
||||||
|
t.Errorf("merged[1].ID = %q, want %q", merged[1].ID, "dex")
|
||||||
|
}
|
||||||
|
if merged[1].Status != models.AgentStatusIdle {
|
||||||
|
t.Errorf("dex status = %q, want %q (done → idle)", merged[1].Status, models.AgentStatusIdle)
|
||||||
|
}
|
||||||
|
if merged[1].SessionKey != "s2" {
|
||||||
|
t.Errorf("dex sessionKey = %q, want %q", merged[1].SessionKey, "s2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialSync_PersistCreatesNew(t *testing.T) {
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
// Simulate the persist logic from initialSync:
|
||||||
|
// new agents should be created
|
||||||
|
card := agentItemToCard(agentListItem{ID: "otto", Name: "Otto", Role: "Orchestrator", Channel: "discord"})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Agent doesn't exist → create
|
||||||
|
_, err := repo.Get(ctx, card.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected agent to not exist yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Create(ctx, card); err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.Get(ctx, card.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get after Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.ID != "otto" {
|
||||||
|
t.Errorf("got.ID = %q, want %q", got.ID, "otto")
|
||||||
|
}
|
||||||
|
if got.DisplayName != "Otto" {
|
||||||
|
t.Errorf("got.DisplayName = %q, want %q", got.DisplayName, "Otto")
|
||||||
|
}
|
||||||
|
if got.Role != "Orchestrator" {
|
||||||
|
t.Errorf("got.Role = %q, want %q", got.Role, "Orchestrator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialSync_PersistUpdatesExisting(t *testing.T) {
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Pre-populate with existing agent
|
||||||
|
repo.agents["otto"] = models.AgentCardData{
|
||||||
|
ID: "otto",
|
||||||
|
DisplayName: "Otto",
|
||||||
|
Role: "Old Role",
|
||||||
|
Status: models.AgentStatusIdle,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate initialSync: agent exists, name/role changed → update
|
||||||
|
newName := "Otto Prime"
|
||||||
|
newRole := "Super Orchestrator"
|
||||||
|
_, err := repo.Update(ctx, "otto", models.UpdateAgentRequest{
|
||||||
|
DisplayName: &newName,
|
||||||
|
Role: &newRole,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.Get(ctx, "otto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get after Update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.DisplayName != "Otto Prime" {
|
||||||
|
t.Errorf("displayName = %q, want %q", got.DisplayName, "Otto Prime")
|
||||||
|
}
|
||||||
|
if got.Role != "Super Orchestrator" {
|
||||||
|
t.Errorf("role = %q, want %q", got.Role, "Super Orchestrator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialSync_MergesSessionStatus(t *testing.T) {
|
||||||
|
// When initialSync merges session state, an agent whose existing status
|
||||||
|
// differs from the session-derived status should be updated.
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
repo.agents["otto"] = models.AgentCardData{
|
||||||
|
ID: "otto",
|
||||||
|
DisplayName: "Otto",
|
||||||
|
Role: "Orchestrator",
|
||||||
|
Status: models.AgentStatusIdle,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate session merge: session says "running" → agent should go active
|
||||||
|
activeStatus := mapSessionStatus("running")
|
||||||
|
if activeStatus != models.AgentStatusActive {
|
||||||
|
t.Fatalf("mapSessionStatus(running) = %q, want active", activeStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := repo.Update(ctx, "otto", models.UpdateAgentRequest{
|
||||||
|
Status: &activeStatus,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.Get(ctx, "otto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Status != models.AgentStatusActive {
|
||||||
|
t.Errorf("status after merge = %q, want %q", got.Status, models.AgentStatusActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialSync_BroadcastsFleet(t *testing.T) {
|
||||||
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
||||||
|
broker := handler.NewBroker()
|
||||||
|
capture := newBroadcastCapture(broker)
|
||||||
|
defer capture.close()
|
||||||
|
|
||||||
|
// Create some agents in the repo
|
||||||
|
repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusActive}
|
||||||
|
repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle}
|
||||||
|
|
||||||
|
// Simulate the final broadcast from initialSync
|
||||||
|
mergedAgents := []models.AgentCardData{
|
||||||
|
repo.agents["otto"],
|
||||||
|
repo.agents["dex"],
|
||||||
|
}
|
||||||
|
broker.Broadcast("fleet.update", mergedAgents)
|
||||||
|
|
||||||
|
events := capture.captured()
|
||||||
|
if len(events) == 0 {
|
||||||
|
t.Fatal("expected at least one broadcast event")
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, evt := range events {
|
||||||
|
if evt.EventType == "fleet.update" {
|
||||||
|
found = true
|
||||||
|
// Verify data is the merged agents list
|
||||||
|
agents, ok := evt.Data.([]models.AgentCardData)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("fleet.update data type = %T, want []models.AgentCardData", evt.Data)
|
||||||
|
}
|
||||||
|
if len(agents) != 2 {
|
||||||
|
t.Errorf("fleet.update agents count = %d, want 2", len(agents))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected fleet.update broadcast event")
|
||||||
|
}
|
||||||
|
}
|
||||||
460
go-backend/internal/gateway/wsclient.go
Normal file
460
go-backend/internal/gateway/wsclient.go
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
// 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.
|
||||||
|
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/gorilla/websocket"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
wsReadyOnce sync.Once // ensures MarkWSReady close is one-shot
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. This is 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 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. On
|
||||||
|
// ctx cancellation it performs a clean shutdown.
|
||||||
|
func (c *WSClient) Start(ctx context.Context) {
|
||||||
|
initialBackoff := 1 * time.Second
|
||||||
|
maxBackoff := 30 * time.Second
|
||||||
|
backoff := initialBackoff
|
||||||
|
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
// Reset backoff on successful connect+run completion
|
||||||
|
backoff = initialBackoff
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// When context is cancelled, close the conn to unblock ReadJSON in readLoop.
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Store connId for reference
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset wsReadyOnce so MarkWSReady can fire again after a reconnect
|
||||||
|
c.wsReadyOnce = sync.Once{}
|
||||||
|
|
||||||
|
// Step 2b: Initial sync — fetch agents + sessions from gateway
|
||||||
|
if err := c.initialSync(ctx); err != nil {
|
||||||
|
c.logger.Warn("initial sync failed, will continue with read loop", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2c: Register live event handlers
|
||||||
|
c.registerEventHandlers()
|
||||||
|
|
||||||
|
// Step 3: Read loop
|
||||||
|
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", "params", string(frame.Params))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for hello-ok method in the result
|
||||||
|
// The gateway responds with method "hello-ok" on success
|
||||||
|
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 when the connection is closed by the ctx-done
|
||||||
|
// goroutine started in connectAndRun.
|
||||||
|
func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error {
|
||||||
|
for {
|
||||||
|
var frame wsFrame
|
||||||
|
if err := conn.ReadJSON(&frame); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
// Check if it's a close error
|
||||||
|
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.Warn("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 {
|
||||||
|
// Send nil to signal error; caller checks via Send return
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Send sends a JSON request to the gateway and returns the response payload.
|
||||||
|
// It is safe for concurrent use. Returns an error if the client is not
|
||||||
|
// connected.
|
||||||
|
func (c *WSClient) Send(method string, params any) (json.RawMessage, error) {
|
||||||
|
reqID := uuid.New().String()
|
||||||
|
|
||||||
|
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()
|
||||||
|
if c.conn == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
return nil, fmt.Errorf("gateway: not connected")
|
||||||
|
}
|
||||||
|
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", reqID)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
return nil, fmt.Errorf("request %s timed out", reqID)
|
||||||
|
}
|
||||||
|
}
|
||||||
484
go-backend/internal/gateway/wsclient_test.go
Normal file
484
go-backend/internal/gateway/wsclient_test.go
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Mock WebSocket server helper ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// newTestWSServer creates an httptest.Server that upgrades to WebSocket and
|
||||||
|
// delegates each connection to handler. The server URL can be converted to
|
||||||
|
// a ws:// URL by replacing "http" with "ws".
|
||||||
|
func newTestWSServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler(conn)
|
||||||
|
}))
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsURL converts an httptest.Server http URL to a ws URL.
|
||||||
|
func wsURL(srv *httptest.Server) string {
|
||||||
|
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handshake helper for mock server ─────────────────────────────────────
|
||||||
|
|
||||||
|
// handleHandshake performs the server side of the v3 handshake:
|
||||||
|
// 1. Send connect.challenge
|
||||||
|
// 2. Read connect request
|
||||||
|
// 3. Send hello-ok response
|
||||||
|
//
|
||||||
|
// Returns the connect request frame for inspection.
|
||||||
|
func handleHandshake(t *testing.T, conn *websocket.Conn) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// 1. Send connect.challenge
|
||||||
|
challenge := map[string]any{
|
||||||
|
"type": "event",
|
||||||
|
"event": "connect.challenge",
|
||||||
|
"params": map[string]any{"nonce": "test-nonce", "ts": 1716180000000},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(challenge); err != nil {
|
||||||
|
t.Fatalf("server: write challenge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read connect request
|
||||||
|
var req map[string]any
|
||||||
|
if err := conn.ReadJSON(&req); err != nil {
|
||||||
|
t.Fatalf("server: read connect request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req["method"] != "connect" {
|
||||||
|
t.Fatalf("server: expected method=connect, got %v", req["method"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send hello-ok response
|
||||||
|
// Note: helloOKResponse expects ConnID at the top level of the result,
|
||||||
|
// matching the WSClient's JSON struct tags.
|
||||||
|
result := map[string]any{
|
||||||
|
"type": "hello-ok",
|
||||||
|
"protocol": 3,
|
||||||
|
"connId": "test-conn-123",
|
||||||
|
"features": map[string]any{"methods": []string{}, "events": []string{}},
|
||||||
|
"auth": map[string]any{"role": "operator", "scopes": []string{"operator.read"}},
|
||||||
|
}
|
||||||
|
res := map[string]any{
|
||||||
|
"type": "res",
|
||||||
|
"id": req["id"],
|
||||||
|
"ok": true,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(res); err != nil {
|
||||||
|
t.Fatalf("server: write hello-ok: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepAlive reads frames from the connection until an error occurs
|
||||||
|
// (e.g., the client disconnects). Used as the default "do nothing"
|
||||||
|
// server loop after handshake.
|
||||||
|
func keepAlive(conn *websocket.Conn) {
|
||||||
|
for {
|
||||||
|
var m map[string]any
|
||||||
|
if err := conn.ReadJSON(&m); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Test: Full handshake ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWSClient_Handshake(t *testing.T) {
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
keepAlive(conn)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
client.Start(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait briefly for handshake to complete
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify connId was set
|
||||||
|
client.connMu.Lock()
|
||||||
|
connID := client.connId
|
||||||
|
client.connMu.Unlock()
|
||||||
|
|
||||||
|
if connID != "test-conn-123" {
|
||||||
|
t.Errorf("expected connId 'test-conn-123', got %q", connID)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Client exited cleanly
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("WSClient did not shut down after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Test: Send() with response matching ───────────────────────────────
|
||||||
|
|
||||||
|
func TestWSClient_Send(t *testing.T) {
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
|
||||||
|
// Read RPC requests and respond to each
|
||||||
|
for {
|
||||||
|
var req map[string]any
|
||||||
|
if err := conn.ReadJSON(&req); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
reqID, _ := req["id"].(string)
|
||||||
|
method, _ := req["method"].(string)
|
||||||
|
|
||||||
|
var result any
|
||||||
|
switch method {
|
||||||
|
case "agents.list":
|
||||||
|
result = map[string]any{
|
||||||
|
"agents": []map[string]any{
|
||||||
|
{"id": "otto", "name": "Otto"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := map[string]any{
|
||||||
|
"type": "res",
|
||||||
|
"id": reqID,
|
||||||
|
"ok": true,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(res); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go client.Start(ctx)
|
||||||
|
|
||||||
|
// Give the client time to complete handshake
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
resp, err := client.Send("agents.list", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Send() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the response payload
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(resp, &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agents, ok := result["agents"].([]any)
|
||||||
|
if !ok || len(agents) != 1 {
|
||||||
|
t.Errorf("expected 1 agent in response, got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Test: Event handler routing ───────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWSClient_EventRouting(t *testing.T) {
|
||||||
|
eventReceived := make(chan json.RawMessage, 1)
|
||||||
|
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
|
||||||
|
// After handshake, send a test event
|
||||||
|
evt := map[string]any{
|
||||||
|
"type": "event",
|
||||||
|
"event": "test.event",
|
||||||
|
"params": map[string]any{"greeting": "hello from server"},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(evt); err != nil {
|
||||||
|
t.Logf("server: write event: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keepAlive(conn)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
||||||
|
|
||||||
|
// Register event handler BEFORE starting the client
|
||||||
|
client.OnEvent("test.event", func(payload json.RawMessage) {
|
||||||
|
eventReceived <- payload
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go client.Start(ctx)
|
||||||
|
|
||||||
|
// Wait for the event handler to fire
|
||||||
|
select {
|
||||||
|
case payload := <-eventReceived:
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal event payload: %v", err)
|
||||||
|
}
|
||||||
|
if greeting, _ := data["greeting"].(string); greeting != "hello from server" {
|
||||||
|
t.Errorf("expected greeting 'hello from server', got %q", greeting)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for event handler to fire")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Test: Concurrent Send ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWSClient_ConcurrentSend(t *testing.T) {
|
||||||
|
var reqCount atomic.Int32
|
||||||
|
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
|
||||||
|
// Read RPC requests and respond to each
|
||||||
|
for {
|
||||||
|
var req map[string]any
|
||||||
|
if err := conn.ReadJSON(&req); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
reqID, _ := req["id"].(string)
|
||||||
|
n := reqCount.Add(1)
|
||||||
|
|
||||||
|
res := map[string]any{
|
||||||
|
"type": "res",
|
||||||
|
"id": reqID,
|
||||||
|
"ok": true,
|
||||||
|
"result": map[string]any{"index": n, "method": req["method"]},
|
||||||
|
}
|
||||||
|
if err := conn.WriteJSON(res); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go client.Start(ctx)
|
||||||
|
|
||||||
|
// Give the client time to complete handshake
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Fire 3 concurrent Send() calls
|
||||||
|
type sendResult struct {
|
||||||
|
method string
|
||||||
|
payload json.RawMessage
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
results := make(chan sendResult, 3)
|
||||||
|
|
||||||
|
methods := []string{"agents.list", "sessions.list", "agents.config"}
|
||||||
|
for _, method := range methods {
|
||||||
|
go func(m string) {
|
||||||
|
resp, err := client.Send(m, nil)
|
||||||
|
results <- sendResult{method: m, payload: resp, err: err}
|
||||||
|
}(method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all results
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
select {
|
||||||
|
case r := <-results:
|
||||||
|
if r.err != nil {
|
||||||
|
t.Errorf("Send(%q) returned error: %v", r.method, r.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(r.payload, &result); err != nil {
|
||||||
|
t.Errorf("Send(%q) unmarshal error: %v", r.method, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gotMethod, _ := result["method"].(string)
|
||||||
|
if gotMethod != r.method {
|
||||||
|
t.Errorf("Send(%q) got response for %q (mismatched)", r.method, gotMethod)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for concurrent Send results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Test: Clean shutdown ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWSClient_CleanShutdown(t *testing.T) {
|
||||||
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
||||||
|
handleHandshake(t, conn)
|
||||||
|
keepAlive(conn)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
client.Start(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Let the client connect and complete handshake
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Cancel context — should trigger clean shutdown
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Client exited cleanly — pass
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("WSClient did not shut down cleanly within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure utility tests (from CUB-205) ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestMapSessionStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected models.AgentStatus
|
||||||
|
}{
|
||||||
|
{"running", models.AgentStatusActive},
|
||||||
|
{"streaming", models.AgentStatusActive},
|
||||||
|
{"done", models.AgentStatusIdle},
|
||||||
|
{"error", models.AgentStatusError},
|
||||||
|
{"", models.AgentStatusIdle},
|
||||||
|
{"garbage", models.AgentStatusIdle},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := mapSessionStatus(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("mapSessionStatus(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentItemToCard(t *testing.T) {
|
||||||
|
t.Run("full fields", func(t *testing.T) {
|
||||||
|
item := agentListItem{
|
||||||
|
ID: "dex",
|
||||||
|
Name: "Dex",
|
||||||
|
Role: "backend",
|
||||||
|
Channel: "telegram",
|
||||||
|
}
|
||||||
|
card := agentItemToCard(item)
|
||||||
|
if card.ID != "dex" {
|
||||||
|
t.Errorf("ID = %q, want %q", card.ID, "dex")
|
||||||
|
}
|
||||||
|
if card.DisplayName != "Dex" {
|
||||||
|
t.Errorf("DisplayName = %q, want %q", card.DisplayName, "Dex")
|
||||||
|
}
|
||||||
|
if card.Role != "backend" {
|
||||||
|
t.Errorf("Role = %q, want %q", card.Role, "backend")
|
||||||
|
}
|
||||||
|
if card.Channel != "telegram" {
|
||||||
|
t.Errorf("Channel = %q, want %q", card.Channel, "telegram")
|
||||||
|
}
|
||||||
|
if card.Status != models.AgentStatusIdle {
|
||||||
|
t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty fields use defaults", func(t *testing.T) {
|
||||||
|
item := agentListItem{
|
||||||
|
ID: "otto",
|
||||||
|
}
|
||||||
|
card := agentItemToCard(item)
|
||||||
|
if card.ID != "otto" {
|
||||||
|
t.Errorf("ID = %q, want %q", card.ID, "otto")
|
||||||
|
}
|
||||||
|
if card.DisplayName != "otto" {
|
||||||
|
t.Errorf("DisplayName = %q, want %q (should fallback to ID)", card.DisplayName, "otto")
|
||||||
|
}
|
||||||
|
if card.Role != "agent" {
|
||||||
|
t.Errorf("Role = %q, want %q (default)", card.Role, "agent")
|
||||||
|
}
|
||||||
|
if card.Channel != "unknown" {
|
||||||
|
t.Errorf("Channel = %q, want %q (per Grimm requirement)", card.Channel, "unknown")
|
||||||
|
}
|
||||||
|
if card.Status != models.AgentStatusIdle {
|
||||||
|
t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty name falls back to ID", func(t *testing.T) {
|
||||||
|
item := agentListItem{
|
||||||
|
ID: "hex",
|
||||||
|
Name: "",
|
||||||
|
Role: "database",
|
||||||
|
}
|
||||||
|
card := agentItemToCard(item)
|
||||||
|
if card.DisplayName != "hex" {
|
||||||
|
t.Errorf("DisplayName = %q, want %q (ID fallback)", card.DisplayName, "hex")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrPtr(t *testing.T) {
|
||||||
|
s := "hello"
|
||||||
|
p := strPtr(s)
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("strPtr returned nil")
|
||||||
|
}
|
||||||
|
if *p != s {
|
||||||
|
t.Errorf("strPtr(%q) = %q, want %q", s, *p, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := ""
|
||||||
|
ep := strPtr(empty)
|
||||||
|
if *ep != empty {
|
||||||
|
t.Errorf("strPtr(empty) = %q, want %q", *ep, empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
235
go-backend/internal/handler/mock_repos_test.go
Normal file
235
go-backend/internal/handler/mock_repos_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
125
go-backend/internal/handler/sse.go
Normal file
125
go-backend/internal/handler/sse.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -63,12 +63,15 @@ type CreateAgentRequest struct {
|
|||||||
|
|
||||||
// UpdateAgentRequest is the payload for PUT /api/agents/{id}.
|
// UpdateAgentRequest is the payload for PUT /api/agents/{id}.
|
||||||
type UpdateAgentRequest struct {
|
type UpdateAgentRequest struct {
|
||||||
Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"`
|
Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"`
|
||||||
CurrentTask *string `json:"currentTask,omitempty"`
|
DisplayName *string `json:"displayName,omitempty"`
|
||||||
TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"`
|
Role *string `json:"role,omitempty"`
|
||||||
TaskElapsed *string `json:"taskElapsed,omitempty"`
|
LastActivityAt *string `json:"lastActivityAt,omitempty"`
|
||||||
Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"`
|
CurrentTask *string `json:"currentTask,omitempty"`
|
||||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"`
|
||||||
|
TaskElapsed *string `json:"taskElapsed,omitempty"`
|
||||||
|
Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"`
|
||||||
|
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentStatusHistoryEntry represents a point-in-time status change for an agent.
|
// AgentStatusHistoryEntry represents a point-in-time status change for an agent.
|
||||||
|
|||||||
186
go-backend/internal/repository/agent_repository.go
Normal file
186
go-backend/internal/repository/agent_repository.go
Normal 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
|
||||||
|
}
|
||||||
38
go-backend/internal/repository/interfaces.go
Normal file
38
go-backend/internal/repository/interfaces.go
Normal 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)
|
||||||
|
}
|
||||||
94
go-backend/internal/repository/project_repository.go
Normal file
94
go-backend/internal/repository/project_repository.go
Normal 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
|
||||||
|
}
|
||||||
78
go-backend/internal/repository/session_repository.go
Normal file
78
go-backend/internal/repository/session_repository.go
Normal 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
|
||||||
|
}
|
||||||
85
go-backend/internal/repository/task_repository.go
Normal file
85
go-backend/internal/repository/task_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
42
kiosk/control-center-kiosk.service
Normal file
42
kiosk/control-center-kiosk.service
Normal 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
88
kiosk/start-kiosk.sh
Executable 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 "$@"
|
||||||
46
reference/CONTROL_CENTER_CONTEXT.md
Normal file
46
reference/CONTROL_CENTER_CONTEXT.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Control Center — Architecture Context
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The Control Center backend uses a **dual-path gateway client** architecture:
|
||||||
|
|
||||||
|
- **Primary path**: WebSocket client (`gateway.WSClient`) connects to the OpenClaw gateway using WS protocol v3. It handles handshake, initial sync (agents.list + sessions.list RPCs), live event routing (sessions.changed, presence, agent.config), and automatic reconnection with exponential backoff.
|
||||||
|
- **Fallback path**: REST poller (`gateway.Client`) polls the gateway `/api/agents` endpoint on an interval. It only activates if the WS client fails to connect within 30 seconds of startup.
|
||||||
|
|
||||||
|
## Live Gateway Connection
|
||||||
|
|
||||||
|
### Startup Sequence
|
||||||
|
1. Both WS client and REST client start concurrently
|
||||||
|
2. REST client waits 30s for WS readiness signal (`wsReady` channel)
|
||||||
|
3. If WS connects successfully → REST client stands down (logs "using WS — REST poller standing down")
|
||||||
|
4. If WS fails within 30s → REST client falls back to polling (logs "WS not ready — falling back to REST polling")
|
||||||
|
5. If no WS client configured → REST client polls immediately
|
||||||
|
|
||||||
|
### WebSocket Client (Primary)
|
||||||
|
- Config: `WS_GATEWAY_URL` (default: `ws://host.docker.internal:18789/`), `OPENCLAW_GATEWAY_TOKEN`
|
||||||
|
- Protocol: v3 handshake (challenge → connect → hello-ok)
|
||||||
|
- Initial sync: `agents.list` + `sessions.list` RPCs → persist → merge → broadcast `fleet.update`
|
||||||
|
- Live events: `sessions.changed`, `presence`, `agent.config`
|
||||||
|
- Reconnection: exponential backoff (1s → 2s → 4s → ... → 30s max)
|
||||||
|
|
||||||
|
### REST Poller (Fallback)
|
||||||
|
- Config: `GATEWAY_URL` (default: `http://host.docker.internal:18789/api/agents`), `GATEWAY_POLL_INTERVAL` (default: 5s)
|
||||||
|
- Only used when WS is unavailable
|
||||||
|
- Polls the `/api/agents` endpoint and syncs agent status changes
|
||||||
|
|
||||||
|
### Wiring
|
||||||
|
```
|
||||||
|
main.go
|
||||||
|
├── wsClient = NewWSClient(...)
|
||||||
|
├── restClient = NewClient(...)
|
||||||
|
├── wsClient.SetRESTClient(restClient) // WS notifies REST on ready
|
||||||
|
├── restClient.SetWSClient(wsClient) // REST defers to WS
|
||||||
|
├── go wsClient.Start(ctx) // primary
|
||||||
|
└── go restClient.Start(ctx) // fallback (waits for WS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
- **Push over poll**: WS is preferred for real-time updates; REST is a safety net
|
||||||
|
- **DB first, then SSE**: All event handlers persist to DB before broadcasting
|
||||||
|
- **Graceful degradation**: System works without WS; REST provides basic functionality
|
||||||
|
- **No hard dependency on REST /api/agents**: If WS is connected, REST endpoint is never called
|
||||||
Reference in New Issue
Block a user