Compare commits
106 Commits
a8b8fb7748
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f3ce08497a | |||
| fd60b0bb57 | |||
| b7b05bb4e3 | |||
| d370d5ec23 | |||
| 1b82e1d3a6 | |||
| 93bf434a47 | |||
| 010408cc45 | |||
| 23f9d4a8fb | |||
| 3d5bf16d37 | |||
| d9a1640b10 | |||
| 5347944c4c | |||
| 48a8598d3b | |||
| a0eb393c6c | |||
| d294818581 | |||
| 9e0366e780 | |||
| 20404b30bb | |||
| b7a54c8461 | |||
| b6e44cb4f8 | |||
| 49b959aee5 | |||
| ae37d79aa8 | |||
| 8fb4183abe | |||
| 6fd2d9bec4 | |||
| ee6ad10db9 | |||
| 5f42a3be18 | |||
| 0e452941dd | |||
| 87cb517623 | |||
| 439741e55f | |||
| 3c26b8deba | |||
| 4569fef11d | |||
| 7a93d43b7e | |||
| d28d6e8dac | |||
| efcedde649 | |||
| 0ac4898027 | |||
| e131798f3b | |||
| 9062f8fa8d | |||
| 60ba3e5b4f | |||
| 70d39b87d1 | |||
| 519e872027 | |||
| 2b4b9b3e96 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| e8ced74429 | |||
| 8b8cb8210c | |||
| 4a2e660a4a | |||
| 07d40d729f | |||
| 8da593c450 | |||
| 437a519c36 | |||
| c906cd46ad | |||
| cce3e061a7 | |||
| ab19a7ccde | |||
| 745994182f | |||
| 1775c25b61 | |||
| 999f6614ce | |||
| 048101e85c | |||
| dcfa4dc2a2 | |||
| 679d65ccea | |||
| e84a479e33 | |||
| 7223a2745f | |||
| 8331468b44 | |||
| e5d9b7ea07 | |||
| 0108d8aca0 | |||
| 27d877db6c | |||
| 29dd9321f8 | |||
| 64adffa0b4 | |||
| 3a0efaa5a4 | |||
| 2a99ace9f8 | |||
| 0ddffaf266 | |||
| 55fd2cd0d2 | |||
| 44b4758747 | |||
| 45b9068acc | |||
| e39fd45018 | |||
| 5fd37b556a | |||
| a2567dd3aa | |||
| 512a3364cf | |||
| bca3bf7677 | |||
| 1c012de47b | |||
| ea603c3552 | |||
| bcaf85c369 | |||
| 84e3d5420e | |||
| 8bdbcae13a | |||
| db91c8bde9 | |||
| e2582569b0 | |||
| 53454e0635 | |||
| d06caeab8e | |||
| 49a9a95086 | |||
| 2a21cad431 | |||
| 5375d11792 | |||
| ed1ee886db | |||
| c8ca182af0 | |||
| fb88eab4d1 | |||
| 82c12554d0 | |||
| f170def0ea | |||
| 040d4cb54d | |||
| 47cbeed456 | |||
| d2da0c160f | |||
| bcaa526a69 | |||
| 14b3dab88b | |||
| b4e110f4c3 | |||
| d5a85c4ed0 | |||
| 8d0adeb2e9 | |||
| a79ab6dbd5 | |||
| 6668da04d4 | |||
| 6cf8d7fe5f | |||
| 7b7b070dac | |||
| 69df1562c7 | |||
| 1c5d982cd9 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Dev Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.23"
|
||||||
|
NODE_VERSION: "22"
|
||||||
|
REGISTRY: code.cubecraftcreations.com
|
||||||
|
BACKEND_IMAGE: ${{ gitea.repository }}/backend
|
||||||
|
FRONTEND_IMAGE: ${{ gitea.repository }}/frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz
|
||||||
|
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | sudo tar -C /usr/local --strip-components=1 -xJ
|
||||||
|
echo "/usr/local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: go test ./...
|
||||||
|
working-directory: ./go-backend
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server
|
||||||
|
working-directory: ./go-backend
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
- name: Lint frontend
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
docker-build-push:
|
||||||
|
needs: test-and-build
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build & push backend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./go-backend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:dev
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push frontend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:dev
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ gitea.sha }}
|
||||||
@@ -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 |
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Control Center
|
||||||
|
|
||||||
|
> Real-time agent fleet dashboard for the OpenClaw AI development team.
|
||||||
|
|
||||||
|
Control Center monitors and controls the full CubeCraft Creations AI agent fleet — Otto, Rex, Dex, Hex, Pip, Nano, Sketch, Bob, Stuart, Norbert, and Flip. It provides live agent status, task progress, session logs, and a command interface, backed by SignalR for push-based updates directly from the OpenClaw gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | ASP.NET Core Web API (.NET 8) |
|
||||||
|
| Database | PostgreSQL (snake_case via EF Core) |
|
||||||
|
| ORM | Entity Framework Core |
|
||||||
|
| Real-time | SignalR (`AgentStatusHub`) |
|
||||||
|
| Frontend | Angular (latest), Angular Material, Angular Signals |
|
||||||
|
| API Client | TypeScript package (`api-client/`) |
|
||||||
|
| Deployment | Docker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Control-Center/
|
||||||
|
├── backend/
|
||||||
|
│ ├── ControlCenter/
|
||||||
|
│ │ ├── Controllers/ # AgentsController, CommandController, LogsController
|
||||||
|
│ │ ├── Hubs/ # AgentStatusHub, IAgentStatusClient, AgentStatusModels
|
||||||
|
│ │ ├── Models/ # AgentMinionMapping
|
||||||
|
│ │ ├── Services/ # AgentMinionMapperService, GatewayEventBridgeService
|
||||||
|
│ │ ├── Program.cs
|
||||||
|
│ │ └── appsettings.json
|
||||||
|
│ ├── Configurations/ # AgentConfiguration
|
||||||
|
│ ├── Data/ # AppDbContext, AppDbContextFactory
|
||||||
|
│ ├── Dtos/ # AgentStatusUpdateDto
|
||||||
|
│ ├── Entities/ # Agent, AgentStatus
|
||||||
|
│ ├── Migrations/ # EF migrations
|
||||||
|
│ ├── Models/ # AgentState
|
||||||
|
│ └── Repositories/ # AgentStateRepository, IAgentStateRepository
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/app/
|
||||||
|
│ ├── layout/ # LayoutShell, HeaderBar, NavRail, BottomNav
|
||||||
|
│ ├── pages/ # hub, logs, projects, sessions, settings
|
||||||
|
│ ├── components/ # AgentCard, AgentStatusBadge, TaskProgressBar,
|
||||||
|
│ │ # QuickJumpButton, QuickJumpDrawer,
|
||||||
|
│ │ # GlobalActionModal, AdaptiveNavigation
|
||||||
|
│ ├── command-hub/ # AgentCard command hub view
|
||||||
|
│ ├── models/ # Agent model, AgentStatus, AgentCardData, Nav
|
||||||
|
│ └── services/ # AgentStatusService (Angular Signals)
|
||||||
|
├── api-client/ # Shared TS client (models, SignalR WS, HTTP utils)
|
||||||
|
├── design/
|
||||||
|
│ ├── command-hub-spec.md
|
||||||
|
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain Model
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `Id` | `Guid` | Primary key |
|
||||||
|
| `Status` | `AgentStatus` | Current agent state |
|
||||||
|
| `Task` | `string?` | Active task description |
|
||||||
|
| `Progress` | `int?` | Task completion % (0–100) |
|
||||||
|
| `SessionKey` | `string` | OpenClaw session identifier |
|
||||||
|
| `Channel` | `string` | Source channel (telegram, slack, etc.) |
|
||||||
|
| `LastActivity` | `DateTime` | Last event timestamp |
|
||||||
|
| `CreatedAt` / `UpdatedAt` | `DateTime` | Audit fields |
|
||||||
|
|
||||||
|
### AgentStatus enum
|
||||||
|
|
||||||
|
| Value | Integer | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `Active` | 0 | Agent is executing a task |
|
||||||
|
| `Idle` | 1 | Agent is waiting for work |
|
||||||
|
| `Thinking` | 2 | Agent is reasoning/planning |
|
||||||
|
| `Error` | 3 | Agent hit an unrecoverable state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Angular Signals** — Reactive state management. No NgRx — kept intentionally simple.
|
||||||
|
2. **Adaptive layout** — Sidebar nav (NavRail) on desktop/kiosk (≥768px); bottom nav on mobile.
|
||||||
|
3. **Tactical Dark Mode** — Theme via CSS custom properties in `styles.scss`.
|
||||||
|
4. **SignalR fleet group** — Clients call `JoinFleet()` to subscribe to all agent updates broadcast by the hub.
|
||||||
|
5. **Backend push model** — Hub uses `IHubContext<AgentStatusHub, IAgentStatusClient>` extension methods to push to clients; no polling.
|
||||||
|
6. **Gateway bridge** — `GatewayEventBridgeService` connects the OpenClaw gateway WebSocket to the SignalR hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- .NET 8 SDK
|
||||||
|
- Node.js 20+
|
||||||
|
- Docker
|
||||||
|
- PostgreSQL
|
||||||
|
- Running OpenClaw gateway (for live agent events)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Restore and build
|
||||||
|
dotnet restore
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
dotnet ef database update --project ControlCenter
|
||||||
|
|
||||||
|
# Run API
|
||||||
|
dotnet run --project ControlCenter
|
||||||
|
```
|
||||||
|
|
||||||
|
API runs at `http://localhost:5000` · Swagger at `http://localhost:5000/swagger`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend runs at `http://localhost:4200`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`backend/ControlCenter/appsettings.json` — override in `appsettings.Development.json` or environment variables:
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ConnectionStrings:DefaultConnection` | *(set in dev config)* | PostgreSQL connection string |
|
||||||
|
| `Gateway:WebSocketUrl` | `ws://localhost:3271/ws` | OpenClaw gateway WebSocket URL |
|
||||||
|
| `Gateway:AuthToken` | `""` | Gateway auth token |
|
||||||
|
| `Cors:AllowedOrigins` | `localhost:4200`, `localhost:5000` | Frontend origins for CORS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-Time Events
|
||||||
|
|
||||||
|
SignalR hub endpoint: `/hubs/agent-status`
|
||||||
|
|
||||||
|
### Hub Methods (server → client)
|
||||||
|
|
||||||
|
| Method | Payload | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ReceiveStatusUpdate` | `AgentStatusUpdateDto` | Agent status changed |
|
||||||
|
| `ReceiveAgentList` | `Agent[]` | Full fleet snapshot on join |
|
||||||
|
|
||||||
|
### Client → Server
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---|---|
|
||||||
|
| `JoinFleet()` | Subscribe to all agent updates |
|
||||||
|
| `LeaveFleet()` | Unsubscribe |
|
||||||
|
| `SendStatusUpdate(dto)` | Push a status update (internal use) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Page | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `/hub` | Command Hub | Live agent grid — status, task, progress |
|
||||||
|
| `/logs` | Logs | Agent session log viewer |
|
||||||
|
| `/projects` | Projects | Linear project tracking view |
|
||||||
|
| `/sessions` | Sessions | OpenClaw session browser |
|
||||||
|
| `/settings` | Settings | App configuration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Fleet
|
||||||
|
|
||||||
|
| Agent | Role |
|
||||||
|
|---|---|
|
||||||
|
| Otto | Orchestrator — owns the dev lifecycle |
|
||||||
|
| Rex | Frontend (Angular) |
|
||||||
|
| Dex | Backend (ASP.NET Core) |
|
||||||
|
| Hex | Database (schema, migrations) |
|
||||||
|
| Pip | Raspberry Pi / Python |
|
||||||
|
| Nano | Arduino / ESP32 / ESPHome |
|
||||||
|
| Sketch | UX/UI design |
|
||||||
|
| Flip | Mobile development |
|
||||||
|
| Bob | Content & marketing copy |
|
||||||
|
| Stuart | Concept visualization (images) |
|
||||||
|
| Norbert | 3D design (OpenSCAD) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch & PR Rules
|
||||||
|
|
||||||
|
- All feature branches target `dev` — **never `main`**
|
||||||
|
- Branch naming: `agent/<agent>/CUB-N-short-description`
|
||||||
|
- PR titles: `CUB-N: short description`
|
||||||
|
- PRs require Otto review before Joshua merges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
| Route prefix | Resource |
|
||||||
|
|---|---|
|
||||||
|
| `/api/agents` | Agent registry and status |
|
||||||
|
| `/api/command` | Issue commands to agents |
|
||||||
|
| `/api/logs` | Session log retrieval |
|
||||||
|
|
||||||
|
Full schema at `/swagger` when running in dev.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built by CubeCraft Creations · Orchestrated by Otto*
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
## .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
*.dll
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
## IDE
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
## OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
.env
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using ControlCenter.Api.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core entity type configuration for the agents table.
|
||||||
|
/// Enforces snake_case naming, required fields, and index design.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentConfiguration : IEntityTypeConfiguration<Agent>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Agent> builder)
|
||||||
|
{
|
||||||
|
// Table name — snake_case
|
||||||
|
builder.ToTable("agents");
|
||||||
|
|
||||||
|
// Primary key
|
||||||
|
builder.HasKey(a => a.Id);
|
||||||
|
builder.Property(a => a.Id)
|
||||||
|
.HasColumnName("id")
|
||||||
|
.ValueGeneratedOnAdd();
|
||||||
|
|
||||||
|
// Status — stored as PostgreSQL enum via Npgsql
|
||||||
|
builder.Property(a => a.Status)
|
||||||
|
.HasColumnName("status")
|
||||||
|
.HasColumnType("agent_status")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Task — nullable text
|
||||||
|
builder.Property(a => a.Task)
|
||||||
|
.HasColumnName("task")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
// Progress — nullable integer (0–100)
|
||||||
|
builder.Property(a => a.Progress)
|
||||||
|
.HasColumnName("progress");
|
||||||
|
|
||||||
|
// Session key — required, not null
|
||||||
|
builder.Property(a => a.SessionKey)
|
||||||
|
.HasColumnName("session_key")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Channel — required, not null
|
||||||
|
builder.Property(a => a.Channel)
|
||||||
|
.HasColumnName("channel")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Last activity — required, defaults to now()
|
||||||
|
builder.Property(a => a.LastActivity)
|
||||||
|
.HasColumnName("last_activity")
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Created at — auto-set on insert
|
||||||
|
builder.Property(a => a.CreatedAt)
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.IsRequired()
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
// Updated at — auto-set on insert and update
|
||||||
|
builder.Property(a => a.UpdatedAt)
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.IsRequired()
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Sessions are looked up by session_key frequently
|
||||||
|
builder.HasIndex(a => a.SessionKey)
|
||||||
|
.HasDatabaseName("ix_agents_session_key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
// Agents are filtered by channel for channel-specific queries
|
||||||
|
builder.HasIndex(a => a.Channel)
|
||||||
|
.HasDatabaseName("ix_agents_channel");
|
||||||
|
|
||||||
|
// Agents are filtered by status for fleet health monitoring
|
||||||
|
builder.HasIndex(a => a.Status)
|
||||||
|
.HasDatabaseName("ix_agents_status");
|
||||||
|
|
||||||
|
// Check constraint: progress must be 0–100 if present
|
||||||
|
builder.ToTable(t => t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Exclude the separate ControlCenter sub-project from this project's compilation -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="ControlCenter/**/*.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@ControlCenter.Api_HostAddress = http://localhost:5178
|
||||||
|
|
||||||
|
GET {{ControlCenter.Api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.cache
|
||||||
|
*.dll
|
||||||
|
*.pdb
|
||||||
|
*.xml
|
||||||
|
|
||||||
|
## IDE
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
## OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>CS1591</NoWarn>
|
||||||
|
<RootNamespace>ControlCenter</RootNamespace>
|
||||||
|
<AssemblyName>ControlCenter</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ControlCenter.Services;
|
||||||
|
|
||||||
|
namespace ControlCenter.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST API for querying agent fleet status.
|
||||||
|
/// Provides the initial data load for the Command Hub,
|
||||||
|
/// while real-time updates flow through the AgentStatus SignalR hub.
|
||||||
|
///
|
||||||
|
/// <para>API contract for Rex (Frontend):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>GET /api/agents</c> — Returns all known agents with current status</item>
|
||||||
|
/// <item><c>GET /api/agents/{agentId}</c> — Returns a specific agent's status</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AgentsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<AgentsController> _logger;
|
||||||
|
private readonly GatewayEventBridgeService _bridgeService;
|
||||||
|
|
||||||
|
public AgentsController(
|
||||||
|
ILogger<AgentsController> logger,
|
||||||
|
GatewayEventBridgeService bridgeService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_bridgeService = bridgeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current fleet status — all known agents with their latest state.
|
||||||
|
/// This is the initial load endpoint; subsequent updates arrive via SignalR.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An array of agent card data for the entire fleet.</returns>
|
||||||
|
/// <response code="200">Returns the fleet snapshot.</response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(AgentCardData[]), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetAgents()
|
||||||
|
{
|
||||||
|
var snapshot = _bridgeService.GetFleetSnapshot();
|
||||||
|
_logger.LogDebug("Fleet snapshot requested: {Count} agents", snapshot.Length);
|
||||||
|
return Ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current status of a specific agent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||||
|
/// <returns>The agent's current card data.</returns>
|
||||||
|
/// <response code="200">Returns the agent's status.</response>
|
||||||
|
/// <response code="404">Agent not found in the fleet state.</response>
|
||||||
|
[HttpGet("{agentId}")]
|
||||||
|
[ProducesResponseType(typeof(AgentCardData), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult GetAgent(string agentId)
|
||||||
|
{
|
||||||
|
var snapshot = _bridgeService.GetFleetSnapshot();
|
||||||
|
var agent = snapshot.FirstOrDefault(a =>
|
||||||
|
a.Id.Equals(agentId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (agent is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Agent not found: {AgentId}", agentId);
|
||||||
|
return NotFound(new { error = $"Agent '{agentId}' not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ControlCenter.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST API for sending control commands to agents.
|
||||||
|
/// Provides the Command Hub's action endpoints for agent lifecycle control.
|
||||||
|
///
|
||||||
|
/// <para>API contract for Rex (Frontend):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>POST /api/command/stop/{agentId}</c> — Stop/abort an agent's active session</item>
|
||||||
|
/// <item><c>POST /api/command/restart/{agentId}</c> — Restart an agent</item>
|
||||||
|
/// <item><c>POST /api/command/steer/{agentId}</c> — Inject a message into an agent's session</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Commands are forwarded to the OpenClaw Gateway via the
|
||||||
|
/// WebSocket bridge service. The Gateway handles the actual execution.</para>
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class CommandController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<CommandController> _logger;
|
||||||
|
|
||||||
|
public CommandController(ILogger<CommandController> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops (aborts) an agent's active session.
|
||||||
|
/// Sends an abort command to the OpenClaw Gateway.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier to stop.</param>
|
||||||
|
/// <returns>Confirmation of the stop command.</returns>
|
||||||
|
/// <response code="200">Stop command sent successfully.</response>
|
||||||
|
/// <response code="404">No active session found for the agent.</response>
|
||||||
|
[HttpPost("stop/{agentId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult StopAgent(string agentId)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stop command received for agent {AgentId}", agentId);
|
||||||
|
|
||||||
|
// TODO: Forward to Gateway via bridge service
|
||||||
|
// await _bridgeService.SendRpcAsync("sessions.abort", new { sessionKey = ... });
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
command = "stop",
|
||||||
|
status = "sent",
|
||||||
|
timestamp = DateTime.UtcNow.ToString("o")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restarts an agent by aborting the current session and allowing
|
||||||
|
/// a new one to start on the next incoming message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier to restart.</param>
|
||||||
|
/// <returns>Confirmation of the restart command.</returns>
|
||||||
|
/// <response code="200">Restart command sent successfully.</response>
|
||||||
|
[HttpPost("restart/{agentId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult RestartAgent(string agentId)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Restart command received for agent {AgentId}", agentId);
|
||||||
|
|
||||||
|
// TODO: Forward to Gateway — abort current session + signal restart
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
command = "restart",
|
||||||
|
status = "sent",
|
||||||
|
timestamp = DateTime.UtcNow.ToString("o")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Steers (injects a message into) an agent's active session.
|
||||||
|
/// Used by operators to redirect an agent's task mid-execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier to steer.</param>
|
||||||
|
/// <param name="request">The steering message to inject.</param>
|
||||||
|
/// <returns>Confirmation of the steer command.</returns>
|
||||||
|
/// <response code="200">Steer command sent successfully.</response>
|
||||||
|
/// <response code="400">Missing or empty message.</response>
|
||||||
|
[HttpPost("steer/{agentId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult SteerAgent(string agentId, [FromBody] SteerRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Message is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Steer command received for agent {AgentId}: {Message}",
|
||||||
|
agentId, request.Message.Length > 100
|
||||||
|
? request.Message[..100] + "..." : request.Message);
|
||||||
|
|
||||||
|
// TODO: Forward to Gateway via bridge service
|
||||||
|
// await _bridgeService.SendRpcAsync("sessions.steer", new { sessionKey = ..., message = request.Message });
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
command = "steer",
|
||||||
|
message = request.Message,
|
||||||
|
status = "sent",
|
||||||
|
timestamp = DateTime.UtcNow.ToString("o")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the steer command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Message">The message to inject into the agent's session.</param>
|
||||||
|
public record SteerRequest(string Message);
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ControlCenter.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST API for querying agent session logs.
|
||||||
|
/// Provides historical message and tool call logs for a specific agent.
|
||||||
|
///
|
||||||
|
/// <para>API contract for Rex (Frontend):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>GET /api/logs/{agentId}</c> — Returns recent logs for an agent</item>
|
||||||
|
/// <item><c>GET /api/logs/{agentId}/tools</c> — Returns recent tool calls for an agent</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Log data is sourced from the OpenClaw Gateway's transcript files.
|
||||||
|
/// The Gateway's <c>logs.tail</c> RPC provides the raw data, and this
|
||||||
|
/// controller formats it for the frontend.</para>
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class LogsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<LogsController> _logger;
|
||||||
|
|
||||||
|
public LogsController(ILogger<LogsController> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets recent session logs for a specific agent.
|
||||||
|
/// Returns the last N messages from the agent's active session transcript.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||||
|
/// <param name="limit">Maximum number of log entries to return (default: 50, max: 200).</param>
|
||||||
|
/// <returns>An array of log entries for the agent.</returns>
|
||||||
|
/// <response code="200">Returns the agent's recent logs.</response>
|
||||||
|
/// <response code="404">No active session found for the agent.</response>
|
||||||
|
[HttpGet("{agentId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult GetLogs(string agentId, [FromQuery] int limit = 50)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 200);
|
||||||
|
|
||||||
|
_logger.LogDebug("Logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
|
||||||
|
|
||||||
|
// TODO: Implement log retrieval by calling the Gateway's logs.tail RPC
|
||||||
|
// or reading transcript files. For now, return an empty array as the
|
||||||
|
// bridge service will provide this data when fully integrated.
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
logs = Array.Empty<object>(),
|
||||||
|
count = 0,
|
||||||
|
hasMore = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets recent tool call logs for a specific agent.
|
||||||
|
/// Returns the last N tool invocations from the agent's session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier.</param>
|
||||||
|
/// <param name="limit">Maximum number of tool entries to return (default: 20, max: 100).</param>
|
||||||
|
/// <returns>An array of tool call entries for the agent.</returns>
|
||||||
|
/// <response code="200">Returns the agent's recent tool calls.</response>
|
||||||
|
/// <response code="404">No active session found for the agent.</response>
|
||||||
|
[HttpGet("{agentId}/tools")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult GetToolLogs(string agentId, [FromQuery] int limit = 20)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
|
||||||
|
_logger.LogDebug("Tool logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
|
||||||
|
|
||||||
|
// TODO: Implement tool log retrieval. Return empty for now.
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
tools = Array.Empty<object>(),
|
||||||
|
count = 0,
|
||||||
|
hasMore = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ControlCenter.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub for real-time agent status updates in the Command Hub.
|
||||||
|
///
|
||||||
|
/// <para>Usage flow:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Client connects to <c>/hubs/agent-status</c></item>
|
||||||
|
/// <item>Client calls <see cref="JoinFleet"/> to subscribe to all agent updates</item>
|
||||||
|
/// <item>Client calls <see cref="JoinAgentGroup"/> to subscribe to a specific agent</item>
|
||||||
|
/// <item>Server pushes <see cref="IAgentStatusClient.AgentStatusChanged"/>
|
||||||
|
/// and <see cref="IAgentStatusClient.AgentTaskProgress"/> events</item>
|
||||||
|
/// <item>Client calls <see cref="GetFleetSnapshot"/> for initial state on connect</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Group naming:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Fleet group: <c>fleet</c> — receives all agent updates</item>
|
||||||
|
/// <item>Agent group: <c>agent:{agentId}</c> — receives updates for one agent</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Typed client: <see cref="IAgentStatusClient"/> — all server-to-client
|
||||||
|
/// calls go through this interface for compile-time safety.</para>
|
||||||
|
///
|
||||||
|
/// <para>Architecture note: This hub bridges OpenClaw Gateway WebSocket events
|
||||||
|
/// to SignalR clients. A background service (<see cref="Services.GatewayEventBridgeService"/>)
|
||||||
|
/// subscribes to Gateway events and pushes them through this hub's extension methods.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class AgentStatusHub : Hub<IAgentStatusClient>
|
||||||
|
{
|
||||||
|
private readonly ILogger<AgentStatusHub> _logger;
|
||||||
|
|
||||||
|
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcasts an agent status update to all connected clients.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Any connected client (or server-side caller) can invoke this method
|
||||||
|
/// to push a status update to every subscriber. The DTO is converted to
|
||||||
|
/// an <see cref="AgentStatusUpdate"/> record and relayed through the
|
||||||
|
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="update">The agent status update DTO to broadcast.</param>
|
||||||
|
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Broadcasting status update for agent {AgentId}: {Status}",
|
||||||
|
update.AgentId, update.Status);
|
||||||
|
|
||||||
|
var agentUpdate = update.ToUpdate();
|
||||||
|
|
||||||
|
// Broadcast to all connected clients
|
||||||
|
await Clients.All.AgentStatusChanged(agentUpdate);
|
||||||
|
|
||||||
|
// Also push to the specific agent's group
|
||||||
|
var agentGroup = AgentGroupName(update.AgentId);
|
||||||
|
await Clients.Group(agentGroup).AgentStatusChanged(agentUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the calling connection to the fleet group.
|
||||||
|
/// Once joined, the client will receive all agent status changes
|
||||||
|
/// and task progress updates across the entire fleet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task JoinFleet()
|
||||||
|
{
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the calling connection from the fleet group.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LeaveFleet()
|
||||||
|
{
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the calling connection to a specific agent's group.
|
||||||
|
/// Once joined, the client will receive updates only for that agent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||||
|
/// <exception cref="HubException">Thrown if agentId is null or empty.</exception>
|
||||||
|
public async Task JoinAgentGroup(string agentId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(agentId))
|
||||||
|
throw new HubException("agentId is required");
|
||||||
|
|
||||||
|
var groupName = AgentGroupName(agentId);
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} joined agent group {GroupName}",
|
||||||
|
Context.ConnectionId, groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the calling connection from a specific agent's group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier.</param>
|
||||||
|
public async Task LeaveAgentGroup(string agentId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(agentId)) return;
|
||||||
|
|
||||||
|
var groupName = AgentGroupName(agentId);
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a snapshot of the current fleet state.
|
||||||
|
/// Called by clients on initial connection to get the full picture
|
||||||
|
/// before incremental updates begin arriving.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An array of <see cref="AgentCardData"/> representing all known agents.</returns>
|
||||||
|
public Task<AgentCardData[]> GetFleetSnapshot()
|
||||||
|
{
|
||||||
|
// The fleet state is managed by the GatewayEventBridgeService.
|
||||||
|
// For now, return an empty array — the bridge service will push
|
||||||
|
// updates as they arrive from the Gateway.
|
||||||
|
_logger.LogDebug("Fleet snapshot requested by {ConnectionId}", Context.ConnectionId);
|
||||||
|
return Task.FromResult(Array.Empty<AgentCardData>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
|
||||||
|
/// SignalR automatically removes disconnected connections from all groups.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||||
|
public override Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
||||||
|
return base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SignalR group name for the entire fleet (all agents).
|
||||||
|
/// </summary>
|
||||||
|
internal const string FleetGroupName = "fleet";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the SignalR group name for a specific agent.
|
||||||
|
/// Format: <c>agent:{agentId}</c> (lowercase for consistency).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier.</param>
|
||||||
|
internal static string AgentGroupName(string agentId) =>
|
||||||
|
$"agent:{agentId.ToLowerInvariant()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for pushing real-time agent updates through
|
||||||
|
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
||||||
|
///
|
||||||
|
/// <para>These methods are intended to be called from background services
|
||||||
|
/// (e.g., <see cref="Services.GatewayEventBridgeService"/>) or other
|
||||||
|
/// server-side code that detects an agent state change.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class AgentStatusHubExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes an agent status change to all clients subscribed to
|
||||||
|
/// the fleet group and the specific agent's group.
|
||||||
|
///
|
||||||
|
/// <para>Call this from any background service when an agent's
|
||||||
|
/// operational status changes (e.g., the Gateway reports a
|
||||||
|
/// session transition from "running" to "done").</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||||
|
/// <param name="update">The agent status update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||||
|
public static async Task PushAgentStatusAsync(
|
||||||
|
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||||
|
AgentStatusUpdate update)
|
||||||
|
{
|
||||||
|
// Broadcast to the fleet group (all subscribers)
|
||||||
|
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||||
|
.AgentStatusChanged(update);
|
||||||
|
|
||||||
|
// Also push to the specific agent's group
|
||||||
|
var agentGroup = AgentStatusHub.AgentGroupName(update.AgentId);
|
||||||
|
await hubContext.Clients.Group(agentGroup)
|
||||||
|
.AgentStatusChanged(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes a task progress update to all clients subscribed to
|
||||||
|
/// the fleet group and the specific agent's group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||||
|
/// <param name="progress">The task progress update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||||
|
public static async Task PushTaskProgressAsync(
|
||||||
|
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||||
|
TaskProgressUpdate progress)
|
||||||
|
{
|
||||||
|
// Broadcast to the fleet group
|
||||||
|
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||||
|
.AgentTaskProgress(progress);
|
||||||
|
|
||||||
|
// Also push to the specific agent's group
|
||||||
|
var agentGroup = AgentStatusHub.AgentGroupName(progress.AgentId);
|
||||||
|
await hubContext.Clients.Group(agentGroup)
|
||||||
|
.AgentTaskProgress(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ControlCenter.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed client interface for the AgentStatus SignalR hub.
|
||||||
|
/// Defines the methods the server can invoke on connected clients
|
||||||
|
/// to push real-time agent status and task progress updates.
|
||||||
|
///
|
||||||
|
/// <para>All server-to-client calls go through this interface for
|
||||||
|
/// compile-time safety — matching the pattern used by the
|
||||||
|
/// Extrudex PrinterHub.</para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IAgentStatusClient
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes an agent status change to all subscribed clients.
|
||||||
|
/// Fired whenever an agent's operational status changes
|
||||||
|
/// (e.g., idle → active, active → thinking, active → error).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="update">The full status update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||||
|
Task AgentStatusChanged(AgentStatusUpdate update);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes a task progress update to all subscribed clients.
|
||||||
|
/// Fired when an agent reports progress on its current task.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="progress">The task progress update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||||
|
Task AgentTaskProgress(TaskProgressUpdate progress);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
namespace ControlCenter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Agent operational status derived from OpenClaw Gateway session activity.
|
||||||
|
/// Maps to the frontend AgentStatus type: 'active' | 'idle' | 'thinking' | 'error'.
|
||||||
|
/// </summary>
|
||||||
|
public enum AgentStatus
|
||||||
|
{
|
||||||
|
/// <summary>Agent is currently processing a turn.</summary>
|
||||||
|
Active,
|
||||||
|
|
||||||
|
/// <summary>Agent completed its last turn; no active work.</summary>
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
/// <summary>LLM call in flight; tokens streaming.</summary>
|
||||||
|
Thinking,
|
||||||
|
|
||||||
|
/// <summary>Agent encountered an unhandled error.</summary>
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extended lifecycle status including offline — not all agents have active sessions.
|
||||||
|
/// Used internally; clients only see <see cref="AgentStatus"/> (offline maps to idle).
|
||||||
|
/// </summary>
|
||||||
|
public enum AgentLifecycleStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Idle,
|
||||||
|
Thinking,
|
||||||
|
Error,
|
||||||
|
Offline
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushed to SignalR clients when an agent's status changes.
|
||||||
|
/// Matches the TypeScript <c>AgentStatusUpdate</c> interface from the design spec.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AgentId">Agent identifier, e.g. "otto", "dex".</param>
|
||||||
|
/// <param name="DisplayName">Human-readable name, e.g. "Otto".</param>
|
||||||
|
/// <param name="Role">Role description, e.g. "Orchestrator Agent".</param>
|
||||||
|
/// <param name="Status">Current operational status.</param>
|
||||||
|
/// <param name="CurrentTask">Description of the current task, if any.</param>
|
||||||
|
/// <param name="SessionKey">Full session key, e.g. "agent:otto:telegram:direct:8787451565".</param>
|
||||||
|
/// <param name="Channel">Channel the agent is operating on, e.g. "telegram".</param>
|
||||||
|
/// <param name="LastActivity">ISO 8601 timestamp of last activity.</param>
|
||||||
|
/// <param name="ErrorMessage">Error message when status is 'error'.</param>
|
||||||
|
public record AgentStatusUpdate(
|
||||||
|
string AgentId,
|
||||||
|
string DisplayName,
|
||||||
|
string Role,
|
||||||
|
string Status,
|
||||||
|
string? CurrentTask,
|
||||||
|
string SessionKey,
|
||||||
|
string Channel,
|
||||||
|
string LastActivity,
|
||||||
|
string? ErrorMessage = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushed to SignalR clients when an agent's task progress updates.
|
||||||
|
/// Matches the TypeScript <c>TaskProgressUpdate</c> interface from the design spec.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AgentId">Agent identifier.</param>
|
||||||
|
/// <param name="TaskDescription">Description of the current task.</param>
|
||||||
|
/// <param name="Progress">Task progress percentage (0–100), if trackable.</param>
|
||||||
|
/// <param name="Elapsed">Elapsed time string, e.g. "04m 12s".</param>
|
||||||
|
public record TaskProgressUpdate(
|
||||||
|
string AgentId,
|
||||||
|
string TaskDescription,
|
||||||
|
int? Progress,
|
||||||
|
string? Elapsed
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data transfer object for broadcasting agent status updates
|
||||||
|
/// to all connected SignalR clients via the hub's SendStatusUpdate method.
|
||||||
|
///
|
||||||
|
/// <para>This DTO provides a mutable, serialization-friendly alternative to
|
||||||
|
/// <see cref="AgentStatusUpdate"/> for callers that construct updates
|
||||||
|
/// from external data sources (e.g., HTTP API payloads).</para>
|
||||||
|
/// </summary>
|
||||||
|
public class AgentStatusUpdateDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Agent identifier, e.g. "otto", "dex", "rex".
|
||||||
|
/// </summary>
|
||||||
|
public string AgentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable display name, e.g. "Otto", "Dex".
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
|
||||||
|
/// </summary>
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current operational status of the agent as lowercase string:
|
||||||
|
/// "active", "idle", "thinking", "error".
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Description of the agent's current task, if any.
|
||||||
|
/// </summary>
|
||||||
|
public string? CurrentTask { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||||
|
/// </summary>
|
||||||
|
public string SessionKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Communication channel, e.g. "telegram", "discord", "slack".
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISO 8601 timestamp of the agent's last activity.
|
||||||
|
/// </summary>
|
||||||
|
public string LastActivity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error message when the agent status is "error".
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts this DTO to an immutable <see cref="AgentStatusUpdate"/> record
|
||||||
|
/// for use with the typed SignalR client interface.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An <see cref="AgentStatusUpdate"/> with equivalent field values.</returns>
|
||||||
|
public AgentStatusUpdate ToUpdate() => new(
|
||||||
|
AgentId,
|
||||||
|
DisplayName,
|
||||||
|
Role,
|
||||||
|
Status,
|
||||||
|
CurrentTask,
|
||||||
|
SessionKey,
|
||||||
|
Channel,
|
||||||
|
LastActivity,
|
||||||
|
ErrorMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of an agent's full card data, sent on initial connection
|
||||||
|
/// or when the fleet state is requested.
|
||||||
|
/// Matches the TypeScript <c>AgentCardData</c> interface from the design spec.
|
||||||
|
/// </summary>
|
||||||
|
public record AgentCardData(
|
||||||
|
string Id,
|
||||||
|
string DisplayName,
|
||||||
|
string Role,
|
||||||
|
string Status,
|
||||||
|
string? CurrentTask,
|
||||||
|
int? TaskProgress,
|
||||||
|
string? TaskElapsed,
|
||||||
|
string SessionKey,
|
||||||
|
string Channel,
|
||||||
|
string LastActivity,
|
||||||
|
string? ErrorMessage
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace ControlCenter.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines which side of the Control Center dashboard a minion occupies.
|
||||||
|
/// </summary>
|
||||||
|
public enum MinionSide
|
||||||
|
{
|
||||||
|
/// <summary>Development side — Rex, Dex, Hex.</summary>
|
||||||
|
Dev,
|
||||||
|
|
||||||
|
/// <summary>Business side — Larry, Mel, Buzz.</summary>
|
||||||
|
Business
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visual state of a minion sprite, derived from the agent's
|
||||||
|
/// <see cref="AgentStatus"/>. Maps Active/Idle/Thinking/Error
|
||||||
|
/// to frontend animation states.
|
||||||
|
/// </summary>
|
||||||
|
public enum MinionState
|
||||||
|
{
|
||||||
|
/// <summary>Agent is actively processing — minion shows working animation.</summary>
|
||||||
|
Active,
|
||||||
|
|
||||||
|
/// <summary>Agent is idle — minion shows idle/patrolling animation.</summary>
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
/// <summary>Agent is thinking (LLM call in flight) — minion shows thinking animation.</summary>
|
||||||
|
Thinking,
|
||||||
|
|
||||||
|
/// <summary>Agent encountered an error — minion shows error/distress animation.</summary>
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static mapping entry that associates an agent ID with a minion's
|
||||||
|
/// display side and position index within that side.
|
||||||
|
///
|
||||||
|
/// <para>Position indices are zero-based within each side. The dev side
|
||||||
|
/// has Rex at 0, Dex at 1, and Hex at 2. The business side has
|
||||||
|
/// Larry at 0, Mel at 1, and Buzz at 2.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AgentId">Agent identifier, e.g. "rex", "dex".</param>
|
||||||
|
/// <param name="Side">Which side of the dashboard the minion occupies.</param>
|
||||||
|
/// <param name="PositionIndex">Zero-based position index within the side.</param>
|
||||||
|
/// <param name="DisplayName">Human-readable name, e.g. "Rex".</param>
|
||||||
|
public record AgentMinionMapping(
|
||||||
|
string AgentId,
|
||||||
|
MinionSide Side,
|
||||||
|
int PositionIndex,
|
||||||
|
string DisplayName
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real-time minion state update pushed to SignalR clients
|
||||||
|
/// when an agent's status changes. Combines the static mapping
|
||||||
|
/// (who/where) with the dynamic state (what the minion is doing).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AgentId">Agent identifier, e.g. "rex".</param>
|
||||||
|
/// <param name="DisplayName">Human-readable minion name, e.g. "Rex".</param>
|
||||||
|
/// <param name="Side">Which side of the dashboard — Dev or Business.</param>
|
||||||
|
/// <param name="PositionIndex">Position within the side (0-based).</param>
|
||||||
|
/// <param name="State">Current minion animation state.</param>
|
||||||
|
/// <param name="Timestamp">ISO 8601 timestamp of the state change.</param>
|
||||||
|
public record MinionStateUpdate(
|
||||||
|
string AgentId,
|
||||||
|
string DisplayName,
|
||||||
|
MinionSide Side,
|
||||||
|
int PositionIndex,
|
||||||
|
MinionState State,
|
||||||
|
string Timestamp
|
||||||
|
);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ControlCenter.Hubs;
|
||||||
|
using ControlCenter.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── API Services ───────────────────────────────────────────
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
{
|
||||||
|
c.SwaggerDoc("v1", new()
|
||||||
|
{
|
||||||
|
Title = "Control Center API",
|
||||||
|
Version = "v1",
|
||||||
|
Description = "OpenClaw Control Center — Command Hub backend with SignalR real-time updates"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include XML doc comments in Swagger output
|
||||||
|
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||||
|
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||||
|
if (File.Exists(xmlPath))
|
||||||
|
{
|
||||||
|
c.IncludeXmlComments(xmlPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CORS (kiosk + remote browser) ─────────────────────────
|
||||||
|
// The Control Center frontend runs on a different origin than the backend.
|
||||||
|
// SignalR requires credentials for WebSocket transport, so we use
|
||||||
|
// specific origins rather than AllowAnyOrigin.
|
||||||
|
var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins")
|
||||||
|
.Get<string[]>() ?? new[] { "http://localhost:4200", "http://localhost:5000" };
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins(corsOrigins)
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials(); // Required for SignalR WebSocket
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SignalR (real-time agent status updates) ───────────────
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// ── Gateway Bridge Service ────────────────────────────────
|
||||||
|
// Background service that connects to the OpenClaw Gateway WebSocket
|
||||||
|
// and bridges events to the AgentStatus SignalR hub.
|
||||||
|
builder.Services.AddSingleton<GatewayEventBridgeService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<GatewayEventBridgeService>());
|
||||||
|
|
||||||
|
// ── Agent-Minion Mapper Service ────────────────────────────
|
||||||
|
// Maps agents to minion sprites/positions and publishes state
|
||||||
|
// updates through SignalR.
|
||||||
|
builder.Services.AddSingleton<AgentMinionMapperService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// ── Middleware ──────────────────────────────────────────────
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseCors();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
// ── Hub Endpoints ───────────────────────────────────────────
|
||||||
|
// Agent status hub at /hubs/agent-status (matches the design spec)
|
||||||
|
app.MapHub<AgentStatusHub>("/hubs/agent-status");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5053",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using ControlCenter.Hubs;
|
||||||
|
using ControlCenter.Models;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ControlCenter.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that maps Linear agents to minion sprites and positions
|
||||||
|
/// in the Control Center dashboard.
|
||||||
|
///
|
||||||
|
/// <para>Static mappings define where each minion appears:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Dev side: Rex (0), Dex (1), Hex (2)</item>
|
||||||
|
/// <item>Business side: Larry (0), Mel (1), Buzz (2)</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Dynamic state is derived from the agent's <see cref="AgentStatus"/>:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Active</c> → <see cref="MinionState.Active"/></item>
|
||||||
|
/// <item><c>Idle</c> → <see cref="MinionState.Idle"/></item>
|
||||||
|
/// <item><c>Thinking</c> → <see cref="MinionState.Thinking"/></item>
|
||||||
|
/// <item><c>Error</c> → <see cref="MinionState.Error"/></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>State updates are published through the <see cref="AgentStatusHub"/>
|
||||||
|
/// SignalR hub so that connected clients can animate minion sprites
|
||||||
|
/// in real time.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class AgentMinionMapperService
|
||||||
|
{
|
||||||
|
private readonly ILogger<AgentMinionMapperService> _logger;
|
||||||
|
private readonly IHubContext<AgentStatusHub, IAgentStatusClient> _hubContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static agent-to-minion mapping table. Defines which side and position
|
||||||
|
/// each agent's minion occupies on the dashboard.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, AgentMinionMapping> Mappings = new()
|
||||||
|
{
|
||||||
|
// ── Dev Side ──────────────────────────────────
|
||||||
|
["rex"] = new AgentMinionMapping("rex", MinionSide.Dev, 0, "Rex"),
|
||||||
|
["dex"] = new AgentMinionMapping("dex", MinionSide.Dev, 1, "Dex"),
|
||||||
|
["hex"] = new AgentMinionMapping("hex", MinionSide.Dev, 2, "Hex"),
|
||||||
|
|
||||||
|
// ── Business Side ─────────────────────────────
|
||||||
|
["larry"] = new AgentMinionMapping("larry", MinionSide.Business, 0, "Larry"),
|
||||||
|
["mel"] = new AgentMinionMapping("mel", MinionSide.Business, 1, "Mel"),
|
||||||
|
["buzz"] = new AgentMinionMapping("buzz", MinionSide.Business, 2, "Buzz"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps <see cref="AgentStatus"/> string values to <see cref="MinionState"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, MinionState> StatusToMinionState = new()
|
||||||
|
{
|
||||||
|
["active"] = MinionState.Active,
|
||||||
|
["idle"] = MinionState.Idle,
|
||||||
|
["thinking"] = MinionState.Thinking,
|
||||||
|
["error"] = MinionState.Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
public AgentMinionMapperService(
|
||||||
|
ILogger<AgentMinionMapperService> logger,
|
||||||
|
IHubContext<AgentStatusHub, IAgentStatusClient> hubContext)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_hubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the minion mapping for a given agent ID.
|
||||||
|
/// Returns null if the agent is not mapped to a minion position.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent identifier, e.g. "rex", "dex".</param>
|
||||||
|
/// <returns>The mapping record, or null if unmapped.</returns>
|
||||||
|
public AgentMinionMapping? GetMapping(string agentId)
|
||||||
|
{
|
||||||
|
return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all minion mappings, ordered by side then position index.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>All mappings, sorted for consistent display order.</returns>
|
||||||
|
public IReadOnlyList<AgentMinionMapping> GetAllMappings()
|
||||||
|
{
|
||||||
|
return Mappings.Values
|
||||||
|
.OrderBy(m => m.Side)
|
||||||
|
.ThenBy(m => m.PositionIndex)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an agent status string to a <see cref="MinionState"/>.
|
||||||
|
/// Falls back to <see cref="MinionState.Idle"/> for unrecognized statuses.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">Agent status string: "active", "idle", "thinking", or "error".</param>
|
||||||
|
/// <returns>The corresponding minion state.</returns>
|
||||||
|
public MinionState StatusToState(string status)
|
||||||
|
{
|
||||||
|
return StatusToMinionState.GetValueOrDefault(
|
||||||
|
status?.ToLowerInvariant() ?? string.Empty,
|
||||||
|
MinionState.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a minion state update through SignalR when an agent's
|
||||||
|
/// status changes. Only publishes for agents that have a minion mapping.
|
||||||
|
///
|
||||||
|
/// <para>This is the primary integration point: the
|
||||||
|
/// <see cref="GatewayEventBridgeService"/> calls this method
|
||||||
|
/// whenever it detects a status change from the OpenClaw Gateway.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentId">The agent whose status changed, e.g. "dex".</param>
|
||||||
|
/// <param name="status">The new status string: "active", "idle", "thinking", or "error".</param>
|
||||||
|
/// <returns>A task that completes when the SignalR message has been sent.</returns>
|
||||||
|
public async Task PublishMinionStateUpdateAsync(string agentId, string status)
|
||||||
|
{
|
||||||
|
var mapping = GetMapping(agentId);
|
||||||
|
if (mapping is null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No minion mapping for agent {AgentId}; skipping state update", agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minionState = StatusToState(status);
|
||||||
|
var update = new MinionStateUpdate(
|
||||||
|
AgentId: mapping.AgentId,
|
||||||
|
DisplayName: mapping.DisplayName,
|
||||||
|
Side: mapping.Side,
|
||||||
|
PositionIndex: mapping.PositionIndex,
|
||||||
|
State: minionState,
|
||||||
|
Timestamp: DateTime.UtcNow.ToString("o")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast to the fleet group (all subscribers)
|
||||||
|
await _hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||||
|
.AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
|
||||||
|
|
||||||
|
// Also push to the specific agent's group
|
||||||
|
var agentGroup = AgentStatusHub.AgentGroupName(agentId);
|
||||||
|
await _hubContext.Clients.Group(agentGroup)
|
||||||
|
.AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Minion state update: {AgentId} → {State} (Side: {Side}, Position: {Index})",
|
||||||
|
agentId, minionState, mapping.Side, mapping.PositionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current minion state for all mapped agents, suitable
|
||||||
|
/// for building an initial fleet snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>All minion mappings with their current (idle) state.</returns>
|
||||||
|
public IReadOnlyList<MinionStateUpdate> GetFullMinionState()
|
||||||
|
{
|
||||||
|
return Mappings.Values
|
||||||
|
.OrderBy(m => m.Side)
|
||||||
|
.ThenBy(m => m.PositionIndex)
|
||||||
|
.Select(m => new MinionStateUpdate(
|
||||||
|
AgentId: m.AgentId,
|
||||||
|
DisplayName: m.DisplayName,
|
||||||
|
Side: m.Side,
|
||||||
|
PositionIndex: m.PositionIndex,
|
||||||
|
State: MinionState.Idle,
|
||||||
|
Timestamp: DateTime.UtcNow.ToString("o")))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a status string to an <see cref="AgentStatusUpdate"/>
|
||||||
|
/// for SignalR push. Uses the mapping table for display names and roles.
|
||||||
|
/// </summary>
|
||||||
|
private AgentStatusUpdate ToAgentStatusUpdate(string agentId, string status)
|
||||||
|
{
|
||||||
|
var mapping = GetMapping(agentId);
|
||||||
|
var displayName = mapping?.DisplayName ?? char.ToUpperInvariant(agentId[0]) + agentId[1..];
|
||||||
|
|
||||||
|
return new AgentStatusUpdate(
|
||||||
|
AgentId: agentId,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Role: mapping is not null
|
||||||
|
? $"{mapping.Side} Agent"
|
||||||
|
: "Agent",
|
||||||
|
Status: status,
|
||||||
|
CurrentTask: null,
|
||||||
|
SessionKey: string.Empty,
|
||||||
|
Channel: string.Empty,
|
||||||
|
LastActivity: DateTime.UtcNow.ToString("o"),
|
||||||
|
ErrorMessage: status == "error" ? "Agent encountered an error" : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ControlCenter.Hubs;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ControlCenter.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that connects to the OpenClaw Gateway WebSocket
|
||||||
|
/// and bridges Gateway events to the <see cref="Hubs.AgentStatusHub"/>.
|
||||||
|
///
|
||||||
|
/// <para>Architecture:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Connects to the Gateway WS endpoint (configurable via appsettings)</item>
|
||||||
|
/// <item>Handles the v3 protocol handshake (challenge → connect → hello-ok)</item>
|
||||||
|
/// <item>Subscribes to <c>sessions.changed</c> and related events</item>
|
||||||
|
/// <item>Translates session state changes into <see cref="AgentStatusUpdate"/>
|
||||||
|
/// and <see cref="TaskProgressUpdate"/> objects</item>
|
||||||
|
/// <item>Pushes updates through the <see cref="Hubs.AgentStatusHub"/> SignalR hub</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>This is the server-side bridge that allows Angular clients to
|
||||||
|
/// receive real-time updates via SignalR instead of connecting directly
|
||||||
|
/// to the Gateway WebSocket.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class GatewayEventBridgeService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<GatewayEventBridgeService> _logger;
|
||||||
|
private readonly IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> _hubContext;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory fleet state — maps agent IDs to their latest card data.
|
||||||
|
/// Updated on every <c>sessions.changed</c> event from the Gateway.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ConcurrentDictionary<string, AgentCardData> _fleetState = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Known agent roles for display in the Command Hub.
|
||||||
|
/// Maps agent IDs to their functional descriptions.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, string> AgentRoles = new()
|
||||||
|
{
|
||||||
|
["main"] = "Primary Assistant",
|
||||||
|
["otto"] = "Orchestrator Agent",
|
||||||
|
["dave"] = "Network Admin Agent",
|
||||||
|
["bob"] = "Content Writer Agent",
|
||||||
|
["stuart"] = "Image & Creative Agent",
|
||||||
|
["phil"] = "Home Automation Agent",
|
||||||
|
["carl"] = "Security Agent",
|
||||||
|
["larry"] = "Business Agent",
|
||||||
|
["mel"] = "E-Commerce Agent",
|
||||||
|
["norbert"] = "Product Agent",
|
||||||
|
["jerry"] = "Market Research Agent",
|
||||||
|
["rex"] = "Frontend Dev Agent",
|
||||||
|
["dex"] = "Backend Dev Agent",
|
||||||
|
["hex"] = "Database Agent",
|
||||||
|
["pip"] = "Raspberry Pi Agent",
|
||||||
|
["nano"] = "ESP32/Firmware Agent",
|
||||||
|
["axiom"] = "Utility Agent",
|
||||||
|
["bonnie"] = "Music Agent",
|
||||||
|
["sketch"] = "UI/UX Design Agent",
|
||||||
|
["flip"] = "Mobile Dev Agent",
|
||||||
|
["buzz"] = "SEO Agent",
|
||||||
|
["aries"] = "Companion Agent"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps OpenClaw session status to <see cref="AgentStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static string MapSessionStatus(string? sessionStatus) => sessionStatus switch
|
||||||
|
{
|
||||||
|
"running" => "active",
|
||||||
|
"streaming" => "thinking",
|
||||||
|
"error" or "aborted" => "error",
|
||||||
|
"done" => "idle",
|
||||||
|
_ => "idle"
|
||||||
|
};
|
||||||
|
|
||||||
|
public GatewayEventBridgeService(
|
||||||
|
ILogger<GatewayEventBridgeService> logger,
|
||||||
|
IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> hubContext,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_hubContext = hubContext;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current fleet state snapshot.
|
||||||
|
/// Used by the hub's <c>GetFleetSnapshot</c> method and by the
|
||||||
|
/// <c>AgentsController</c> REST endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public AgentCardData[] GetFleetSnapshot() =>
|
||||||
|
_fleetState.Values.ToArray();
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Gateway Event Bridge service starting");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ConnectAndListenAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Gateway Event Bridge service stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Gateway connection lost, reconnecting in 5 seconds...");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to the OpenClaw Gateway WebSocket and processes events
|
||||||
|
/// until the connection is lost or cancellation is requested.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var gatewayUrl = _configuration["Gateway:WebSocketUrl"]
|
||||||
|
?? "ws://localhost:3271/ws";
|
||||||
|
var authToken = _configuration["Gateway:AuthToken"] ?? string.Empty;
|
||||||
|
|
||||||
|
_logger.LogInformation("Connecting to Gateway at {Url}", gatewayUrl);
|
||||||
|
|
||||||
|
using var ws = new ClientWebSocket();
|
||||||
|
|
||||||
|
// Set auth header if available
|
||||||
|
if (!string.IsNullOrEmpty(authToken))
|
||||||
|
{
|
||||||
|
ws.Options.SetRequestHeader("Authorization", $"Bearer {authToken}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ws.ConnectAsync(new Uri(gatewayUrl), stoppingToken);
|
||||||
|
_logger.LogInformation("Connected to Gateway WebSocket");
|
||||||
|
|
||||||
|
// Start receiving messages
|
||||||
|
await ReceiveMessagesAsync(ws, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Receives and processes WebSocket messages from the Gateway.
|
||||||
|
/// Handles the v3 protocol handshake and dispatches events.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReceiveMessagesAsync(ClientWebSocket ws, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
var messageBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
WebSocketReceiveResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await ws.ReceiveAsync(buffer, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (WebSocketException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "WebSocket receive error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Gateway WebSocket closed by server");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||||
|
|
||||||
|
if (result.EndOfMessage)
|
||||||
|
{
|
||||||
|
var message = messageBuilder.ToString();
|
||||||
|
messageBuilder.Clear();
|
||||||
|
await ProcessMessageAsync(ws, message, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a single WebSocket message from the Gateway.
|
||||||
|
/// Routes based on the message type: event, response, or challenge.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessMessageAsync(
|
||||||
|
ClientWebSocket ws, string message, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(message);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var type = root.GetProperty("type").GetString();
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "event":
|
||||||
|
await HandleGatewayEventAsync(root);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "res":
|
||||||
|
HandleGatewayResponse(root);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for connect.challenge events
|
||||||
|
if (root.TryGetProperty("event", out var eventName) &&
|
||||||
|
eventName.GetString() == "connect.challenge")
|
||||||
|
{
|
||||||
|
await HandleConnectChallengeAsync(ws, root, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse Gateway message: {Message}",
|
||||||
|
message.Length > 200 ? message[..200] + "..." : message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the Gateway connect.challenge event by sending
|
||||||
|
/// a connect request with authentication credentials.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleConnectChallengeAsync(
|
||||||
|
ClientWebSocket ws, JsonElement root, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Received connect.challenge from Gateway");
|
||||||
|
|
||||||
|
var connectRequest = new
|
||||||
|
{
|
||||||
|
type = "req",
|
||||||
|
id = $"bridge-{Guid.NewGuid():N}",
|
||||||
|
method = "connect",
|
||||||
|
@params = new
|
||||||
|
{
|
||||||
|
minProtocol = 3,
|
||||||
|
maxProtocol = 3,
|
||||||
|
client = new
|
||||||
|
{
|
||||||
|
id = "control-center-backend",
|
||||||
|
version = "1.0.0",
|
||||||
|
platform = "server",
|
||||||
|
mode = "operator"
|
||||||
|
},
|
||||||
|
role = "operator",
|
||||||
|
scopes = new[] { "operator.read", "operator.write" },
|
||||||
|
auth = new
|
||||||
|
{
|
||||||
|
token = _configuration["Gateway:AuthToken"] ?? string.Empty
|
||||||
|
},
|
||||||
|
locale = "en-US",
|
||||||
|
userAgent = "control-center-backend/1.0.0"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(connectRequest);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a Gateway event message by dispatching to the
|
||||||
|
/// appropriate handler based on event name.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleGatewayEventAsync(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("event", out var eventProp)) return;
|
||||||
|
var eventName = eventProp.GetString();
|
||||||
|
|
||||||
|
_logger.LogDebug("Gateway event: {Event}", eventName);
|
||||||
|
|
||||||
|
switch (eventName)
|
||||||
|
{
|
||||||
|
case "sessions.changed":
|
||||||
|
await HandleSessionsChangedAsync(root);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "session.message":
|
||||||
|
HandleSessionMessage(root);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "session.tool":
|
||||||
|
HandleSessionTool(root);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "health":
|
||||||
|
HandleHealthEvent(root);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a sessions.changed event from the Gateway.
|
||||||
|
/// Updates the fleet state and pushes status changes through SignalR.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleSessionsChangedAsync(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||||
|
|
||||||
|
// The payload may contain a snapshot of all sessions
|
||||||
|
if (payload.TryGetProperty("snapshot", out var snapshot) &&
|
||||||
|
snapshot.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var session in snapshot.EnumerateArray())
|
||||||
|
{
|
||||||
|
var cardData = SessionToCardData(session);
|
||||||
|
if (cardData is null) continue;
|
||||||
|
|
||||||
|
_fleetState[cardData.Id] = cardData;
|
||||||
|
|
||||||
|
var update = new AgentStatusUpdate(
|
||||||
|
AgentId: cardData.Id,
|
||||||
|
DisplayName: cardData.DisplayName,
|
||||||
|
Role: cardData.Role,
|
||||||
|
Status: cardData.Status,
|
||||||
|
CurrentTask: cardData.CurrentTask,
|
||||||
|
SessionKey: cardData.SessionKey,
|
||||||
|
Channel: cardData.Channel,
|
||||||
|
LastActivity: cardData.LastActivity,
|
||||||
|
ErrorMessage: cardData.ErrorMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
await _hubContext.PushAgentStatusAsync(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle individual updates/added/removed
|
||||||
|
if (payload.TryGetProperty("updated", out var updated) &&
|
||||||
|
updated.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var sessionKey in updated.EnumerateArray())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Session updated: {Key}", sessionKey.GetString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a session.message event. Updates the agent's last activity
|
||||||
|
/// and pushes a status update if the status changed.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleSessionMessage(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||||
|
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
|
||||||
|
|
||||||
|
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
|
||||||
|
var agentId = ExtractAgentId(sessionKey);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(agentId)) return;
|
||||||
|
|
||||||
|
// Update last activity timestamp
|
||||||
|
if (_fleetState.TryGetValue(agentId, out var existing))
|
||||||
|
{
|
||||||
|
_fleetState[agentId] = existing with
|
||||||
|
{
|
||||||
|
LastActivity = DateTime.UtcNow.ToString("o"),
|
||||||
|
Status = "active"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a session.tool event. Extracts tool progress information
|
||||||
|
/// and pushes a <see cref="TaskProgressUpdate"/> through SignalR.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleSessionTool(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||||
|
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
|
||||||
|
|
||||||
|
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
|
||||||
|
var agentId = ExtractAgentId(sessionKey);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(agentId)) return;
|
||||||
|
|
||||||
|
var toolName = payload.TryGetProperty("toolName", out var tn) ? tn.GetString() : null;
|
||||||
|
var toolStatus = payload.TryGetProperty("status", out var ts) ? ts.GetString() : null;
|
||||||
|
|
||||||
|
if (toolName is null || toolStatus is null) return;
|
||||||
|
|
||||||
|
var progress = toolStatus switch
|
||||||
|
{
|
||||||
|
"started" => 0,
|
||||||
|
"completed" => 100,
|
||||||
|
_ => (int?)null
|
||||||
|
};
|
||||||
|
|
||||||
|
var update = new TaskProgressUpdate(
|
||||||
|
AgentId: agentId,
|
||||||
|
TaskDescription: $"{toolName} ({toolStatus})",
|
||||||
|
Progress: progress,
|
||||||
|
Elapsed: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire and forget — don't block the event loop
|
||||||
|
_ = _hubContext.PushTaskProgressAsync(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a health event from the Gateway.
|
||||||
|
/// Logs the health status for diagnostics.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleHealthEvent(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||||
|
|
||||||
|
var ok = payload.TryGetProperty("ok", out var okProp) && okProp.GetBoolean();
|
||||||
|
var status = payload.TryGetProperty("status", out var s) ? s.GetString() : "unknown";
|
||||||
|
|
||||||
|
_logger.LogInformation("Gateway health: ok={Ok}, status={Status}", ok, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a Gateway response message. Currently only logs for diagnostics.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleGatewayResponse(JsonElement root)
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Gateway RPC response OK");
|
||||||
|
|
||||||
|
// Check for hello-ok after connect
|
||||||
|
if (root.TryGetProperty("payload", out var payload) &&
|
||||||
|
payload.TryGetProperty("type", out var typeProp) &&
|
||||||
|
typeProp.GetString() == "hello-ok")
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Gateway handshake complete (hello-ok received)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (root.TryGetProperty("error", out var error))
|
||||||
|
{
|
||||||
|
var errorMsg = error.TryGetProperty("message", out var msg)
|
||||||
|
? msg.GetString() : "unknown error";
|
||||||
|
_logger.LogWarning("Gateway RPC error: {Error}", errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a raw Gateway session JSON element into an
|
||||||
|
/// <see cref="AgentCardData"/> record.
|
||||||
|
/// </summary>
|
||||||
|
private AgentCardData? SessionToCardData(JsonElement session)
|
||||||
|
{
|
||||||
|
// Extract agent ID from session key or agentId field
|
||||||
|
string? agentId = null;
|
||||||
|
if (session.TryGetProperty("agentId", out var aid))
|
||||||
|
agentId = aid.GetString();
|
||||||
|
else if (session.TryGetProperty("key", out var key))
|
||||||
|
agentId = ExtractAgentId(key.GetString() ?? string.Empty);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(agentId)) return null;
|
||||||
|
|
||||||
|
var sessionKey = session.TryGetProperty("key", out var sk)
|
||||||
|
? sk.GetString() ?? string.Empty : string.Empty;
|
||||||
|
|
||||||
|
var status = session.TryGetProperty("status", out var s)
|
||||||
|
? MapSessionStatus(s.GetString()) : "idle";
|
||||||
|
|
||||||
|
var channel = ExtractChannel(session);
|
||||||
|
|
||||||
|
var lastActivity = session.TryGetProperty("updatedAt", out var ua)
|
||||||
|
? DateTimeOffset.FromUnixTimeMilliseconds(ua.GetInt64()).ToString("o")
|
||||||
|
: DateTime.UtcNow.ToString("o");
|
||||||
|
|
||||||
|
var displayName = char.ToUpperInvariant(agentId![0]) + agentId[1..];
|
||||||
|
var role = AgentRoles.GetValueOrDefault(agentId!, "Agent");
|
||||||
|
|
||||||
|
return new AgentCardData(
|
||||||
|
Id: agentId!,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Role: role,
|
||||||
|
Status: status,
|
||||||
|
CurrentTask: null,
|
||||||
|
TaskProgress: null,
|
||||||
|
TaskElapsed: null,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Channel: channel,
|
||||||
|
LastActivity: lastActivity,
|
||||||
|
ErrorMessage: status == "error" ? "Agent encountered an error" : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the agent ID from a session key.
|
||||||
|
/// Session key format: "agent:{agentId}:{channel}:..."
|
||||||
|
/// </summary>
|
||||||
|
private static string? ExtractAgentId(string sessionKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sessionKey)) return null;
|
||||||
|
|
||||||
|
var parts = sessionKey.Split(':');
|
||||||
|
if (parts.Length >= 2 && parts[0] == "agent")
|
||||||
|
return parts[1];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the channel from a session element.
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractChannel(JsonElement session)
|
||||||
|
{
|
||||||
|
// Try direct "channel" property
|
||||||
|
if (session.TryGetProperty("channel", out var ch))
|
||||||
|
return ch.GetString() ?? "unknown";
|
||||||
|
|
||||||
|
// Try origin.provider
|
||||||
|
if (session.TryGetProperty("origin", out var origin) &&
|
||||||
|
origin.TryGetProperty("provider", out var provider))
|
||||||
|
return provider.GetString() ?? "unknown";
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Microsoft.AspNetCore": "Information",
|
||||||
|
"ControlCenter": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Gateway": {
|
||||||
|
"WebSocketUrl": "ws://localhost:3271/ws",
|
||||||
|
"AuthToken": ""
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:4200",
|
||||||
|
"http://localhost:5000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"ControlCenter": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
|
"Gateway": {
|
||||||
|
"WebSocketUrl": "ws://localhost:3271/ws",
|
||||||
|
"AuthToken": ""
|
||||||
|
},
|
||||||
|
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:4200",
|
||||||
|
"http://localhost:5000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ControlCenter.Api.Configurations;
|
||||||
|
using ControlCenter.Api.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core DbContext for the Control Center database.
|
||||||
|
/// All table and column names use snake_case via explicit HasColumnName configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Agent> Agents => Set<Agent>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// Apply all entity type configurations from the Configurations namespace
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AgentConfiguration).Assembly);
|
||||||
|
|
||||||
|
// Map the AgentStatus enum to a PostgreSQL enum type named "agent_status"
|
||||||
|
// This must be called after ApplyConfigurations to ensure the model is built
|
||||||
|
// before the enum mapping is applied.
|
||||||
|
modelBuilder.HasPostgresEnum<AgentStatus>();
|
||||||
|
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ControlCenter.Api.Entities;
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Design-time factory for AppDbContext, used by EF Core tools (dotnet ef)
|
||||||
|
/// to create migrations without requiring a running application.
|
||||||
|
/// </summary>
|
||||||
|
public class AppDbContextFactory : Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory<AppDbContext>
|
||||||
|
{
|
||||||
|
public AppDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||||
|
|
||||||
|
// Connection string for design-time operations (migrations).
|
||||||
|
// In production, this comes from appsettings / environment variables.
|
||||||
|
var connectionString = "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
|
||||||
|
|
||||||
|
optionsBuilder.UseNpgsql(connectionString, npgsqlOptions =>
|
||||||
|
{
|
||||||
|
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new AppDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
namespace ControlCenter.Api.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data transfer object for broadcasting agent status updates
|
||||||
|
/// to all connected SignalR clients.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentStatusUpdateDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Agent identifier, e.g. "otto", "dex", "rex".
|
||||||
|
/// Not null — every update must identify the agent it refers to.
|
||||||
|
/// </summary>
|
||||||
|
public string AgentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable display name, e.g. "Otto", "Dex".
|
||||||
|
/// Not null — used by clients to render agent cards.
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
|
||||||
|
/// Not null — provides context for the agent's function.
|
||||||
|
/// </summary>
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current operational status of the agent.
|
||||||
|
/// Maps to <see cref="Entities.AgentStatus"/> values as lowercase strings:
|
||||||
|
/// "active", "idle", "thinking", "error".
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Description of the agent's current task, if any.
|
||||||
|
/// Null when the agent is idle with no active task.
|
||||||
|
/// </summary>
|
||||||
|
public string? CurrentTask { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task progress percentage (0–100).
|
||||||
|
/// Null when progress is not trackable for the current task.
|
||||||
|
/// </summary>
|
||||||
|
public int? TaskProgress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Elapsed time string for the current task, e.g. "04m 12s".
|
||||||
|
/// Null when no task is active.
|
||||||
|
/// </summary>
|
||||||
|
public string? TaskElapsed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||||
|
/// Not null — uniquely identifies the agent session.
|
||||||
|
/// </summary>
|
||||||
|
public string SessionKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
|
||||||
|
/// Not null — every agent session operates on exactly one channel.
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISO 8601 timestamp of the agent's last activity.
|
||||||
|
/// Not null — used by clients to detect stale connections.
|
||||||
|
/// </summary>
|
||||||
|
public string LastActivity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error message when the agent status is "error".
|
||||||
|
/// Null when the agent is not in an error state.
|
||||||
|
/// </summary>
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace ControlCenter.Api.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an agent's current state in the Control Center.
|
||||||
|
/// Each row tracks one agent session's status, task, and activity.
|
||||||
|
/// </summary>
|
||||||
|
public class Agent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key — UUID generated on insert.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current operational status of the agent.
|
||||||
|
/// Stored as an enum in PostgreSQL via Npgsql.
|
||||||
|
/// </summary>
|
||||||
|
public AgentStatus Status { get; set; } = AgentStatus.Idle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Description of the agent's current task, if any.
|
||||||
|
/// Nullable — not all agents have an active task.
|
||||||
|
/// </summary>
|
||||||
|
public string? Task { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task progress percentage (0–100).
|
||||||
|
/// Nullable — progress is only meaningful when an agent has a trackable task.
|
||||||
|
/// </summary>
|
||||||
|
public int? Progress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||||
|
/// Not null — every agent row must be associated with a session.
|
||||||
|
/// </summary>
|
||||||
|
public string SessionKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
|
||||||
|
/// Not null — every agent session operates on exactly one channel.
|
||||||
|
/// </summary>
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp of the agent's last activity.
|
||||||
|
/// Default: current UTC timestamp on insert.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Row creation timestamp. Set automatically on insert.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Row last-update timestamp. Updated automatically on any modification.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ControlCenter.Api.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Agent operational status enum.
|
||||||
|
/// Maps to the agent_status enum type in PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public enum AgentStatus
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Idle = 1,
|
||||||
|
Thinking = 2,
|
||||||
|
Error = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using ControlCenter.Api.Dtos;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub for broadcasting agent status updates to connected clients.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Clients call <see cref="SendStatusUpdate"/> to broadcast a status change,
|
||||||
|
/// and the hub relays it to all connected clients via the
|
||||||
|
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Server-side code should use <see cref="AgentStatusHubExtensions.PushStatusUpdateAsync"/>
|
||||||
|
/// via <c>IHubContext<AgentStatusHub, IAgentStatusClient></c> for background-service broadcasts.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Architecture note: This hub bridges OpenClaw Gateway events to SignalR clients.
|
||||||
|
/// A background service subscribes to Gateway events and pushes them through
|
||||||
|
/// this hub's extension methods.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class AgentStatusHub : Hub<IAgentStatusClient>
|
||||||
|
{
|
||||||
|
private readonly ILogger<AgentStatusHub> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AgentStatusHub"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger for diagnostic output.</param>
|
||||||
|
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcasts an agent status update to all connected clients.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Any connected client (or server-side caller) can invoke this method
|
||||||
|
/// to push a status update to every subscriber. The update is relayed
|
||||||
|
/// through the <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="update">The agent status update payload to broadcast.</param>
|
||||||
|
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Broadcasting status update for agent {AgentId}: {Status}",
|
||||||
|
update.AgentId, update.Status);
|
||||||
|
|
||||||
|
await Clients.All.AgentStatusChanged(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the calling connection to the fleet group.
|
||||||
|
/// Once joined, the client will receive all agent status updates.
|
||||||
|
/// </summary>
|
||||||
|
public async Task JoinFleet()
|
||||||
|
{
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the calling connection from the fleet group.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LeaveFleet()
|
||||||
|
{
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides <see cref="Hub{T}.OnDisconnectedAsync"/> to log disconnections.
|
||||||
|
/// SignalR automatically removes disconnected connections from all groups.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||||
|
public override Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
||||||
|
return base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SignalR group name for the entire fleet (all agents).
|
||||||
|
/// </summary>
|
||||||
|
internal const string FleetGroupName = "fleet";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed client interface for the AgentStatus SignalR hub.
|
||||||
|
/// Defines the methods the server can invoke on connected clients
|
||||||
|
/// to push real-time agent status updates.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAgentStatusClient
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes an agent status change to all subscribed clients.
|
||||||
|
/// Fired whenever an agent's operational status changes
|
||||||
|
/// (e.g., idle → active, active → thinking, active → error).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="update">The full status update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||||
|
Task AgentStatusChanged(AgentStatusUpdateDto update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for pushing real-time agent updates through
|
||||||
|
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// These methods are intended to be called from background services
|
||||||
|
/// or other server-side code that detects an agent state change,
|
||||||
|
/// using the injected <c>IHubContext<AgentStatusHub, IAgentStatusClient></c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class AgentStatusHubExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes an agent status update to all connected clients.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Call this from any background service when an agent's
|
||||||
|
/// operational status changes (e.g., the Gateway reports a
|
||||||
|
/// session transition from "running" to "done").
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||||
|
/// <param name="update">The agent status update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the message has been sent to all clients.</returns>
|
||||||
|
public static async Task PushStatusUpdateAsync(
|
||||||
|
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||||
|
AgentStatusUpdateDto update)
|
||||||
|
{
|
||||||
|
await hubContext.Clients.All.AgentStatusChanged(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes an agent status update to clients subscribed to the fleet group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||||
|
/// <param name="update">The agent status update payload.</param>
|
||||||
|
/// <returns>A Task that completes when the message has been sent to the fleet group.</returns>
|
||||||
|
public static async Task PushStatusUpdateToFleetAsync(
|
||||||
|
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||||
|
AgentStatusUpdateDto update)
|
||||||
|
{
|
||||||
|
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||||
|
.AgentStatusChanged(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using ControlCenter.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260426101703_CreateAgentsTable")]
|
||||||
|
partial class CreateAgentsTable
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Channel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("channel");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastActivity")
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("last_activity");
|
||||||
|
|
||||||
|
b.Property<int?>("Progress")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("progress");
|
||||||
|
|
||||||
|
b.Property<string>("SessionKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("session_key");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("agent_status")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("Task")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("task");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Channel")
|
||||||
|
.HasDatabaseName("ix_agents_channel");
|
||||||
|
|
||||||
|
b.HasIndex("SessionKey")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_agents_session_key");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasDatabaseName("ix_agents_status");
|
||||||
|
|
||||||
|
b.ToTable("agents", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CreateAgentsTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:Enum:agent_status", "active,idle,thinking,error");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "agents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
status = table.Column<int>(type: "agent_status", nullable: false),
|
||||||
|
task = table.Column<string>(type: "text", nullable: true),
|
||||||
|
progress = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
session_key = table.Column<string>(type: "text", nullable: false),
|
||||||
|
channel = table.Column<string>(type: "text", nullable: false),
|
||||||
|
last_activity = table.Column<DateTime>(type: "timestamptz", nullable: false),
|
||||||
|
created_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
|
||||||
|
updated_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_agents", x => x.id);
|
||||||
|
table.CheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_agents_channel",
|
||||||
|
table: "agents",
|
||||||
|
column: "channel");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_agents_session_key",
|
||||||
|
table: "agents",
|
||||||
|
column: "session_key",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_agents_status",
|
||||||
|
table: "agents",
|
||||||
|
column: "status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "agents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using ControlCenter.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Channel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("channel");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastActivity")
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("last_activity");
|
||||||
|
|
||||||
|
b.Property<int?>("Progress")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("progress");
|
||||||
|
|
||||||
|
b.Property<string>("SessionKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("session_key");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("agent_status")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("Task")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("task");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Channel")
|
||||||
|
.HasDatabaseName("ix_agents_channel");
|
||||||
|
|
||||||
|
b.HasIndex("SessionKey")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_agents_session_key");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasDatabaseName("ix_agents_status");
|
||||||
|
|
||||||
|
b.ToTable("agents", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ControlCenter.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only model representing an agent's current state.
|
||||||
|
/// Used as the return type from the Agent State Repository
|
||||||
|
/// to decouple consumers from the persistence layer.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentState
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string? Task { get; set; }
|
||||||
|
public int? Progress { get; set; }
|
||||||
|
public string SessionKey { get; set; } = string.Empty;
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
public DateTime LastActivity { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ControlCenter.Api.Data;
|
||||||
|
using ControlCenter.Api.Hubs;
|
||||||
|
using ControlCenter.Api.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// Register SignalR for real-time agent status updates
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// Register Agent State Repository
|
||||||
|
builder.Services.AddScoped<IAgentStateRepository, AgentStateRepository>();
|
||||||
|
|
||||||
|
// Register DbContext with PostgreSQL
|
||||||
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
|
||||||
|
|
||||||
|
options.UseNpgsql(connectionString, npgsqlOptions =>
|
||||||
|
{
|
||||||
|
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Map SignalR hubs
|
||||||
|
app.MapHub<AgentStatusHub>("/hubs/agent-status");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5178",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7041;http://localhost:5178",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using ControlCenter.Api.Data;
|
||||||
|
using ControlCenter.Api.Entities;
|
||||||
|
using ControlCenter.Api.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of the Agent State Repository.
|
||||||
|
/// Maps between the persisted <see cref="Agent"/> entity and the
|
||||||
|
/// read-oriented <see cref="AgentState"/> model.
|
||||||
|
/// </summary>
|
||||||
|
public class AgentStateRepository : IAgentStateRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public AgentStateRepository(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<AgentState>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var agents = await _db.Agents
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(a => a.LastActivity)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return agents.Select(ToModel).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AgentState?> GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var agent = await _db.Agents
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.SessionKey == sessionKey, ct);
|
||||||
|
|
||||||
|
return agent is null ? null : ToModel(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<AgentStatus>(status, ignoreCase: true, out var parsedStatus))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var agent = await _db.Agents.FindAsync([id], ct);
|
||||||
|
if (agent is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
agent.Status = parsedStatus;
|
||||||
|
agent.UpdatedAt = DateTime.UtcNow;
|
||||||
|
agent.LastActivity = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a persisted <see cref="Agent"/> entity to a <see cref="AgentState"/> model.
|
||||||
|
/// </summary>
|
||||||
|
private static AgentState ToModel(Agent agent) => new()
|
||||||
|
{
|
||||||
|
Id = agent.Id,
|
||||||
|
Status = agent.Status.ToString(),
|
||||||
|
Task = agent.Task,
|
||||||
|
Progress = agent.Progress,
|
||||||
|
SessionKey = agent.SessionKey,
|
||||||
|
Channel = agent.Channel,
|
||||||
|
LastActivity = agent.LastActivity,
|
||||||
|
CreatedAt = agent.CreatedAt,
|
||||||
|
UpdatedAt = agent.UpdatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using ControlCenter.Api.Models;
|
||||||
|
|
||||||
|
namespace ControlCenter.Api.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository interface for accessing and mutating Agent State.
|
||||||
|
/// Provides a clean abstraction over the EF Core data access layer.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAgentStateRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve all agent states.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AgentState>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve a single agent state by its session key.
|
||||||
|
/// Returns null if no agent is found with the given session key.
|
||||||
|
/// </summary>
|
||||||
|
Task<AgentState?> GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the status of an agent by its primary key.
|
||||||
|
/// Returns true if the agent was found and updated, false otherwise.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Database=control_center;Username=postgres;Password=postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Package models defines the database entities for the Control Center Go backend.
|
||||||
|
// Structs map 1:1 to the PostgreSQL schema defined in backend/migrations/.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentStatus represents the possible lifecycle states of an agent.
|
||||||
|
type AgentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AgentStatusActive AgentStatus = "active"
|
||||||
|
AgentStatusIdle AgentStatus = "idle"
|
||||||
|
AgentStatusThinking AgentStatus = "thinking"
|
||||||
|
AgentStatusError AgentStatus = "error"
|
||||||
|
AgentStatusOffline AgentStatus = "offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent represents a registered agent and its current state.
|
||||||
|
type Agent struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Status AgentStatus `db:"status" json:"status"`
|
||||||
|
Task *string `db:"task" json:"task,omitempty"`
|
||||||
|
Progress int32 `db:"progress" json:"progress"`
|
||||||
|
SessionKey *string `db:"session_key" json:"session_key,omitempty"`
|
||||||
|
Channel *string `db:"channel" json:"channel,omitempty"`
|
||||||
|
LastActivity time.Time `db:"last_activity" json:"last_activity"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStatus represents the possible states of an agent session.
|
||||||
|
type SessionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionStatusRunning SessionStatus = "running"
|
||||||
|
SessionStatusCompleted SessionStatus = "completed"
|
||||||
|
SessionStatusCrashed SessionStatus = "crashed"
|
||||||
|
SessionStatusTerminated SessionStatus = "terminated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session tracks an agent session over time.
|
||||||
|
type Session struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||||
|
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||||
|
EndedAt *time.Time `db:"ended_at" json:"ended_at,omitempty"`
|
||||||
|
Status SessionStatus `db:"status" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskLogStatus represents the possible states of a task log entry.
|
||||||
|
type TaskLogStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskLogStatusPending TaskLogStatus = "pending"
|
||||||
|
TaskLogStatusRunning TaskLogStatus = "running"
|
||||||
|
TaskLogStatusCompleted TaskLogStatus = "completed"
|
||||||
|
TaskLogStatusFailed TaskLogStatus = "failed"
|
||||||
|
TaskLogStatusCancelled TaskLogStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskLog records a historical task assigned to an agent.
|
||||||
|
type TaskLog struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||||
|
Task string `db:"task" json:"task"`
|
||||||
|
Status TaskLogStatus `db:"status" json:"status"`
|
||||||
|
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||||
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||||
|
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectStatus represents the possible states of a project.
|
||||||
|
type ProjectStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProjectStatusPlanned ProjectStatus = "planned"
|
||||||
|
ProjectStatusInProgress ProjectStatus = "in_progress"
|
||||||
|
ProjectStatusCompleted ProjectStatus = "completed"
|
||||||
|
ProjectStatusPaused ProjectStatus = "paused"
|
||||||
|
ProjectStatusCancelled ProjectStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project represents a project managed by the Control Center.
|
||||||
|
type Project struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
Status ProjectStatus `db:"status" json:"status"`
|
||||||
|
AgentID *pgtype.UUID `db:"agent_id" json:"agent_id,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentEvent represents an event in the agent lifecycle or telemetry stream.
|
||||||
|
type AgentEvent struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||||
|
EventType string `db:"event_type" json:"event_type"`
|
||||||
|
Payload *map[string]interface{} `db:"payload" json:"payload,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: 001_initial_schema (down)
|
||||||
|
-- Description: Reverts the core Control Center database schema.
|
||||||
|
|
||||||
|
-- Drop in reverse dependency order to avoid FK conflicts
|
||||||
|
DROP TABLE IF EXISTS agent_events;
|
||||||
|
DROP TABLE IF EXISTS task_logs;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
|
DROP TABLE IF EXISTS agents;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- Migration: 001_initial_schema
|
||||||
|
-- Description: Creates the core Control Center database schema.
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: agents
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle'
|
||||||
|
CHECK (status IN ('active', 'idle', 'thinking', 'error', 'offline')),
|
||||||
|
task TEXT,
|
||||||
|
progress INT NOT NULL DEFAULT 0
|
||||||
|
CHECK (progress >= 0 AND progress <= 100),
|
||||||
|
session_key TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE agents IS 'Registered agents and their current state';
|
||||||
|
COMMENT ON COLUMN agents.status IS 'Agent lifecycle status: active, idle, thinking, error, offline';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: sessions
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running'
|
||||||
|
CHECK (status IN ('running', 'completed', 'crashed', 'terminated')),
|
||||||
|
CONSTRAINT fk_sessions_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE sessions IS 'Agent session history';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: task_logs
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE task_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
task TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
CONSTRAINT fk_task_logs_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_logs IS 'Historical record of tasks assigned to agents';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: projects
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'planned'
|
||||||
|
CHECK (status IN ('planned', 'in_progress', 'completed', 'paused', 'cancelled')),
|
||||||
|
agent_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_projects_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE projects IS 'Projects managed by the Control Center';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: agent_events
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE agent_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_agent_events_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE agent_events IS 'Event stream for agent lifecycle and telemetry';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: 002_add_indexes (down)
|
||||||
|
-- Description: Remove all indexes added in 002_add_indexes.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_agents_status;
|
||||||
|
DROP INDEX IF EXISTS idx_agents_last_activity;
|
||||||
|
DROP INDEX IF EXISTS idx_agents_created_at;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_agent_id;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_status;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_started_at;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_task_logs_agent_started;
|
||||||
|
DROP INDEX IF EXISTS idx_task_logs_status;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_agent_events_agent_created;
|
||||||
|
DROP INDEX IF EXISTS idx_agent_events_event_type;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_projects_status;
|
||||||
|
DROP INDEX IF EXISTS idx_projects_agent_id;
|
||||||
|
DROP INDEX IF EXISTS idx_projects_created;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migration: 002_add_indexes
|
||||||
|
-- Description: Add performance indexes for common query patterns.
|
||||||
|
|
||||||
|
-- agents: status filtering, activity ordering
|
||||||
|
CREATE INDEX idx_agents_status ON agents(status);
|
||||||
|
CREATE INDEX idx_agents_last_activity ON agents(last_activity DESC);
|
||||||
|
CREATE INDEX idx_agents_created_at ON agents(created_at DESC);
|
||||||
|
|
||||||
|
-- sessions: agent session lookups, active session checks
|
||||||
|
CREATE INDEX idx_sessions_agent_id ON sessions(agent_id);
|
||||||
|
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||||
|
CREATE INDEX idx_sessions_started_at ON sessions(started_at DESC);
|
||||||
|
|
||||||
|
-- task_logs: agent task history, chronological ordering
|
||||||
|
CREATE INDEX idx_task_logs_agent_started ON task_logs(agent_id, started_at DESC);
|
||||||
|
CREATE INDEX idx_task_logs_status ON task_logs(status);
|
||||||
|
|
||||||
|
-- agent_events: event stream queries
|
||||||
|
CREATE INDEX idx_agent_events_agent_created ON agent_events(agent_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_agent_events_event_type ON agent_events(event_type);
|
||||||
|
|
||||||
|
-- projects: status filtering, agent assignment
|
||||||
|
CREATE INDEX idx_projects_status ON projects(status);
|
||||||
|
CREATE INDEX idx_projects_agent_id ON projects(agent_id);
|
||||||
|
CREATE INDEX idx_projects_created ON projects(created_at DESC);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
# Install Go 1.23
|
||||||
|
RUN curl -sL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz
|
||||||
|
|
||||||
|
# Install Node 22
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||||
@@ -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:
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
# Control Center — Project Context
|
||||||
|
|
||||||
|
> **Last updated:** 2026-05-21
|
||||||
|
> **Repo:** `CubeCraft-Creations/Control-Center` | **Host:** `code.cubecraftcreations.com`
|
||||||
|
> **Local clone:** `/mnt/ai-storage/projects/Control-Center` | **Default branch:** `dev`
|
||||||
|
> **Discord:** `DISCORD_DEV_CONTROL_CENTER_CHANNEL_ID`
|
||||||
|
> **Linear Epic:** [CUB-119](https://linear.app/cubecraft-creations/issue/CUB-119)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Real-time dashboard for monitoring and controlling the OpenClaw agent fleet. Displays agent statuses, active tasks, sessions, and projects. Uses SSE for live updates from the Go backend, which connects to the OpenClaw gateway via WebSocket for live agent data.
|
||||||
|
|
||||||
|
**Completed refactor:** ASP.NET Core + Angular → Go + React is done (CUB-119 epic). All legacy code is removed from git.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Notes |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| Backend | Go 1.24+ | Chi router, pgx (PostgreSQL), SSE broker, gorilla/websocket |
|
||||||
|
| Frontend | React 18 + TypeScript | Vite, Tailwind CSS, React Query, TanStack Router |
|
||||||
|
| Database | PostgreSQL 16+ | snake_case naming, 2 migrations |
|
||||||
|
| Real-time | SSE + WebSocket | SSE for browser, WebSocket for OpenClaw gateway |
|
||||||
|
| Gateway Integration | WebSocket client | OpenClaw gateway `/ws` — live agent + session RPC |
|
||||||
|
| API Client | TypeScript SDK | `api-client/` — shared models, WS client, HTTP client |
|
||||||
|
| CI/CD | Gitea Actions | `.gitea/workflows/dev.yml`, `deploy-dev.yaml` |
|
||||||
|
| Deployment | Docker Compose | PostgreSQL + Go backend + React/nginx |
|
||||||
|
| Testing | Vitest, Go test | Unit + integration tests for WS client, gateway, handlers |
|
||||||
|
| Design | Kiosk layout | Bottom nav (mobile), nav rail (desktop), quick-jump drawer |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenClaw Gateway (WebSocket)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Go Backend (Chi + pgx)
|
||||||
|
├── Gateway WS Client (connect, reconnect, agents.list, sessions.list RPC)
|
||||||
|
├── SSE Broker (fan-out: agent.status, agent.task, fleet.update)
|
||||||
|
├── REST API (/api/agents, /api/sessions, /api/tasks, /api/projects)
|
||||||
|
└── Repository/Store layers → PostgreSQL
|
||||||
|
│
|
||||||
|
├── SSE /api/events
|
||||||
|
▼
|
||||||
|
React Frontend
|
||||||
|
├── SSEProvider → useRealtimeSync → React Query cache
|
||||||
|
├── HubPage (dashboard), LogsPage, ProjectsPage, SessionsPage, SettingsPage
|
||||||
|
└── Layout (header bar + nav rail + bottom nav + quick-jump drawer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Architecture Decisions
|
||||||
|
1. **Go replaced ASP.NET Core** — lighter runtime, faster cold-start, better concurrency for gateway polling
|
||||||
|
2. **React replaced Angular** — lighter than Angular for dashboard/kiosk use
|
||||||
|
3. **SSE over SignalR** — simpler server-side, unidirectional events sufficient for browser updates
|
||||||
|
4. **WebSocket for gateway integration** — bidirectional needed for RPC (agents.list, sessions.list)
|
||||||
|
5. **PostgreSQL** — shared with Extrudex pattern; migrations in `go-backend/migrations/`
|
||||||
|
6. **Agent state seeded on first boot** via `gateway.SeedDemoAgents` for offline dev
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Control-Center/
|
||||||
|
├── go-backend/ # Go backend
|
||||||
|
│ ├── cmd/server/main.go # Entrypoint, wire deps, start gateway poller
|
||||||
|
│ ├── Dockerfile / go.mod / go.sum
|
||||||
|
│ ├── migrations/ # 001_initial_schema, 002_add_indexes
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── config/config.go # Env vars (DATABASE_URL, GATEWAY_URL, etc.)
|
||||||
|
│ ├── db/db.go # PostgreSQL pool (pgx)
|
||||||
|
│ ├── gateway/
|
||||||
|
│ │ ├── client.go # GW poller → sync DB + SSE fan-out
|
||||||
|
│ │ ├── events.go # SSE event broker
|
||||||
|
│ │ ├── events_test.go
|
||||||
|
│ │ ├── sync.go # Initial sync from gateway
|
||||||
|
│ │ ├── sync_test.go
|
||||||
|
│ │ ├── wsclient.go # WebSocket client (handshake, connect, reconnect, RPC)
|
||||||
|
│ │ └── wsclient_test.go
|
||||||
|
│ ├── handler/
|
||||||
|
│ │ ├── agent.go # CRUD + history
|
||||||
|
│ │ ├── project.go # List projects
|
||||||
|
│ │ ├── session.go # List sessions
|
||||||
|
│ │ ├── sse.go # SSE broker: subscribe + broadcast
|
||||||
|
│ │ ├── task.go # List tasks
|
||||||
|
│ │ ├── helpers.go
|
||||||
|
│ │ ├── handler_test.go
|
||||||
|
│ │ └── mock_repos_test.go
|
||||||
|
│ ├── models/models.go # Domain types
|
||||||
|
│ ├── repository/ # DB access layer + interfaces
|
||||||
|
│ ├── router/router.go # Chi router: REST + SSE mount
|
||||||
|
│ └── store/ # Agent, Project, Session, Task stores
|
||||||
|
├── api-client/ # Shared TypeScript SDK
|
||||||
|
│ └── src/
|
||||||
|
│ ├── models/types.ts # Agent, Session, Task, Project, SSE event types
|
||||||
|
│ ├── services/http-client.ts # Axios REST client
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── config.ts # Client config
|
||||||
|
│ │ └── status-mapper.ts # Agent status → display mapping
|
||||||
|
│ └── websocket/
|
||||||
|
│ └── ws-client.ts # WebSocket client (handshake, Send, RPC, reconnector)
|
||||||
|
├── frontend/ # React frontend
|
||||||
|
│ ├── Dockerfile + nginx.conf
|
||||||
|
│ ├── package.json + vite.config.ts
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.tsx / main.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── ErrorBoundary.tsx
|
||||||
|
│ │ │ └── Layout.tsx # Header bar + nav rail + bottom nav + quick-jump
|
||||||
|
│ │ ├── contexts/
|
||||||
|
│ │ │ └── SSEContext.tsx # SSEProvider — wraps entire app
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useLocalStorage.ts
|
||||||
|
│ │ │ ├── useRealtimeSync.ts # SSE messages → React Query cache
|
||||||
|
│ │ │ ├── useRealtimeSync.test.tsx
|
||||||
|
│ │ │ ├── useSSE.ts # SSE: connect, reconnect, typed events
|
||||||
|
│ │ │ ├── useSSE.test.ts
|
||||||
|
│ │ │ └── useTheme.tsx
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── HubPage.tsx # Fleet dashboard (agent grid + stats)
|
||||||
|
│ │ │ ├── LogsPage.tsx # Agent log viewer
|
||||||
|
│ │ │ ├── ProjectsPage.tsx # Project list
|
||||||
|
│ │ │ ├── SessionsPage.tsx # Session list
|
||||||
|
│ │ │ └── SettingsPage.tsx # Settings + theme toggle
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── api.ts # Axios REST client
|
||||||
|
│ │ │ └── sse.ts # SSE utilities
|
||||||
|
│ │ └── types/index.ts
|
||||||
|
│ └── vitest.config.ts
|
||||||
|
├── frontend-legacy/ # Original Angular frontend (kept for reference, not in git)
|
||||||
|
├── backend/ # Original ASP.NET backend (kept for reference, not in git)
|
||||||
|
│ ├── ControlCenter/ # ASP.NET Core project
|
||||||
|
│ └── Api/ # API layer
|
||||||
|
├── design/
|
||||||
|
│ ├── command-hub-spec.md # Detailed design spec
|
||||||
|
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
|
||||||
|
├── kiosk/
|
||||||
|
│ ├── control-center-kiosk.service # Systemd service
|
||||||
|
│ └── start-kiosk.sh # Kiosk startup script
|
||||||
|
├── reference/
|
||||||
|
│ └── CONTROL_CENTER_CONTEXT.md # Older context file (superseded by this one)
|
||||||
|
├── ci-image/Dockerfile # CI build image
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema (PostgreSQL)
|
||||||
|
|
||||||
|
### agents
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| display_name | VARCHAR(256) NOT NULL | |
|
||||||
|
| role | VARCHAR(256) | |
|
||||||
|
| status | VARCHAR(32) | active, idle, thinking, error |
|
||||||
|
| current_task | VARCHAR(512) | |
|
||||||
|
| task_progress | INTEGER DEFAULT 0 | |
|
||||||
|
| session_key | VARCHAR(256) | |
|
||||||
|
| channel | VARCHAR(256) | |
|
||||||
|
| last_activity | TIMESTAMP | |
|
||||||
|
| error_message | TEXT | |
|
||||||
|
| created_at | TIMESTAMP | |
|
||||||
|
| updated_at | TIMESTAMP | |
|
||||||
|
|
||||||
|
### sessions
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| session_key | VARCHAR(256) UNIQUE |
|
||||||
|
| agent_id | UUID FK → agents |
|
||||||
|
| channel | VARCHAR(256) |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| context_tokens | INTEGER |
|
||||||
|
| total_tokens | INTEGER |
|
||||||
|
| estimated_cost | NUMERIC |
|
||||||
|
| model | VARCHAR(256) |
|
||||||
|
| started_at | TIMESTAMP |
|
||||||
|
| last_activity_at | TIMESTAMP |
|
||||||
|
|
||||||
|
### tasks
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| agent_id | UUID FK → agents |
|
||||||
|
| title | VARCHAR(512) |
|
||||||
|
| description | TEXT |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| progress | INTEGER DEFAULT 0 |
|
||||||
|
| session_key | VARCHAR(256) |
|
||||||
|
| created_at, updated_at | TIMESTAMP |
|
||||||
|
|
||||||
|
### projects
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| name | VARCHAR(256) UNIQUE |
|
||||||
|
| description | TEXT |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| agent_ids | TEXT[] |
|
||||||
|
| created_at, updated_at | TIMESTAMP |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | /health | Health check (probes DB) |
|
||||||
|
| GET | /api/agents | List all agents |
|
||||||
|
| POST | /api/agents | Create agent |
|
||||||
|
| GET | /api/agents/:id | Get agent detail |
|
||||||
|
| PUT | /api/agents/:id | Update agent |
|
||||||
|
| DELETE | /api/agents/:id | Delete agent |
|
||||||
|
| GET | /api/agents/:id/history | Agent status history |
|
||||||
|
| GET | /api/sessions | List sessions |
|
||||||
|
| GET | /api/tasks | List tasks |
|
||||||
|
| GET | /api/projects | List projects |
|
||||||
|
| GET | /api/events | SSE event stream |
|
||||||
|
|
||||||
|
## SSE Events
|
||||||
|
|
||||||
|
| Event Type | Payload |
|
||||||
|
|------------|---------|
|
||||||
|
| `agent.status` | Agent status change |
|
||||||
|
| `agent.task` | Agent current task updated |
|
||||||
|
| `agent.progress` | Task progress percentage |
|
||||||
|
| `fleet.update` | Full fleet snapshot |
|
||||||
|
| `connected` | Connection established |
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### dev.yml
|
||||||
|
- Lint + typecheck: Go vet + golangci-lint + tsc
|
||||||
|
- Test: Go test + vitest
|
||||||
|
- Build: Go build + npm build
|
||||||
|
- Docker: Build and push images
|
||||||
|
- Triggers: push to dev/main
|
||||||
|
|
||||||
|
### deploy-dev.yaml
|
||||||
|
- Workflow dispatch
|
||||||
|
- SCP deploy script to dev host
|
||||||
|
- systemctl restart with rollback
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
| Service | Image/Build | Ports | Depends On |
|
||||||
|
|---------|-------------|-------|------------|
|
||||||
|
| postgres | postgres:16-alpine | 5432 | — |
|
||||||
|
| backend | ./go-backend/Dockerfile | 8080 | postgres (healthy) |
|
||||||
|
| frontend | ./frontend/Dockerfile | 3000 | backend (healthy) |
|
||||||
|
|
||||||
|
## Linear Issue Map
|
||||||
|
|
||||||
|
| CUB | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| 119 | **Epic: Control Center Refactor — .NET → Go + React** | Todo |
|
||||||
|
| 120 | PostgreSQL schema + migrations | Done |
|
||||||
|
| 121 | React pages wired to real API | Done |
|
||||||
|
| 122 | React frontend scaffold | Done |
|
||||||
|
| 123 | Gateway integration + SSE streaming | Done |
|
||||||
|
| 124 | Go backend scaffold | Done |
|
||||||
|
| 125 | Real-time SSE frontend | Done |
|
||||||
|
| 126 | Docker Compose deployment | Done |
|
||||||
|
| 127 | CRUD API endpoints | Done |
|
||||||
|
| 200 | Live WebSocket gateway client (CUB-200-207 sub-epic) | In Review |
|
||||||
|
| 201 | agents.list + sessions.list RPC and data mapping | In Review |
|
||||||
|
| 202 | Real-time event subscription + SSE fan-out | In Review |
|
||||||
|
| 203 | WS client scaffold — handshake, connect, reconnect loop | In Review |
|
||||||
|
| 204 | Config, wiring, and graceful fallback | In Review |
|
||||||
|
| 205 | Unit tests — gateway utility functions | In Review |
|
||||||
|
| 206 | Unit tests — WSClient handshake, Send/RPC, frame router, reconnect | In Review |
|
||||||
|
| 207 | Unit tests — event handlers and initial sync | In Review |
|
||||||
|
|
||||||
|
### Legacy Issues (Angular/ASP.NET — all Done)
|
||||||
|
CUB-19 through CUB-63: All 27 Control Center issues completed, including minion mapping, breakroom UI, dark mode theme, agent cards, quick-jump drawer, adaptive nav, SignalR hub, and status animations.
|
||||||
|
|
||||||
|
## Known Limitations / Next Steps
|
||||||
|
|
||||||
|
1. Agent detail/history views are scaffolded but not fully implemented
|
||||||
|
2. 16-bit minion breakroom concept (CUB-59-63) was on Angular — needs React port if desired
|
||||||
|
3. `.env` must be created from `.env.example` with a valid `GATEWAY_URL` for live agent data
|
||||||
|
4. Docker containers not currently running — start with `docker compose up --build -d`
|
||||||
|
|
||||||
|
## Default Agent Assignments
|
||||||
|
|
||||||
|
| Area | Agent | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Backend (Go API, Gateway WS, SSE) | Dex | gitea-dex MCP |
|
||||||
|
| Database (PostgreSQL schema) | Hex | gitea-hex MCP |
|
||||||
|
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
|
||||||
|
| Design (wireframes, UX) | Sketch | |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/ai-storage/projects/Control-Center
|
||||||
|
git checkout dev
|
||||||
|
git pull origin dev
|
||||||
|
|
||||||
|
# Docker Compose (recommended)
|
||||||
|
cp .env.example .env # edit GATEWAY_URL first
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Manual
|
||||||
|
cd go-backend && go run cmd/server/main.go # → :8080
|
||||||
|
cd frontend && npm install && npm run dev # → :5173 (Vite proxy to :8080)
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Build output (rebuilt in container)
|
||||||
|
dist/
|
||||||
|
out-tsc/
|
||||||
|
|
||||||
|
# Angular cache
|
||||||
|
.angular/cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
coverage/
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/mcp.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Control Center Frontend — Multi-stage Docker Build
|
||||||
|
# Angular 21 + nginx for static serving + API proxy
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Build Stage ---
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (layer caching)
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build production bundle
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Runtime Stage ---
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
|
||||||
|
# Remove default nginx config
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy built Angular app from builder stage
|
||||||
|
COPY --from=builder /app/dist/frontend/browser /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose HTTP port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check — confirm nginx is serving
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
@@ -53,7 +53,13 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
],
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": [
|
||||||
|
"src",
|
||||||
|
"src/styles"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||||
|
|
||||||
|
# Cache static assets (Angular uses content hashes)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache hashed JS/CSS bundles
|
||||||
|
location ~* \.(js|css)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy SignalR WebSocket connections to backend
|
||||||
|
location /hubs/ {
|
||||||
|
proxy_pass http://backend:8080/hubs/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Angular SPA — all other routes fall back to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.11.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^21.2.10",
|
||||||
|
"@angular/cdk": "^21.2.8",
|
||||||
|
"@angular/common": "^21.2.0",
|
||||||
|
"@angular/compiler": "^21.2.0",
|
||||||
|
"@angular/core": "^21.2.0",
|
||||||
|
"@angular/forms": "^21.2.0",
|
||||||
|
"@angular/material": "^21.2.8",
|
||||||
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/router": "^21.2.0",
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.2.8",
|
||||||
|
"@angular/cli": "^21.2.8",
|
||||||
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
@@ -5,7 +5,7 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: `<router-outlet />`,
|
template: '<router-outlet />',
|
||||||
styles: [`
|
styles: [`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<!-- ========================================================================== -->
|
||||||
|
<!-- AgentCard — per spec Section 7.3 -->
|
||||||
|
<!-- Integrates: Status Badge · Task Progress Bar · Quick‑Jump Button -->
|
||||||
|
<!-- Left‑border accent matches status color. role="article" + aria‑labels. -->
|
||||||
|
<!-- Enhanced: data-status attribute, elapsed time, design tokens. -->
|
||||||
|
<!-- ========================================================================== -->
|
||||||
|
<article
|
||||||
|
class="agent-card"
|
||||||
|
role="article"
|
||||||
|
[attr.data-status]="status"
|
||||||
|
[attr.aria-label]="(displayName || agentId) + ' — ' + statusLabel()"
|
||||||
|
[style.border-left-color]="statusBorderColor()"
|
||||||
|
(click)="cardClick.emit(sessionKey)"
|
||||||
|
appLongPress
|
||||||
|
[appLongPressDuration]="500"
|
||||||
|
(appLongPress)="cardLongPress.emit(sessionKey)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- ── Header: status badge + agent info ── -->
|
||||||
|
<div class="agent-card__header">
|
||||||
|
<div class="agent-card__badge" [attr.aria-label]="'Status: ' + statusLabel()">
|
||||||
|
<span
|
||||||
|
class="status-dot"
|
||||||
|
[ngClass]="[statusDotClass()]"
|
||||||
|
></span>
|
||||||
|
<span class="agent-card__status-label">{{ statusLabel() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent-card__identity">
|
||||||
|
<span class="agent-card__name">{{ displayName || agentId }}</span>
|
||||||
|
<span class="agent-card__role">{{ role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Body: current task ── -->
|
||||||
|
<div class="agent-card__body" *ngIf="task || isError()">
|
||||||
|
<p
|
||||||
|
class="agent-card__task"
|
||||||
|
[class.agent-card__task--error]="isError()"
|
||||||
|
[attr.aria-label]="'Current task: ' + (isError() ? errorMessage || task : task)"
|
||||||
|
>
|
||||||
|
{{ isError() ? errorMessage || task : task }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Task Progress Bar ── -->
|
||||||
|
<div
|
||||||
|
class="agent-card__progress"
|
||||||
|
*ngIf="showProgress()"
|
||||||
|
[attr.aria-label]="'Task progress: ' + progress + '%'"
|
||||||
|
>
|
||||||
|
<mat-progress-bar
|
||||||
|
mode="determinate"
|
||||||
|
[value]="progress"
|
||||||
|
[aria-label]="'Progress ' + progress + '% complete'"
|
||||||
|
></mat-progress-bar>
|
||||||
|
<span class="agent-card__progress-label text-mono">{{ progress }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Elapsed Time ── -->
|
||||||
|
<div
|
||||||
|
class="agent-card__elapsed"
|
||||||
|
*ngIf="taskElapsed && isActiveLike()"
|
||||||
|
[attr.aria-label]="'Elapsed: ' + taskElapsed"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true" class="agent-card__elapsed-icon">schedule</mat-icon>
|
||||||
|
<span class="text-mono">{{ taskElapsed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footer: channel + last activity + quick‑jump ── -->
|
||||||
|
<div class="agent-card__footer">
|
||||||
|
<div class="agent-card__meta">
|
||||||
|
<span
|
||||||
|
class="agent-card__channel text-mono"
|
||||||
|
[attr.aria-label]="'Channel: ' + channel"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">{{ channelIcon() }}</mat-icon>
|
||||||
|
{{ channel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="agent-card__last-activity text-mono"
|
||||||
|
[attr.aria-label]="'Last activity: ' + lastActivityLabel()"
|
||||||
|
>
|
||||||
|
{{ lastActivityLabel() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick‑Jump Button -->
|
||||||
|
<a
|
||||||
|
class="agent-card__jump"
|
||||||
|
mat-button
|
||||||
|
[routerLink]="jumpRoute()"
|
||||||
|
[attr.aria-label]="'Jump to session for ' + (displayName || agentId)"
|
||||||
|
matTooltip="Jump to session"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// AgentCard — M3 tactical dark styling
|
||||||
|
// Per spec Section 7.3: left‑border accent, status‑aware coloring,
|
||||||
|
// responsive card layout with 320px min‑width.
|
||||||
|
// Enhanced: data-status selectors, elapsed time, design token imports.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@use 'tokens' as tokens;
|
||||||
|
|
||||||
|
.agent-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: tokens.$cc-card-min-width;
|
||||||
|
padding: tokens.$cc-card-padding;
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
border-radius: tokens.$cc-card-border-radius;
|
||||||
|
border-left: 4px solid var(--status-offline); // default; overridden by [style]
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
border-right: 1px solid var(--cc-outline);
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
gap: 16px;
|
||||||
|
transition: border-left-color 0.3s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CUB-26: Card is now clickable for session drawer
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header ──
|
||||||
|
.agent-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--status-active-bg); // overridden per status below
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__role {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body ──
|
||||||
|
.agent-card__body {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__task {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress Bar ──
|
||||||
|
.agent-card__progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__progress-label {
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override mat-progress-bar to match tactical dark theme
|
||||||
|
.agent-card__progress ::ng-deep .mat-mdc-progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
.mdc-linear-progress__bar-inner {
|
||||||
|
background-color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-linear-progress__track {
|
||||||
|
background-color: var(--cc-outline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elapsed Time ──
|
||||||
|
.agent-card__elapsed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__elapsed-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ──
|
||||||
|
.agent-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto; // push footer to bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__channel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__channel .mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__last-activity {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick‑Jump Button ──
|
||||||
|
.agent-card__jump {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.mat-mdc-button {
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status‑specific background tints for badge ──
|
||||||
|
// Using data-status attribute selectors for clean styling.
|
||||||
|
|
||||||
|
.agent-card[data-status="active"] .agent-card__badge {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="idle"] .agent-card__badge {
|
||||||
|
background-color: var(--status-idle-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="thinking"] .agent-card__badge {
|
||||||
|
background-color: var(--status-thinking-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="error"] .agent-card__badge {
|
||||||
|
background-color: var(--status-error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="offline"] .agent-card__badge {
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active‑like pulse on card border ──
|
||||||
|
.agent-card[data-status="active"],
|
||||||
|
.agent-card[data-status="thinking"] {
|
||||||
|
border-left-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="error"] {
|
||||||
|
border-left-color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Responsive ──
|
||||||
|
@media (max-width: tokens.$cc-breakpoint-mobile) {
|
||||||
|
.agent-card {
|
||||||
|
min-width: unset;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__meta {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accessibility: reduced motion ──
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.agent-card {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { AgentStatus } from '../../../models/agent.model';
|
||||||
|
import { LongPressDirective } from '../../../directives/long-press.directive';
|
||||||
|
import {
|
||||||
|
STATUS_COLORS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
CHANNEL_ICONS,
|
||||||
|
} from '../../../design/tokens';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AgentCard Component
|
||||||
|
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
|
||||||
|
// and Quick‑Jump Button into a card with left‑border status accent.
|
||||||
|
// CUB-26: Emits cardClick and cardLongPress for drawer/modal integration.
|
||||||
|
// Enhanced with data-status attribute, elapsed time, and design tokens.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-agent-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
LongPressDirective,
|
||||||
|
],
|
||||||
|
templateUrl: './agent-card.component.html',
|
||||||
|
styleUrl: './agent-card.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AgentCardComponent implements OnDestroy {
|
||||||
|
// --- Six required inputs per spec ---
|
||||||
|
|
||||||
|
/** Agent status — drives badge color and left‑border accent */
|
||||||
|
@Input({ required: true }) status!: AgentStatus;
|
||||||
|
|
||||||
|
/** Current task description, e.g. "Reviewing PR #42" */
|
||||||
|
@Input() task = '';
|
||||||
|
|
||||||
|
/** Task progress percentage 0–100 */
|
||||||
|
@Input() progress = 0;
|
||||||
|
|
||||||
|
/** Full session key for quick‑jump navigation */
|
||||||
|
@Input({ required: true }) sessionKey = '';
|
||||||
|
|
||||||
|
/** Communication channel, e.g. "telegram" */
|
||||||
|
@Input({ required: true }) channel = '';
|
||||||
|
|
||||||
|
/** Timestamp of last agent activity */
|
||||||
|
@Input({ required: true }) lastActivity!: Date;
|
||||||
|
|
||||||
|
// --- Additional display inputs ---
|
||||||
|
|
||||||
|
/** Short agent ID, e.g. "otto" */
|
||||||
|
@Input() agentId = '';
|
||||||
|
|
||||||
|
/** Display name, e.g. "Otto" */
|
||||||
|
@Input() displayName = '';
|
||||||
|
|
||||||
|
/** Role description, e.g. "Orchestrator Agent" */
|
||||||
|
@Input() role = '';
|
||||||
|
|
||||||
|
/** Error message (shown only when status is 'error') */
|
||||||
|
@Input() errorMessage = '';
|
||||||
|
|
||||||
|
/** Elapsed time string, e.g. "04m 12s" */
|
||||||
|
@Input() taskElapsed = '';
|
||||||
|
|
||||||
|
// --- CUB-26: Outputs for drawer/modal integration ---
|
||||||
|
|
||||||
|
/** Emitted when the card is clicked — opens the session drawer. */
|
||||||
|
@Output() readonly cardClick = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** Emitted when the card is long-pressed — bypasses drawer, opens session log directly. */
|
||||||
|
@Output() readonly cardLongPress = new EventEmitter<string>();
|
||||||
|
|
||||||
|
// --- Internal state ---
|
||||||
|
|
||||||
|
/** Timer for refreshing relative-time label */
|
||||||
|
private _timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Internal signal to trigger relative-time recomputation */
|
||||||
|
private readonly _tick = signal(0);
|
||||||
|
|
||||||
|
// --- Computed values using design tokens ---
|
||||||
|
|
||||||
|
/** Map status → CSS custom property for the left‑border accent */
|
||||||
|
readonly statusBorderColor = computed(() => {
|
||||||
|
const map: Record<AgentStatus, string> = {
|
||||||
|
active: 'var(--status-active)',
|
||||||
|
idle: 'var(--status-idle)',
|
||||||
|
thinking: 'var(--status-thinking)',
|
||||||
|
error: 'var(--status-error)',
|
||||||
|
offline: 'var(--status-offline)',
|
||||||
|
};
|
||||||
|
return map[this.status] ?? 'var(--status-offline)';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Human‑readable status label (from design tokens) */
|
||||||
|
readonly statusLabel = computed(() => STATUS_LABELS[this.status] ?? this.status);
|
||||||
|
|
||||||
|
/** CSS class suffix for the status badge dot */
|
||||||
|
readonly statusDotClass = computed(() => `status-dot--${this.status}`);
|
||||||
|
|
||||||
|
/** Material icon name for the channel (from design tokens) */
|
||||||
|
readonly channelIcon = computed(() => CHANNEL_ICONS[this.channel] ?? 'chat');
|
||||||
|
|
||||||
|
/** Relative time string for lastActivity, refreshed every 30s */
|
||||||
|
readonly lastActivityLabel = computed(() => {
|
||||||
|
// Read tick to create dependency that forces recomputation
|
||||||
|
this._tick();
|
||||||
|
return this._relativeTime(this.lastActivity);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Quick‑jump route derived from sessionKey */
|
||||||
|
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
|
||||||
|
|
||||||
|
/** Whether progress bar should show */
|
||||||
|
readonly showProgress = computed(() => this.progress > 0 && this.status !== 'error');
|
||||||
|
|
||||||
|
/** Whether error state is active */
|
||||||
|
readonly isError = computed(() => this.status === 'error');
|
||||||
|
|
||||||
|
/** Whether card is in an active-like state (active or thinking) */
|
||||||
|
readonly isActiveLike = computed(() => this.status === 'active' || this.status === 'thinking');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Start the relative-time refresh timer
|
||||||
|
this._startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this._stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
private _relativeTime(date: Date | null | undefined): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const now = Date.now();
|
||||||
|
const then = date.getTime();
|
||||||
|
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
||||||
|
if (diffSec < 60) return 'just now';
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||||
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _startTimer(): void {
|
||||||
|
this._stopTimer();
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
// Increment tick to force lastActivityLabel recomputation
|
||||||
|
this._tick.update(v => v + 1);
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopTimer(): void {
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './agent-card/agent-card.component';
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
<!-- ======================================================================== -->
|
||||||
|
<!-- Adaptive Navigation — Desktop sidebar / Mobile header -->
|
||||||
|
<!-- Desktop (≥768px): 72px sidebar with full navigation items -->
|
||||||
|
<!-- Mobile (<768px): 56px compact header with hamburger menu -->
|
||||||
|
<!-- ======================================================================== -->
|
||||||
|
|
||||||
|
<!-- ======================= DESKTOP SIDEBAR (≥768px) ======================= -->
|
||||||
|
<aside class="adaptive-nav__sidebar" aria-label="Navigation sidebar">
|
||||||
|
<!-- Brand / Toggle header -->
|
||||||
|
<div class="adaptive-nav__sidebar-header">
|
||||||
|
<span class="adaptive-nav__brand">OC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation destinations -->
|
||||||
|
<nav class="adaptive-nav__sidebar-nav">
|
||||||
|
@for (dest of destinations; track dest.route) {
|
||||||
|
<a
|
||||||
|
class="adaptive-nav__sidebar-item"
|
||||||
|
[routerLink]="dest.route"
|
||||||
|
routerLinkActive="adaptive-nav__sidebar-item--active"
|
||||||
|
[attr.aria-label]="dest.label"
|
||||||
|
>
|
||||||
|
<mat-icon
|
||||||
|
[matBadge]="dest.badge ?? 0"
|
||||||
|
[matBadgeHidden]="!dest.badge"
|
||||||
|
matBadgePosition="above after"
|
||||||
|
matBadgeSize="small"
|
||||||
|
>
|
||||||
|
{{ dest.icon }}
|
||||||
|
</mat-icon>
|
||||||
|
<span class="adaptive-nav__sidebar-label">{{ dest.label }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Sidebar footer: LIVE indicator + action buttons -->
|
||||||
|
<div class="adaptive-nav__sidebar-footer">
|
||||||
|
<div class="adaptive-nav__live" [class.adaptive-nav__live--connected]="isConnected()">
|
||||||
|
<span
|
||||||
|
class="adaptive-nav__live-dot"
|
||||||
|
[class.adaptive-nav__live-dot--connected]="isConnected()"
|
||||||
|
></span>
|
||||||
|
<mat-chip
|
||||||
|
class="adaptive-nav__live-chip"
|
||||||
|
[highlighted]="isConnected()"
|
||||||
|
[disabled]="!isConnected()"
|
||||||
|
>
|
||||||
|
{{ isConnected() ? 'LIVE' : 'OFFLINE' }}
|
||||||
|
</mat-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons (placeholder) -->
|
||||||
|
<div class="adaptive-nav__sidebar-actions">
|
||||||
|
<button mat-icon-button aria-label="Notifications">
|
||||||
|
<mat-icon>notifications</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button aria-label="Settings">
|
||||||
|
<mat-icon>settings</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ======================= MOBILE HEADER (<768px) ======================== -->
|
||||||
|
<header class="adaptive-nav__mobile-header" role="banner">
|
||||||
|
<!-- Hamburger menu button -->
|
||||||
|
<button
|
||||||
|
class="adaptive-nav__hamburger"
|
||||||
|
mat-icon-button
|
||||||
|
[attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"
|
||||||
|
[attr.aria-expanded]="mobileMenuOpen()"
|
||||||
|
(click)="toggleMobileMenu()"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ mobileMenuOpen() ? 'close' : 'menu' }}</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="adaptive-nav__mobile-title">Command Hub</h1>
|
||||||
|
|
||||||
|
<!-- LIVE indicator (always visible on mobile) -->
|
||||||
|
<div class="adaptive-nav__live adaptive-nav__live--mobile" [class.adaptive-nav__live--connected]="isConnected()">
|
||||||
|
<span
|
||||||
|
class="adaptive-nav__live-dot"
|
||||||
|
[class.adaptive-nav__live-dot--connected]="isConnected()"
|
||||||
|
></span>
|
||||||
|
<span class="adaptive-nav__live-text">{{ isConnected() ? 'LIVE' : 'OFFLINE' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile action buttons (placeholder) -->
|
||||||
|
<button class="adaptive-nav__mobile-action" mat-icon-button aria-label="Notifications">
|
||||||
|
<mat-icon>notifications</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ======================= MOBILE DRAWER OVERLAY ========================= -->
|
||||||
|
@if (mobileMenuOpen()) {
|
||||||
|
<div class="adaptive-nav__overlay" (click)="closeMobileMenu()" role="presentation"></div>
|
||||||
|
<nav class="adaptive-nav__mobile-drawer" aria-label="Mobile navigation menu">
|
||||||
|
@for (dest of destinations; track dest.route) {
|
||||||
|
<a
|
||||||
|
class="adaptive-nav__drawer-item"
|
||||||
|
[routerLink]="dest.route"
|
||||||
|
routerLinkActive="adaptive-nav__drawer-item--active"
|
||||||
|
[attr.aria-label]="dest.label"
|
||||||
|
(click)="closeMobileMenu()"
|
||||||
|
>
|
||||||
|
<mat-icon>{{ dest.icon }}</mat-icon>
|
||||||
|
<span class="adaptive-nav__drawer-label">{{ dest.label }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
+365
@@ -0,0 +1,365 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Adaptive Navigation — Desktop sidebar / Mobile header
|
||||||
|
// Per CUB-27 spec breakpoints:
|
||||||
|
// Compact (0–599px): Mobile header + hamburger + drawer
|
||||||
|
// Medium (600–1023px): Collapsed sidebar (icon-only, 72px)
|
||||||
|
// Expanded (≥1024px): Full sidebar with labels (72px collapsed, 256px expanded)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Desktop Sidebar (visible ≥600px)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.adaptive-nav__sidebar {
|
||||||
|
display: none; // Hidden by default (mobile-first)
|
||||||
|
flex-direction: column;
|
||||||
|
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
border-right: 1px solid var(--cc-outline);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 64px;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__brand {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--status-active);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 2px 8px;
|
||||||
|
border-radius: 28px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sidebar Footer — LIVE indicator + action buttons
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.adaptive-nav__sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0 20px;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.mat-mdc-icon-button {
|
||||||
|
color: var(--cc-on-surface-variant) !important;
|
||||||
|
--mdc-icon-button-icon-size: 20px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LIVE Status Indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.adaptive-nav__live {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
|
||||||
|
&--connected {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
min-width: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--status-error);
|
||||||
|
transition: background-color 200ms ease;
|
||||||
|
|
||||||
|
&--connected {
|
||||||
|
background-color: var(--status-active);
|
||||||
|
animation: pulse-active 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__live-chip {
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
height: 24px !important;
|
||||||
|
min-height: 24px !important;
|
||||||
|
padding: 0 8px !important;
|
||||||
|
color: var(--status-active) !important;
|
||||||
|
--mdc-chip-elevated-container-color: transparent;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__live-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile Header (visible <600px only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.adaptive-nav__mobile-header {
|
||||||
|
display: none; // Hidden on desktop, shown on mobile via media query
|
||||||
|
align-items: center;
|
||||||
|
height: var(--cc-header-height-compact, 56px);
|
||||||
|
padding: 0 12px;
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
z-index: 20;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__hamburger {
|
||||||
|
color: var(--cc-on-surface-variant) !important;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__live--mobile {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
.adaptive-nav__live-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-action {
|
||||||
|
color: var(--cc-on-surface-variant) !important;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile Drawer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.adaptive-nav__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--cc-header-height-compact, 56px); // Below header
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
max-width: 80vw;
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
border-right: 1px solid var(--cc-outline);
|
||||||
|
z-index: 50;
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slide-in-left 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__drawer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 20px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
|
||||||
|
.adaptive-nav__drawer-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__drawer-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawer slide-in animation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Media Queries — Layout Switch (CUB-27 breakpoints)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Compact (0–599px): Show mobile header, hide sidebar
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.adaptive-nav__sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-header {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide mobile drawer and overlay on desktop
|
||||||
|
.adaptive-nav__overlay,
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
// These are conditional via @if in template, no display:none needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium (600–1023px): Show collapsed sidebar (icon-only), hide mobile
|
||||||
|
@media (min-width: 600px) and (max-width: 1023px) {
|
||||||
|
.adaptive-nav__sidebar {
|
||||||
|
display: flex;
|
||||||
|
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide labels on medium (collapsed)
|
||||||
|
.adaptive-nav__sidebar-label,
|
||||||
|
.adaptive-nav__brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-item {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__overlay,
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded (≥1024px): Show sidebar with labels
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.adaptive-nav__sidebar {
|
||||||
|
display: flex;
|
||||||
|
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||||
|
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__overlay,
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility: Reduced Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.adaptive-nav__live-dot--connected {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, signal, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
|
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptive Navigation Component — switches between desktop sidebar
|
||||||
|
* and mobile header layouts using CSS media queries + JS breakpoint sync.
|
||||||
|
*
|
||||||
|
* Per CUB-27 spec breakpoints:
|
||||||
|
* Compact (0–599px): Mobile header + hamburger + bottom nav
|
||||||
|
* Medium (600–1023px): Collapsed sidebar (icon-only)
|
||||||
|
* Expanded (≥1024px): Expandable sidebar (hover/click)
|
||||||
|
*
|
||||||
|
* The LIVE status indicator is visible in all layouts.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-adaptive-navigation',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatBadgeModule,
|
||||||
|
],
|
||||||
|
templateUrl: './adaptive-navigation.component.html',
|
||||||
|
styleUrl: './adaptive-navigation.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AdaptiveNavigationComponent implements OnInit, OnDestroy {
|
||||||
|
/** Navigation destinations shared with other nav components */
|
||||||
|
protected readonly destinations = NAV_DESTINATIONS;
|
||||||
|
|
||||||
|
/** Whether the mobile drawer is open */
|
||||||
|
protected readonly mobileMenuOpen = signal(false);
|
||||||
|
|
||||||
|
/** Live connection status */
|
||||||
|
protected readonly isConnected = signal(true);
|
||||||
|
|
||||||
|
/** Responsive breakpoint state */
|
||||||
|
protected readonly isMedium = signal(false);
|
||||||
|
protected readonly isExpanded = signal(false);
|
||||||
|
|
||||||
|
private readonly COMPACT_MAX = 599;
|
||||||
|
private readonly MEDIUM_MAX = 1023;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.updateBreakpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onResize(): void {
|
||||||
|
this.updateBreakpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle mobile menu */
|
||||||
|
toggleMobileMenu(): void {
|
||||||
|
this.mobileMenuOpen.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close mobile menu (e.g. on nav) */
|
||||||
|
closeMobileMenu(): void {
|
||||||
|
this.mobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBreakpoint(): void {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
this.isMedium.set(w >= this.COMPACT_MAX + 1 && w <= this.MEDIUM_MAX);
|
||||||
|
this.isExpanded.set(w > this.MEDIUM_MAX);
|
||||||
|
// Close mobile menu when switching to desktop
|
||||||
|
if (w > this.COMPACT_MAX) {
|
||||||
|
this.mobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// HostListener auto-unsubscribes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './adaptive-navigation.component';
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
<!-- ============================================================================
|
||||||
|
Agent Session Drawer — CUB-26
|
||||||
|
Desktop: 480px side drawer slides from right with modal overlay.
|
||||||
|
Mobile: Bottom sheet slides up from bottom.
|
||||||
|
Shows: Agent name, status badge, session key, live log tail,
|
||||||
|
recent messages, and action buttons.
|
||||||
|
============================================================================-->
|
||||||
|
|
||||||
|
<!-- Backdrop overlay -->
|
||||||
|
@if (isOpen()) {
|
||||||
|
<div
|
||||||
|
class="session-drawer-backdrop"
|
||||||
|
(click)="onBackdropClick()"
|
||||||
|
[class.session-drawer-backdrop--visible]="isOpen()"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Drawer panel -->
|
||||||
|
<div
|
||||||
|
#drawerPanel
|
||||||
|
class="session-drawer"
|
||||||
|
[class.session-drawer--open]="isOpen()"
|
||||||
|
[class.session-drawer--mobile]="isMobile"
|
||||||
|
(keydown)="onDrawerKeydown($event)"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Agent session details"
|
||||||
|
[attr.aria-hidden]="!isOpen()"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="session-drawer__header">
|
||||||
|
@if (agent) {
|
||||||
|
<div class="session-drawer__header-identity">
|
||||||
|
<span class="status-dot {{ getStatusClass(agent.status) }}" [attr.aria-label]="getStatusLabel(agent.status)"></span>
|
||||||
|
<div class="session-drawer__header-text">
|
||||||
|
<h2 class="session-drawer__title">{{ agent.displayName }}</h2>
|
||||||
|
<span class="session-drawer__role">{{ agent.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="session-drawer__close-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
(click)="close()"
|
||||||
|
matIconButton
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
@if (agent) {
|
||||||
|
<div class="session-drawer__content">
|
||||||
|
|
||||||
|
<!-- Status & Session Key Section -->
|
||||||
|
<section class="session-drawer__section">
|
||||||
|
<div class="session-drawer__meta-row">
|
||||||
|
<span class="session-drawer__status-chip {{ getStatusChipColor(agent.status) }}">
|
||||||
|
{{ getStatusLabel(agent.status) }}
|
||||||
|
</span>
|
||||||
|
@if (agent.channel) {
|
||||||
|
<span class="session-drawer__channel-badge">
|
||||||
|
<mat-icon class="session-drawer__channel-icon">chat</mat-icon>
|
||||||
|
{{ agent.channel }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="session-drawer__session-key">
|
||||||
|
<span class="session-drawer__label">Session Key</span>
|
||||||
|
<code class="session-drawer__key-value">{{ agent.sessionKey }}</code>
|
||||||
|
</div>
|
||||||
|
@if (agent.currentTask) {
|
||||||
|
<div class="session-drawer__task-info">
|
||||||
|
<span class="session-drawer__label">Current Task</span>
|
||||||
|
<span class="session-drawer__task-text">{{ agent.currentTask }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="session-drawer__last-activity">
|
||||||
|
<span class="session-drawer__label">Last Activity</span>
|
||||||
|
<span class="session-drawer__activity-time">{{ formatRelativeTime(agent.lastActivity) }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Messages Section -->
|
||||||
|
<section class="session-drawer__section">
|
||||||
|
<h3 class="session-drawer__section-title">Recent Messages</h3>
|
||||||
|
<div class="session-drawer__messages">
|
||||||
|
@for (msg of recentMessages(); track msg.id) {
|
||||||
|
<div class="session-drawer__message session-drawer__message--{{ msg.sender }}">
|
||||||
|
<span class="session-drawer__message-sender">
|
||||||
|
{{ msg.sender === 'agent' ? agent.displayName : 'You' }}
|
||||||
|
</span>
|
||||||
|
<p class="session-drawer__message-text">{{ msg.content }}</p>
|
||||||
|
<span class="session-drawer__message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="session-drawer__empty-state">No recent messages</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Live Log Tail Section -->
|
||||||
|
<section class="session-drawer__section">
|
||||||
|
<h3 class="session-drawer__section-title">Live Log</h3>
|
||||||
|
<div class="session-drawer__log-container">
|
||||||
|
@for (line of logLines(); track $index) {
|
||||||
|
<div class="session-drawer__log-line {{ getLogLevelClass(line.level) }}">
|
||||||
|
<span class="session-drawer__log-time">{{ formatTime(line.timestamp) }}</span>
|
||||||
|
<span class="session-drawer__log-level">{{ line.level.toUpperCase() }}</span>
|
||||||
|
<span class="session-drawer__log-message">{{ line.message }}</span>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="session-drawer__empty-state">No log output</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action buttons (sticky footer) -->
|
||||||
|
<div class="session-drawer__actions">
|
||||||
|
<button
|
||||||
|
class="session-drawer__action-btn session-drawer__action-btn--primary"
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="onOpenSession()"
|
||||||
|
>
|
||||||
|
<mat-icon>open_in_new</mat-icon>
|
||||||
|
Open Full Session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="session-drawer__action-btn session-drawer__action-btn--secondary"
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="onPinToDashboard()"
|
||||||
|
>
|
||||||
|
<mat-icon>push_pin</mat-icon>
|
||||||
|
Pin to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+500
@@ -0,0 +1,500 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Agent Session Drawer — CUB-26
|
||||||
|
// Desktop: 480px side drawer slides from right with modal overlay.
|
||||||
|
// Mobile: Bottom sheet slides up from bottom.
|
||||||
|
// Uses Control Center design tokens from CUB-21.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backdrop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 998;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawer Panel — Desktop: Side drawer from right
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 100vw;
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
border-left: 1px solid var(--cc-outline);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile: Bottom Sheet — slides up from bottom
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
&--mobile {
|
||||||
|
top: auto;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
&.session-drawer--open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag handle for mobile bottom sheet
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--cc-on-surface-variant);
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 8px auto 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__header-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__role {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__close-btn {
|
||||||
|
--mat-icon-button-state-layer-color: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content — scrollable area
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__section {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Meta Row — Status + Channel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
&.status-chip--active {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--idle {
|
||||||
|
background-color: var(--status-idle-bg);
|
||||||
|
color: var(--status-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--thinking {
|
||||||
|
background-color: var(--status-thinking-bg);
|
||||||
|
color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--error {
|
||||||
|
background-color: var(--status-error-bg);
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--offline {
|
||||||
|
background-color: rgba(100, 116, 139, 0.12);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__channel-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--cc-surface-container-high);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__channel-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session Key
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__session-key {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__key-value {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
background: var(--cc-surface);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task Info
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__task-info {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__task-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Last Activity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__last-activity {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__activity-time {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recent Messages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&--agent {
|
||||||
|
background: var(--cc-surface-container-high);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--user {
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message-sender {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message-time {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live Log Container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__log-container {
|
||||||
|
background: var(--cc-surface);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 1px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
color: #FBBF24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--debug {
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-time {
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-level {
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-message {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__empty-state {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action Buttons — Sticky Footer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile Adjustments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer--mobile {
|
||||||
|
.session-drawer__header {
|
||||||
|
padding: 12px 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__content {
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__actions {
|
||||||
|
padding: 12px 20px 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.session-drawer__action-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-container {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Responsive — wider viewports keep 480px, narrow go full-width
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.session-drawer:not(.session-drawer--mobile) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility: Reduced Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.session-drawer {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer-backdrop {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { AgentCardData, AgentStatus } from '../../models/agent.model';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Agent Session Drawer — Per CUB-26
|
||||||
|
// Desktop: 480px side drawer slides from right with modal overlay.
|
||||||
|
// Mobile: Bottom sheet slides up from bottom.
|
||||||
|
// Shows: Agent name, status badge, session key, live log tail,
|
||||||
|
// recent messages, and action buttons.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SessionLogLine {
|
||||||
|
timestamp: Date;
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMessage {
|
||||||
|
id: string;
|
||||||
|
sender: 'agent' | 'user';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-agent-session-drawer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatButtonModule, MatIconModule, MatChipsModule],
|
||||||
|
templateUrl: './agent-session-drawer.component.html',
|
||||||
|
styleUrl: './agent-session-drawer.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AgentSessionDrawerComponent implements OnDestroy {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The agent whose session details are displayed. */
|
||||||
|
@Input() set agent(value: AgentCardData | null) {
|
||||||
|
this._agent = value;
|
||||||
|
if (value) {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.loadSessionData(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get agent(): AgentCardData | null {
|
||||||
|
return this._agent;
|
||||||
|
}
|
||||||
|
private _agent: AgentCardData | null = null;
|
||||||
|
|
||||||
|
/** Whether this is mobile viewport (bottom sheet mode). */
|
||||||
|
@Input() isMobile = false;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Emitted when the user clicks "Open Full Session". Payload is the session key. */
|
||||||
|
@Output() readonly openSession = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** Emitted when the user clicks "Pin to Dashboard". Payload is the session key. */
|
||||||
|
@Output() readonly pinToDashboard = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** Emitted when the drawer closes. */
|
||||||
|
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Signals
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
readonly isOpen = signal(false);
|
||||||
|
readonly logLines = signal<SessionLogLine[]>([]);
|
||||||
|
readonly recentMessages = signal<SessionMessage[]>([]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View Children
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getStatusClass(status: string): string {
|
||||||
|
return `status-dot--${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: AgentStatus): string {
|
||||||
|
const labels: Record<AgentStatus, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
idle: 'Idle',
|
||||||
|
thinking: 'Thinking…',
|
||||||
|
error: 'Error',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
return labels[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusChipColor(status: AgentStatus): string {
|
||||||
|
const map: Record<AgentStatus, string> = {
|
||||||
|
active: 'status-chip--active',
|
||||||
|
idle: 'status-chip--idle',
|
||||||
|
thinking: 'status-chip--thinking',
|
||||||
|
error: 'status-chip--error',
|
||||||
|
offline: 'status-chip--offline',
|
||||||
|
};
|
||||||
|
return map[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogLevelClass(level: SessionLogLine['level']): string {
|
||||||
|
return `log-line--${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date to a short time string. */
|
||||||
|
formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date to a relative time string. */
|
||||||
|
formatRelativeTime(date: Date): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = date.getTime();
|
||||||
|
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
||||||
|
if (diffSec < 60) return 'just now';
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||||
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open the drawer for a specific agent. */
|
||||||
|
open(agentData: AgentCardData): void {
|
||||||
|
this._agent = agentData;
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.loadSessionData(agentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the drawer. */
|
||||||
|
close(): void {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
this.drawerClose.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard Handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle keyboard navigation within the drawer. */
|
||||||
|
onDrawerKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab through actions — browser default Tab behavior is fine,
|
||||||
|
// we just trap focus within the drawer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outside Click
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onBackdropClick(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onOpenSession(): void {
|
||||||
|
if (this._agent) {
|
||||||
|
this.openSession.emit(this._agent.sessionKey);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPinToDashboard(): void {
|
||||||
|
if (this._agent) {
|
||||||
|
this.pinToDashboard.emit(this._agent.sessionKey);
|
||||||
|
}
|
||||||
|
// Don't close — user may want to keep viewing
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Clean up any subscriptions when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Load mock session data for the agent (TODO: wire to real data service). */
|
||||||
|
private loadSessionData(agentData: AgentCardData): void {
|
||||||
|
// TODO: Replace with real session data service when available.
|
||||||
|
// For now, generate placeholder log lines and messages.
|
||||||
|
const now = new Date();
|
||||||
|
const logLines: SessionLogLine[] = [];
|
||||||
|
for (let i = 19; i >= 0; i--) {
|
||||||
|
const ts = new Date(now.getTime() - i * 5000);
|
||||||
|
const levels: SessionLogLine['level'][] = ['info', 'info', 'info', 'debug', 'warn'];
|
||||||
|
const messages = [
|
||||||
|
`Processing task queue for ${agentData.displayName}`,
|
||||||
|
`SignalR heartbeat OK`,
|
||||||
|
`Session state: active`,
|
||||||
|
`Checking for pending commands…`,
|
||||||
|
`Updating task progress: ${Math.floor(Math.random() * 100)}%`,
|
||||||
|
];
|
||||||
|
logLines.push({
|
||||||
|
timestamp: ts,
|
||||||
|
level: levels[i % levels.length],
|
||||||
|
message: messages[i % messages.length],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logLines.set(logLines);
|
||||||
|
|
||||||
|
const recentMessages: SessionMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
sender: 'user',
|
||||||
|
content: `Hey ${agentData.displayName}, how's the current task going?`,
|
||||||
|
timestamp: new Date(now.getTime() - 120000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
sender: 'agent',
|
||||||
|
content: agentData.currentTask ?? 'Working on it — progress is steady.',
|
||||||
|
timestamp: new Date(now.getTime() - 115000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
sender: 'user',
|
||||||
|
content: 'Great, let me know if you hit any blockers.',
|
||||||
|
timestamp: new Date(now.getTime() - 110000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.recentMessages.set(recentMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AgentSessionDrawerComponent } from './agent-session-drawer.component';
|
||||||
|
export type { SessionLogLine, SessionMessage } from './agent-session-drawer.component';
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<span class="badge"
|
||||||
|
[class]="statusClass"
|
||||||
|
[class.badge--pulse]="hasPulse"
|
||||||
|
[attr.aria-label]="'Agent status: ' + displayLabel"
|
||||||
|
role="status">
|
||||||
|
<span class="badge__dot"></span>
|
||||||
|
<span class="badge__label">{{ displayLabel }}</span>
|
||||||
|
</span>
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Agent Status Badge — per spec Section 7.3
|
||||||
|
// Colored pill with dot indicator and optional pulse animation.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
$badge-height: 24px;
|
||||||
|
$dot-size: 8px;
|
||||||
|
$border-radius: 12px;
|
||||||
|
$font-size: 12px;
|
||||||
|
$font-weight: 500;
|
||||||
|
$padding-x: 8px;
|
||||||
|
$gap: 6px;
|
||||||
|
|
||||||
|
@use 'sass:color';
|
||||||
|
|
||||||
|
// Status color palette
|
||||||
|
$color-active: #22c55e; // green-500
|
||||||
|
$color-idle: #9ca3af; // gray-400
|
||||||
|
$color-thinking: #3b82f6; // blue-500
|
||||||
|
$color-error: #ef4444; // red-500
|
||||||
|
$color-offline: #9ca3af; // gray-400
|
||||||
|
|
||||||
|
// Background tints (12% opacity for soft pill background)
|
||||||
|
$bg-active: rgba($color-active, 0.12);
|
||||||
|
$bg-idle: rgba($color-idle, 0.12);
|
||||||
|
$bg-thinking: rgba($color-thinking, 0.12);
|
||||||
|
$bg-error: rgba($color-error, 0.12);
|
||||||
|
$bg-offline: rgba($color-offline, 0.12);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Base pill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: $badge-height;
|
||||||
|
padding: 0 $padding-x;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
gap: $gap;
|
||||||
|
font-size: $font-size;
|
||||||
|
font-weight: $font-weight;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dot indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.badge__dot {
|
||||||
|
width: $dot-size;
|
||||||
|
height: $dot-size;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Label text
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.badge__label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status color variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.badge--active {
|
||||||
|
background: $bg-active;
|
||||||
|
color: color.adjust($color-active, $lightness: -10%);
|
||||||
|
|
||||||
|
.badge__dot {
|
||||||
|
background: $color-active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--idle {
|
||||||
|
background: $bg-idle;
|
||||||
|
color: color.adjust($color-idle, $lightness: -15%);
|
||||||
|
|
||||||
|
.badge__dot {
|
||||||
|
background: $color-idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--thinking {
|
||||||
|
background: $bg-thinking;
|
||||||
|
color: color.adjust($color-thinking, $lightness: -10%);
|
||||||
|
|
||||||
|
.badge__dot {
|
||||||
|
background: $color-thinking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--error {
|
||||||
|
background: $bg-error;
|
||||||
|
color: color.adjust($color-error, $lightness: -10%);
|
||||||
|
|
||||||
|
.badge__dot {
|
||||||
|
background: $color-error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--offline {
|
||||||
|
background: $bg-offline;
|
||||||
|
color: color.adjust($color-offline, $lightness: -15%);
|
||||||
|
|
||||||
|
.badge__dot {
|
||||||
|
background: $color-offline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pulse animation — applied when status is active, thinking, or error
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.badge--pulse {
|
||||||
|
.badge__dot {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active: 2s pulse
|
||||||
|
.badge--active.badge--pulse .badge__dot {
|
||||||
|
animation-duration: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking: 3s pulse
|
||||||
|
.badge--thinking.badge--pulse .badge__dot {
|
||||||
|
animation-duration: 3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error: 0.8s pulse (fast, urgent)
|
||||||
|
.badge--error.badge--pulse .badge__dot {
|
||||||
|
animation-duration: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
import { AgentStatus } from '../../models/agent.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Status Badge component.
|
||||||
|
* Displays a colored pill with a pulse animation indicating the agent's current status.
|
||||||
|
* Per spec Section 7.3: Agent Card Component Interface — status indicator.
|
||||||
|
*
|
||||||
|
* Color mapping:
|
||||||
|
* - Active → green
|
||||||
|
* - Idle → gray
|
||||||
|
* - Thinking → blue
|
||||||
|
* - Error → red
|
||||||
|
* - Offline → gray (no pulse)
|
||||||
|
*
|
||||||
|
* Pulse animations:
|
||||||
|
* - Active → 2s
|
||||||
|
* - Error → 0.8s
|
||||||
|
* - Thinking → 3s
|
||||||
|
* - Idle / Offline → no pulse
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-agent-status-badge',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './agent-status-badge.component.html',
|
||||||
|
styleUrl: './agent-status-badge.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AgentStatusBadgeComponent {
|
||||||
|
/** Current agent status — binds to the AgentStatus type from the model. */
|
||||||
|
readonly status = input.required<AgentStatus>();
|
||||||
|
|
||||||
|
/** Label text shown inside the badge. Defaults to title-cased status. */
|
||||||
|
readonly label = input<string>();
|
||||||
|
|
||||||
|
get displayLabel(): string {
|
||||||
|
return this.label() ?? this.titleCase(this.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS class driven by the current status value. */
|
||||||
|
get statusClass(): string {
|
||||||
|
return `badge--${this.status()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the pulse animation should be active for the current status. */
|
||||||
|
get hasPulse(): boolean {
|
||||||
|
return this.status() === 'active' || this.status() === 'thinking' || this.status() === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
private titleCase(value: string): string {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AgentStatusBadgeComponent } from './agent-status-badge.component';
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<!-- Backdrop overlay — click to dismiss -->
|
||||||
|
<div class="global-action-modal__backdrop" #backdrop (click)="onBackdropClick()"></div>
|
||||||
|
|
||||||
|
<!-- Modal panel -->
|
||||||
|
<div class="global-action-modal__panel" (click)="onModalClick($event)" role="dialog" aria-modal="true" aria-label="Global Actions">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="global-action-modal__header">
|
||||||
|
<h2 class="global-action-modal__title">Global Actions</h2>
|
||||||
|
<button matIconButton
|
||||||
|
class="global-action-modal__close"
|
||||||
|
aria-label="Close modal"
|
||||||
|
(click)="onClose()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action grid -->
|
||||||
|
<div class="global-action-modal__actions">
|
||||||
|
@for (action of actions; track action.key) {
|
||||||
|
<button class="global-action-modal__action-btn global-action-modal__action-btn--{{ action.color }}"
|
||||||
|
(click)="onAction(action)">
|
||||||
|
<div class="global-action-modal__action-icon">
|
||||||
|
<mat-icon>{{ action.icon }}</mat-icon>
|
||||||
|
</div>
|
||||||
|
<span class="global-action-modal__action-label">{{ action.label }}</span>
|
||||||
|
<span class="global-action-modal__action-desc">{{ action.description }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Global Action Modal — Tactical Dark Mode Styling
|
||||||
|
// Uses Control Center design tokens from styles.scss
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backdrop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(560px, calc(100vw - 48px));
|
||||||
|
background: var(--cc-surface-container);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: var(--cc-card-border-radius);
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__close {
|
||||||
|
--mat-icon-button-state-layer-color: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action Grid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action Button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__action-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--cc-surface);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--cc-surface-container-high);
|
||||||
|
border-color: var(--cc-on-surface-variant);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mat-sys-primary, #38BDF8);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action icon wrapper
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action label
|
||||||
|
.global-action-modal__action-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action description
|
||||||
|
.global-action-modal__action-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color Variants — per-action accent colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__action-btn--deploy {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-btn--pause {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-idle-bg);
|
||||||
|
color: var(--status-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-btn--emergency {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-error-bg);
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-label {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-btn--add {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-thinking-bg);
|
||||||
|
color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Responsive — stack single column on narrow viewports
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.global-action-modal__actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Action Modal — overlay for fleet-wide commands.
|
||||||
|
*
|
||||||
|
* Four main actions: Deploy All, Pause All, Emergency Stop, Add Agent.
|
||||||
|
* Tactical Dark Mode styling using Control Center design tokens.
|
||||||
|
* Dismisses on backdrop click or close button.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-global-action-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatIconModule, MatButtonModule],
|
||||||
|
templateUrl: './global-action-modal.component.html',
|
||||||
|
styleUrl: './global-action-modal.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class GlobalActionModalComponent {
|
||||||
|
/** Emitted when any action button is clicked. Payload is the action key. */
|
||||||
|
@Output() readonly actionSelected = new EventEmitter<GlobalAction>();
|
||||||
|
|
||||||
|
/** Emitted when the modal is dismissed (backdrop click or close button). */
|
||||||
|
@Output() readonly dismissed = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('backdrop') backdropEl!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
/** All available global actions. */
|
||||||
|
readonly actions: GlobalActionDef[] = [
|
||||||
|
{
|
||||||
|
key: 'deploy-all',
|
||||||
|
label: 'Deploy All',
|
||||||
|
description: 'Deploy all agents in the fleet',
|
||||||
|
icon: 'rocket_launch',
|
||||||
|
color: 'deploy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pause-all',
|
||||||
|
label: 'Pause All',
|
||||||
|
description: 'Pause all running agents',
|
||||||
|
icon: 'pause_circle',
|
||||||
|
color: 'pause',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'emergency-stop',
|
||||||
|
label: 'Emergency Stop',
|
||||||
|
description: 'Immediately halt all agents',
|
||||||
|
icon: 'emergency',
|
||||||
|
color: 'emergency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'add-agent',
|
||||||
|
label: 'Add Agent',
|
||||||
|
description: 'Register a new agent to the fleet',
|
||||||
|
icon: 'person_add',
|
||||||
|
color: 'add',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
onBackdropClick(): void {
|
||||||
|
this.dismissed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModalClick(event: Event): void {
|
||||||
|
// Prevent clicks inside the modal panel from closing it
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.dismissed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAction(action: GlobalActionDef): void {
|
||||||
|
this.actionSelected.emit(action.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalAction = 'deploy-all' | 'pause-all' | 'emergency-stop' | 'add-agent';
|
||||||
|
|
||||||
|
export interface GlobalActionDef {
|
||||||
|
key: GlobalAction;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: 'deploy' | 'pause' | 'emergency' | 'add';
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './quick-jump-button/quick-jump-button.component';
|
||||||
|
export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component';
|
||||||
|
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';
|
||||||
|
export { AgentSessionDrawerComponent } from './agent-session-drawer/index';
|
||||||
|
export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="quick-jump-button"
|
||||||
|
[attr.aria-label]="'Jump to agent session'"
|
||||||
|
(click)="onJumpClick()"
|
||||||
|
>
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Quick-Jump Button — M3 FilledTonalIconButton
|
||||||
|
// Per spec Section 7.3: Agent Card Quick-Jump action
|
||||||
|
// M3 spec: FilledTonalIconButton uses secondary container color
|
||||||
|
// with 8% state layer overlay for hover/focus.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.quick-jump-button {
|
||||||
|
// M3 FilledTonalIconButton: secondary-container background
|
||||||
|
// Angular Material mat-icon-button sets up the base shape (40x40, round).
|
||||||
|
// We override the color tokens to match FilledTonal style.
|
||||||
|
--mdc-icon-button-icon-color: var(--mat-sys-on-secondary-container);
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
// M3 State Layer: 8% overlay on hover
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
// State layer overlay using a pseudo-element for precise 8% opacity
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--mat-sys-on-secondary-container);
|
||||||
|
opacity: 0.08;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3 State Layer: 12% overlay on focus-visible (slightly stronger for accessibility)
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
outline: 3px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--mat-sys-on-secondary-container);
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3 State Layer: 12% overlay on active/pressed
|
||||||
|
&:active {
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--mat-sys-on-secondary-container);
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon color stays on-secondary-container across all states
|
||||||
|
.mat-icon {
|
||||||
|
color: var(--mat-sys-on-secondary-container);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { MatIconButton } from '@angular/material/button';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-Jump Button — M3 FilledTonalIconButton
|
||||||
|
*
|
||||||
|
* An icon button that emits a navigation event for jumping to an agent session.
|
||||||
|
* Uses the Material Design 3 FilledTonalIconButton style with 8% state layer
|
||||||
|
* overlay on hover and focus.
|
||||||
|
*
|
||||||
|
* Per spec Section 7.3: Agent Card Component Interface
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quick-jump-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatIconButton, MatIcon],
|
||||||
|
templateUrl: './quick-jump-button.component.html',
|
||||||
|
styleUrl: './quick-jump-button.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class QuickJumpButtonComponent {
|
||||||
|
/** Emitted when the button is clicked, carrying the session key for navigation. */
|
||||||
|
@Output() jumpClick = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** The session key to navigate to. Set by the parent agent card. */
|
||||||
|
sessionKey = '';
|
||||||
|
|
||||||
|
onJumpClick(): void {
|
||||||
|
this.jumpClick.emit(this.sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { QuickJumpDrawerComponent } from './quick-jump-drawer.component';
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
<!-- ============================================================================
|
||||||
|
Quick-Jump Drawer — Slide-out panel for fast agent switching
|
||||||
|
Per CUB-51: Slides from right, agent list with status badges,
|
||||||
|
search/filter input, closes via ESC or outside click.
|
||||||
|
============================================================================-->
|
||||||
|
|
||||||
|
<!-- Backdrop overlay -->
|
||||||
|
@if (isOpen()) {
|
||||||
|
<div
|
||||||
|
class="quick-jump-backdrop"
|
||||||
|
(click)="onBackdropClick($event)"
|
||||||
|
[@backdropEnter]
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Drawer panel -->
|
||||||
|
<div
|
||||||
|
class="quick-jump-drawer"
|
||||||
|
[class.quick-jump-drawer--open]="isOpen()"
|
||||||
|
(keydown)="onDrawerKeydown($event)"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Quick jump to agent"
|
||||||
|
[attr.aria-hidden]="!isOpen()"
|
||||||
|
>
|
||||||
|
<!-- Drawer header -->
|
||||||
|
<div class="quick-jump-drawer__header">
|
||||||
|
<h2 class="quick-jump-drawer__title">Jump to Agent</h2>
|
||||||
|
<button
|
||||||
|
class="quick-jump-drawer__close-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
(click)="close()"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="quick-jump-drawer__search">
|
||||||
|
<span class="quick-jump-drawer__search-icon">search</span>
|
||||||
|
<input
|
||||||
|
#searchInput
|
||||||
|
type="text"
|
||||||
|
class="quick-jump-drawer__search-input"
|
||||||
|
placeholder="Search agents..."
|
||||||
|
[formControl]="searchControl"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Search agents"
|
||||||
|
/>
|
||||||
|
@if (searchControl.value) {
|
||||||
|
<button
|
||||||
|
class="quick-jump-drawer__search-clear"
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
(click)="searchControl.setValue('')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent list -->
|
||||||
|
<ul class="quick-jump-drawer__agent-list" role="listbox" aria-label="Agent list">
|
||||||
|
@for (agent of filteredAgents(); track agent.id; let i = $index) {
|
||||||
|
<li
|
||||||
|
[id]="'quick-jump-agent-' + i"
|
||||||
|
class="quick-jump-drawer__agent-item"
|
||||||
|
[class.quick-jump-drawer__agent-item--highlighted]="highlightedIndex() === i"
|
||||||
|
role="option"
|
||||||
|
[attr.aria-selected]="highlightedIndex() === i"
|
||||||
|
(click)="selectAgent(agent)"
|
||||||
|
(mouseenter)="highlightedIndex.set(i)"
|
||||||
|
(mouseleave)="highlightedIndex.set(-1)"
|
||||||
|
>
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span
|
||||||
|
class="status-dot {{ getStatusClass(agent.status) }}"
|
||||||
|
[attr.aria-label]="getStatusLabel(agent.status)"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- Agent info -->
|
||||||
|
<div class="quick-jump-drawer__agent-info">
|
||||||
|
<span class="quick-jump-drawer__agent-name">{{ agent.displayName }}</span>
|
||||||
|
<span class="quick-jump-drawer__agent-role">{{ agent.role }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status label -->
|
||||||
|
<span class="quick-jump-drawer__agent-status-label" [class]="'status-label--' + agent.status">
|
||||||
|
{{ getStatusLabel(agent.status) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="quick-jump-drawer__empty">
|
||||||
|
@if (searchControl.value) {
|
||||||
|
<span>No agents matching "{{ searchControl.value }}"</span>
|
||||||
|
} @else {
|
||||||
|
<span>No agents online</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Footer hint -->
|
||||||
|
<div class="quick-jump-drawer__footer">
|
||||||
|
<span class="quick-jump-drawer__footer-hint">
|
||||||
|
<kbd>↑↓</kbd> Navigate <kbd>↵</kbd> Select <kbd>Esc</kbd> Close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Quick-Jump Drawer — Slide-out panel for fast agent switching
|
||||||
|
// Per CUB-51: slides from right, agent list with status badges,
|
||||||
|
// search/filter input, closes via ESC or outside click.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backdrop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 998;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
|
||||||
|
&.backdrop-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawer Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 90vw;
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
border-left: 1px solid var(--cc-outline);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 24px 8px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--status-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 12px;
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
// Use a simple "search" text since icon font may not be loaded inside
|
||||||
|
// the drawer — rely on Material icon font from the parent app
|
||||||
|
&::before {
|
||||||
|
content: 'search';
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: 'Inter', 'Roboto', sans-serif;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent List
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__agent-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&--highlighted {
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--highlighted {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0; // Allow text truncation
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-status-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.status-label--active {
|
||||||
|
color: var(--status-active);
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--idle {
|
||||||
|
color: var(--status-idle);
|
||||||
|
background-color: var(--status-idle-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--thinking {
|
||||||
|
color: var(--status-thinking);
|
||||||
|
background-color: var(--status-thinking-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--error {
|
||||||
|
color: var(--status-error);
|
||||||
|
background-color: var(--status-error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--offline {
|
||||||
|
color: var(--status-offline);
|
||||||
|
background-color: rgba(100, 116, 139, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Footer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__footer {
|
||||||
|
padding: 12px 24px 16px;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__footer-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile Adjustments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.quick-jump-drawer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__header {
|
||||||
|
padding: 16px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search {
|
||||||
|
margin: 12px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-list {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__footer {
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { AgentCardData } from '../../models/agent.model';
|
||||||
|
import { AgentStatusService } from '../../services/agent-status.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quick-jump-drawer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule],
|
||||||
|
templateUrl: './quick-jump-drawer.component.html',
|
||||||
|
styleUrl: './quick-jump-drawer.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class QuickJumpDrawerComponent implements OnDestroy {
|
||||||
|
/** Emits when the drawer should close (ESC, outside click, or item select). */
|
||||||
|
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/** Whether the drawer is visible. */
|
||||||
|
readonly isOpen = signal(false);
|
||||||
|
|
||||||
|
/** Search/filter input control. */
|
||||||
|
readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
|
/** Filtered agent list based on search. */
|
||||||
|
readonly filteredAgents = signal<AgentCardData[]>([]);
|
||||||
|
|
||||||
|
/** Track which agent row is highlighted via keyboard navigation. */
|
||||||
|
readonly highlightedIndex = signal(-1);
|
||||||
|
|
||||||
|
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private readonly agentStatusService: AgentStatusService) {
|
||||||
|
// Reactively filter agents as the search input changes
|
||||||
|
this.searchControl.valueChanges
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((query) => this.filterAgents(query));
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
this.filterAgents('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open the drawer and focus the search input. */
|
||||||
|
open(): void {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.searchControl.setValue('', { emitEvent: false });
|
||||||
|
this.highlightedIndex.set(-1);
|
||||||
|
// Focus search input after animation frame (drawer needs to render first)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.searchInput?.nativeElement?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the drawer. */
|
||||||
|
close(): void {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
this.searchControl.setValue('', { emitEvent: false });
|
||||||
|
this.highlightedIndex.set(-1);
|
||||||
|
this.drawerClose.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the drawer open/close. */
|
||||||
|
toggle(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard Handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle keyboard navigation within the drawer panel. */
|
||||||
|
onDrawerKeydown(event: KeyboardEvent): void {
|
||||||
|
const agents = this.filteredAgents();
|
||||||
|
if (!agents.length) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightedIndex.update((i) =>
|
||||||
|
i < agents.length - 1 ? i + 1 : 0
|
||||||
|
);
|
||||||
|
this.scrollIntoView();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightedIndex.update((i) =>
|
||||||
|
i > 0 ? i - 1 : agents.length - 1
|
||||||
|
);
|
||||||
|
this.scrollIntoView();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
const idx = this.highlightedIndex();
|
||||||
|
if (idx >= 0 && idx < agents.length) {
|
||||||
|
this.selectAgent(agents[idx]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outside Click
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Close when clicking on the backdrop (outside the panel). */
|
||||||
|
onBackdropClick(event: MouseEvent): void {
|
||||||
|
if (
|
||||||
|
this.drawerPanel?.nativeElement &&
|
||||||
|
!this.drawerPanel.nativeElement.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent Selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Select an agent — navigates or focuses the agent card. */
|
||||||
|
selectAgent(agent: AgentCardData): void {
|
||||||
|
// TODO: Wire up navigation to the selected agent's detail view
|
||||||
|
// For now, emit close after selection
|
||||||
|
console.log('[QuickJump] Selected agent:', agent.id);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Get the CSS class for a given agent status. */
|
||||||
|
getStatusClass(status: string): string {
|
||||||
|
return `status-dot--${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a human-readable label for an agent status. */
|
||||||
|
getStatusLabel(status: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
idle: 'Idle',
|
||||||
|
thinking: 'Thinking',
|
||||||
|
error: 'Error',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
return labels[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private filterAgents(query: string): void {
|
||||||
|
const allAgents = this.agentStatusService.agents();
|
||||||
|
const lowerQuery = query.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!lowerQuery) {
|
||||||
|
this.filteredAgents.set(allAgents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allAgents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.displayName.toLowerCase().includes(lowerQuery) ||
|
||||||
|
agent.id.toLowerCase().includes(lowerQuery) ||
|
||||||
|
agent.role.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
this.filteredAgents.set(filtered);
|
||||||
|
this.highlightedIndex.set(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollIntoView(): void {
|
||||||
|
const idx = this.highlightedIndex();
|
||||||
|
const el = document.getElementById(`quick-jump-agent-${idx}`);
|
||||||
|
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Task Progress Bar — Barrel Export
|
||||||
|
// CUB-44
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export { TaskProgressBarComponent } from './task-progress-bar.component';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- Task Progress Bar: determinate progress with optional elapsed time -->
|
||||||
|
<div class="task-progress-bar">
|
||||||
|
<!-- Info row: percentage + optional elapsed -->
|
||||||
|
<div class="task-progress-bar__info">
|
||||||
|
<span class="task-progress-bar__percent">{{ clampedProgress }}%</span>
|
||||||
|
<span *ngIf="showElapsed" class="task-progress-bar__elapsed">
|
||||||
|
{{ elapsedText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Angular Material determinate progress bar -->
|
||||||
|
<mat-progress-bar
|
||||||
|
class="task-progress-bar__bar"
|
||||||
|
mode="determinate"
|
||||||
|
[value]="clampedProgress"
|
||||||
|
aria-label="Task progress"
|
||||||
|
></mat-progress-bar>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Task Progress Bar — Tactical Dark Theme Styling
|
||||||
|
// Per CUB-44: Uses --color-primary for bar fill and --color-surface-light
|
||||||
|
// for track background, mapped to the Control Center's M3 dark tokens.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.task-progress-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Info row: percentage label + elapsed time
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.task-progress-bar__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-progress-bar__percent {
|
||||||
|
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface, #E2E8F0);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-progress-bar__elapsed {
|
||||||
|
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--cc-on-surface-variant, #8A9BB0);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Material Progress Bar Overrides
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Map the spec's --color-primary and --color-surface-light to the Control
|
||||||
|
// Center's actual theme tokens. This ensures the bar uses the tactical dark
|
||||||
|
// palette while respecting the spec's variable naming.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.task-progress-bar__bar {
|
||||||
|
// Override the track (background) to use the surface container
|
||||||
|
--mat-progress-bar-track-height: 6px;
|
||||||
|
--mat-progress-bar-active-indicator-height: 6px;
|
||||||
|
|
||||||
|
// Bar fill color: primary (cyan/sky blue per tactical dark theme)
|
||||||
|
--mat-progress-bar-active-indicator-color: var(--color-primary, var(--mat-sys-primary, #38BDF8));
|
||||||
|
|
||||||
|
// Track background: surface container (dark slate)
|
||||||
|
--mat-progress-bar-track-color: var(--color-surface-light, var(--cc-surface-container, #1C2027));
|
||||||
|
|
||||||
|
// Border radius for a softer bar
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
// Smooth transition on value changes
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounded ends on the progress bar fill
|
||||||
|
:host ::ng-deep .mdc-linear-progress__bar-inner {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounded track background
|
||||||
|
:host ::ng-deep .mdc-linear-progress__track {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Task Progress Bar Component
|
||||||
|
// Per CUB-44: Determinate progress bar with optional elapsed time display.
|
||||||
|
// Uses Angular Material mat-progress-bar in determinate mode with tactical
|
||||||
|
// dark theme styling via CSS custom properties.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a determinate progress bar with an optional elapsed time indicator.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```html
|
||||||
|
* <app-task-progress-bar [progress]="65" />
|
||||||
|
* <app-task-progress-bar [progress]="42" [showElapsed]="true" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-progress-bar',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatProgressBarModule],
|
||||||
|
templateUrl: './task-progress-bar.component.html',
|
||||||
|
styleUrl: './task-progress-bar.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class TaskProgressBarComponent implements OnInit, OnDestroy {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Current progress percentage (0–100). Required. */
|
||||||
|
@Input({ required: true })
|
||||||
|
progress!: number;
|
||||||
|
|
||||||
|
/** Whether to show elapsed time next to the percentage. Defaults to false. */
|
||||||
|
@Input()
|
||||||
|
showElapsed = false;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Timestamp when the component initialized — used for elapsed calculation. */
|
||||||
|
startTime = Date.now();
|
||||||
|
|
||||||
|
/** Formatted elapsed time string, e.g. "2m 15s ago". */
|
||||||
|
elapsedText = '';
|
||||||
|
|
||||||
|
/** Interval timer for updating the elapsed display. */
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(private cdr: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.updateElapsed();
|
||||||
|
|
||||||
|
if (this.showElapsed) {
|
||||||
|
// Update elapsed time every second
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.updateElapsed();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Clamp progress to 0–100 for safety. */
|
||||||
|
get clampedProgress(): number {
|
||||||
|
return Math.max(0, Math.min(100, this.progress ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recalculate the elapsed time string. */
|
||||||
|
private updateElapsed(): void {
|
||||||
|
const elapsedMs = Date.now() - this.startTime;
|
||||||
|
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
this.elapsedText = `${minutes}m ${seconds}s ago`;
|
||||||
|
} else {
|
||||||
|
this.elapsedText = `${seconds}s ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — Design System Barrel Export
|
||||||
|
// ============================================================================
|
||||||
|
// Import everything from '@app/design' for convenient access.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// import { CcTokens, CcThemeService, CcCssProps } from '@app/design';
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export * from './tokens';
|
||||||
|
export * from './theme.service';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user