Compare commits
69 Commits
agent/rex/
...
agent/Dex/
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e6115bf8 | |||
| 17a28a33e1 | |||
| 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 |
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# Control Center — Environment Configuration Template
|
||||
# =============================================================================
|
||||
# Copy this file to `.env` and fill in real values.
|
||||
# Never commit `.env` — it is in .gitignore by default.
|
||||
# =============================================================================
|
||||
|
||||
# ── Database ────────────────────────────────────────────────────────────────
|
||||
POSTGRES_DB=controlcenter
|
||||
POSTGRES_USER=controlcenter
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||
BACKEND_PORT=8080
|
||||
LOG_LEVEL=info
|
||||
ENVIRONMENT=development
|
||||
|
||||
# ── Frontend ────────────────────────────────────────────────────────────────
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated allowed origins. Use "*" for local dev.
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# ── OpenClaw Gateway ──────────────────────────────────────────────────────────
|
||||
# URL to the OpenClaw gateway agent status endpoint.
|
||||
# In Docker Compose, use the container name or host.docker.internal for the
|
||||
# gateway running outside Compose.
|
||||
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||
|
||||
# How often to poll the gateway for agent state updates.
|
||||
GATEWAY_POLL_INTERVAL=5s
|
||||
40
.gitea/workflows/dev.yml
Normal file
40
.gitea/workflows/dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Dev Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build go-backend
|
||||
run: go build ./cmd/...
|
||||
working-directory: ./go-backend
|
||||
|
||||
- name: Test go-backend
|
||||
run: go test ./...
|
||||
working-directory: ./go-backend
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: ./frontend
|
||||
223
README.md
223
README.md
@@ -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*
|
||||
|
||||
88
docker-compose.yml
Normal file
88
docker-compose.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
# =============================================================================
|
||||
# Control Center — Full-Stack Docker Compose
|
||||
# =============================================================================
|
||||
# Boot the entire stack: PostgreSQL + Go backend + React frontend.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # fill in values
|
||||
# docker compose up --build # first run (builds images)
|
||||
# docker compose up # subsequent runs
|
||||
# docker compose down -v # teardown including DB volume
|
||||
# =============================================================================
|
||||
|
||||
name: control-center
|
||||
|
||||
services:
|
||||
# ── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cc-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-controlcenter}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-controlcenter}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./go-backend/migrations:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-controlcenter} -d ${POSTGRES_DB:-controlcenter}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
# ── Go Backend ──────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./go-backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cc-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-controlcenter}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-controlcenter}?sslmode=disable
|
||||
CORS_ORIGIN: "${CORS_ORIGIN:-http://localhost:3000}"
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
GATEWAY_URL: ${GATEWAY_URL:-http://openclaw-gateway:18789/api/agents}
|
||||
GATEWAY_POLL_INTERVAL: ${GATEWAY_POLL_INTERVAL:-5s}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
# ── React Frontend (nginx) ──────────────────────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cc-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
cc-network:
|
||||
driver: bridge
|
||||
@@ -1,17 +0,0 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
58
frontend/.gitignore
vendored
58
frontend/.gitignore
vendored
@@ -1,44 +1,24 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
# Editor directories and files
|
||||
.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
|
||||
.idea
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
frontend/.vscode/extensions.json
vendored
4
frontend/.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
frontend/.vscode/launch.json
vendored
20
frontend/.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
frontend/.vscode/mcp.json
vendored
9
frontend/.vscode/mcp.json
vendored
@@ -1,9 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
frontend/.vscode/tasks.json
vendored
42
frontend/.vscode/tasks.json
vendored
@@ -1,42 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
frontend/Dockerfile
Normal file
38
frontend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# ============================================================
|
||||
# Control Center Frontend — Multi-stage Docker Build
|
||||
# React 19 + Vite + 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 React app from builder stage
|
||||
COPY --from=builder /app/dist /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;"]
|
||||
@@ -1,59 +1,73 @@
|
||||
# Frontend
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Development server
|
||||
Currently, two official plugins are available:
|
||||
|
||||
To start a local development server, run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
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.
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
## Code scaffolding
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
frontend/nginx.conf
Normal file
41
frontend/nginx.conf
Normal file
@@ -0,0 +1,41 @@
|
||||
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 (Vite 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;
|
||||
}
|
||||
|
||||
# React SPA — all other routes fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
8124
frontend/package-lock.json
generated
8124
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,38 @@
|
||||
{
|
||||
"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",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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"
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"axios": "^1.16.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.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"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import HubPage from './pages/HubPage'
|
||||
import LogsPage from './pages/LogsPage'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import SessionsPage from './pages/SessionsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HubPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
@@ -1,344 +0,0 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
<router-outlet />
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { LayoutShellComponent } from './layout/layout-shell/layout-shell.component';
|
||||
import { HubPageComponent } from './pages/hub/hub-page.component';
|
||||
import { ProjectsPageComponent } from './pages/projects/projects-page.component';
|
||||
import { SessionsPageComponent } from './pages/sessions/sessions-page.component';
|
||||
import { LogsPageComponent } from './pages/logs/logs-page.component';
|
||||
import { SettingsPageComponent } from './pages/settings/settings-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LayoutShellComponent,
|
||||
children: [
|
||||
{ path: '', redirectTo: 'hub', pathMatch: 'full' },
|
||||
{ path: 'hub', component: HubPageComponent },
|
||||
{ path: 'projects', component: ProjectsPageComponent },
|
||||
{ path: 'sessions', component: SessionsPageComponent },
|
||||
{ path: 'logs', component: LogsPageComponent },
|
||||
{ path: 'settings', component: SettingsPageComponent },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet />`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class App {}
|
||||
@@ -1,24 +0,0 @@
|
||||
<nav class="bottom-nav" aria-label="Bottom navigation">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="bottom-nav__item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="bottom-nav__item--active"
|
||||
#rla="routerLinkActive"
|
||||
[attr.aria-label]="dest.label"
|
||||
[attr.aria-current]="rla.isActive ? 'page' : null"
|
||||
>
|
||||
<span class="bottom-nav__icon-wrapper">
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
@@ -1,76 +0,0 @@
|
||||
// ============================================================================
|
||||
// Bottom Navigation Bar — Mobile Navigation
|
||||
// Per spec Section 3.2: M3 NavigationBar pattern
|
||||
// Visible only on compact breakpoint (< 600px)
|
||||
// ============================================================================
|
||||
|
||||
.bottom-nav {
|
||||
display: none; // Hidden on desktop, shown on mobile via media query
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--cc-bottom-nav-height);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
color: var(--cc-on-surface-variant);
|
||||
border-radius: 16px;
|
||||
transition: color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--status-active);
|
||||
background-color: var(--status-active-bg);
|
||||
|
||||
.bottom-nav__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
|
||||
.bottom-nav__item--active & {
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Show bottom nav only on compact breakpoint
|
||||
@media (max-width: 599px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
/**
|
||||
* Bottom Navigation Bar for mobile (compact breakpoint).
|
||||
* Per spec Section 3.2: M3 NavigationBar, 3–5 destinations,
|
||||
* active destination uses M3 indicator pill.
|
||||
* Visible only on compact (< 600px) breakpoint.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-bottom-nav',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './bottom-nav.component.html',
|
||||
styleUrl: './bottom-nav.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BottomNavComponent {
|
||||
/** Show only first 5 destinations on bottom nav */
|
||||
protected readonly destinations = NAV_DESTINATIONS.slice(0, 5);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<header class="header-bar" role="banner">
|
||||
<h1 class="header-bar__title">Command Hub</h1>
|
||||
|
||||
<div class="header-bar__actions">
|
||||
<!-- Live indicator -->
|
||||
<button
|
||||
class="header-bar__action-btn header-bar__live-btn"
|
||||
mat-icon-button
|
||||
[attr.aria-label]="isConnected() ? 'Connected — live' : 'Disconnected'"
|
||||
>
|
||||
<span
|
||||
class="header-bar__live-dot"
|
||||
[class.header-bar__live-dot--connected]="isConnected()"
|
||||
></span>
|
||||
<span class="header-bar__live-label">
|
||||
{{ isConnected() ? 'Live' : 'Offline' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="notificationCount()"
|
||||
[matBadgeHidden]="notificationCount() === 0"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
notifications
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Settings -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Settings"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,76 +0,0 @@
|
||||
// ============================================================================
|
||||
// Header Bar — Top App Bar
|
||||
// Per spec Section 3.1: 64px tall, M3 MediumTopAppBar on expanded
|
||||
// Section 3.2: SmallTopAppBar on mobile
|
||||
// ============================================================================
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--cc-header-height);
|
||||
padding: 0 var(--cc-section-padding);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-bar__action-btn {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background-color: var(--status-error);
|
||||
vertical-align: middle;
|
||||
|
||||
&--connected {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface-variant);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Mobile: smaller title
|
||||
@media (max-width: 599px) {
|
||||
.header-bar {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
display: none; // Space saving on mobile — dot alone is enough
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
|
||||
/**
|
||||
* Header Bar component for the Command Hub.
|
||||
* Per spec Section 3.1: 64px tall, app title + live indicator + notification bell + settings.
|
||||
* Uses M3 top app bar pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-header-bar',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, MatButtonModule, MatBadgeModule],
|
||||
templateUrl: './header-bar.component.html',
|
||||
styleUrl: './header-bar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HeaderBarComponent {
|
||||
protected readonly notificationCount = signal(3);
|
||||
protected readonly isConnected = signal(true);
|
||||
|
||||
// TODO: Wire up notification panel (spec Section 2: Notifications Panel)
|
||||
// TODO: Wire up settings navigation
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './nav-rail/nav-rail.component';
|
||||
export * from './bottom-nav/bottom-nav.component';
|
||||
export * from './header-bar/header-bar.component';
|
||||
export * from './layout-shell/layout-shell.component';
|
||||
@@ -1,17 +0,0 @@
|
||||
<div class="layout-shell">
|
||||
<!-- Desktop/Kiosk: Nav Rail on the left -->
|
||||
<app-nav-rail class="layout-shell__nav-rail" />
|
||||
|
||||
<div class="layout-shell__main">
|
||||
<!-- Header bar at top of content area -->
|
||||
<app-header-bar class="layout-shell__header" />
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<main class="layout-shell__content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Bottom Navigation Bar -->
|
||||
<app-bottom-nav class="layout-shell__bottom-nav" />
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
// ============================================================================
|
||||
// Layout Shell — Adaptive layout container
|
||||
// Desktop: Nav Rail (left) + Main Content (right)
|
||||
// Mobile: Header + Content + Bottom Nav (stacked)
|
||||
// ============================================================================
|
||||
|
||||
.layout-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-background);
|
||||
}
|
||||
|
||||
.layout-shell__nav-rail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; // Prevent flex overflow
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-shell__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--cc-section-padding);
|
||||
}
|
||||
|
||||
.layout-shell__bottom-nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Mobile: Stack layout vertically, add bottom padding for bottom nav
|
||||
@media (max-width: 599px) {
|
||||
.layout-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
// Account for bottom nav bar height
|
||||
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet: Ensure content padding accommodates collapsed nav rail
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.layout-shell__content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NavRailComponent } from '../nav-rail/nav-rail.component';
|
||||
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
|
||||
import { HeaderBarComponent } from '../header-bar/header-bar.component';
|
||||
|
||||
/**
|
||||
* Layout Shell — wraps the main content area with adaptive navigation.
|
||||
* Desktop/Kiosk: Nav Rail (left) + Header + Content
|
||||
* Mobile: Header + Content + Bottom Nav
|
||||
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-layout-shell',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent],
|
||||
templateUrl: './layout-shell.component.html',
|
||||
styleUrl: './layout-shell.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LayoutShellComponent {}
|
||||
@@ -1,44 +0,0 @@
|
||||
<aside
|
||||
class="nav-rail"
|
||||
[class.nav-rail--expanded]="expanded()"
|
||||
[attr.aria-label]="'Navigation'"
|
||||
>
|
||||
<!-- Header with OpenClaw brand -->
|
||||
<div class="nav-rail__header">
|
||||
<button
|
||||
class="nav-rail__toggle"
|
||||
(click)="toggleExpand()"
|
||||
[attr.aria-label]="expanded() ? 'Collapse navigation' : 'Expand navigation'"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__brand">OpenClaw</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation destinations -->
|
||||
<nav class="nav-rail__nav">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="nav-rail__item--active"
|
||||
[attr.aria-label]="dest.label"
|
||||
class="nav-rail__item"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__label">{{ dest.label }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -1,112 +0,0 @@
|
||||
// ============================================================================
|
||||
// Nav Rail — Desktop/Kiosk Navigation
|
||||
// Per spec Section 3.1: 72px collapsed / 256px expanded
|
||||
// Section 5.4: Spacing & Grid
|
||||
// ============================================================================
|
||||
|
||||
.nav-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--cc-nav-rail-collapsed-width);
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
|
||||
&--expanded {
|
||||
width: var(--cc-nav-rail-expanded-width);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 12px;
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.nav-rail__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--cc-on-surface);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--status-active);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav-rail__nav {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
|
||||
// Override Angular Material list item styles for compact nav rail items
|
||||
--mat-list-list-item-one-line-vertical-gap: 4px;
|
||||
}
|
||||
|
||||
.nav-rail__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 0 12px;
|
||||
border-radius: 28px;
|
||||
margin: 2px 12px;
|
||||
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);
|
||||
|
||||
.nav-rail__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Responsive: Hide nav rail on mobile (bottom nav takes over)
|
||||
@media (max-width: 599px) {
|
||||
.nav-rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, signal, HostListener } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-rail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './nav-rail.component.html',
|
||||
styleUrl: './nav-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavRailComponent {
|
||||
protected readonly destinations = NAV_DESTINATIONS;
|
||||
protected readonly expanded = signal(false);
|
||||
|
||||
@HostListener('mouseenter')
|
||||
onHoverIn(): void {
|
||||
this.expanded.set(true);
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
onHoverOut(): void {
|
||||
this.expanded.set(false);
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
this.expanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// ============================================================================
|
||||
// Agent Status Types
|
||||
// Per spec Section 7.3: Agent Card Component Interface
|
||||
// ============================================================================
|
||||
|
||||
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline';
|
||||
|
||||
export interface AgentCardData {
|
||||
/** Short agent ID, e.g., "otto" */
|
||||
id: string;
|
||||
|
||||
/** Display name, e.g., "Otto" */
|
||||
displayName: string;
|
||||
|
||||
/** Role description, e.g., "Orchestrator Agent" */
|
||||
role: string;
|
||||
|
||||
/** Current agent status */
|
||||
status: AgentStatus;
|
||||
|
||||
/** Current task description, e.g., "Reviewing PR #42" */
|
||||
currentTask?: string;
|
||||
|
||||
/** Task progress percentage 0–100 */
|
||||
taskProgress?: number;
|
||||
|
||||
/** Elapsed time string, e.g., "04m 12s" */
|
||||
taskElapsed?: string;
|
||||
|
||||
/** Full session key, e.g., "agent:otto:telegram:direct:8787..." */
|
||||
sessionKey: string;
|
||||
|
||||
/** Communication channel, e.g., "telegram" */
|
||||
channel: string;
|
||||
|
||||
/** Timestamp of last activity */
|
||||
lastActivity: Date;
|
||||
|
||||
/** Error message (populated only on error status) */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AgentStatusUpdate {
|
||||
agentId: string;
|
||||
status: AgentStatus;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TaskProgressUpdate {
|
||||
agentId: string;
|
||||
taskName?: string;
|
||||
progress: number;
|
||||
elapsed?: string;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './agent.model';
|
||||
export * from './nav.model';
|
||||
@@ -1,19 +0,0 @@
|
||||
// ============================================================================
|
||||
// Navigation Model
|
||||
// Per spec Section 3.5: Global Navigation Structure
|
||||
// ============================================================================
|
||||
|
||||
export interface NavDestination {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
export const NAV_DESTINATIONS: NavDestination[] = [
|
||||
{ label: 'Command Hub', icon: 'bolt', route: '/hub' },
|
||||
{ label: 'Projects', icon: 'assignment', route: '/projects' },
|
||||
{ label: 'Sessions', icon: 'folder_open', route: '/sessions' },
|
||||
{ label: 'Logs', icon: 'bar_chart', route: '/logs' },
|
||||
{ label: 'Settings', icon: 'settings', route: '/settings' },
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hub-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="hub-page">
|
||||
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.hub-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
.hub-page__placeholder {
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 16px;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HubPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Logs page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LogsPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Projects page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectsPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sessions-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Sessions page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SessionsPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Settings page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SettingsPageComponent {}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { AgentCardData, AgentStatus, AgentStatusUpdate, TaskProgressUpdate } from '../models/agent.model';
|
||||
|
||||
/**
|
||||
* Agent Status Service — stub for future SignalR integration.
|
||||
* Per spec Section 7.4: Connects to /hubs/agent-status for real-time updates.
|
||||
*
|
||||
* TODO: Implement SignalR hub connection when backend is ready.
|
||||
* TODO: Wire up NgRx store or signals for reactive state management.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentStatusService {
|
||||
/** Stub: list of agents (will come from SignalR) */
|
||||
private readonly _agents = signal<AgentCardData[]>([]);
|
||||
|
||||
readonly agents = this._agents.asReadonly();
|
||||
|
||||
/** Stub: update an agent's status */
|
||||
updateStatus(update: AgentStatusUpdate): void {
|
||||
this._agents.update(agents =>
|
||||
agents.map(agent =>
|
||||
agent.id === update.agentId
|
||||
? { ...agent, status: update.status }
|
||||
: agent
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Stub: update an agent's task progress */
|
||||
updateTaskProgress(progress: TaskProgressUpdate): void {
|
||||
this._agents.update(agents =>
|
||||
agents.map(agent =>
|
||||
agent.id === progress.agentId
|
||||
? {
|
||||
...agent,
|
||||
taskProgress: progress.progress,
|
||||
...(progress.taskName ? { currentTask: progress.taskName } : {}),
|
||||
...(progress.elapsed ? { taskElapsed: progress.elapsed } : {}),
|
||||
}
|
||||
: agent
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: connect() — Initialize SignalR connection
|
||||
// TODO: disconnect() — Clean up SignalR connection
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-darkest p-4">
|
||||
<div className="max-w-md w-full p-6 rounded-xl border border-danger/30 bg-danger/10 text-center">
|
||||
<h2 className="text-xl font-bold text-danger mb-2">Something went wrong</h2>
|
||||
<p className="text-on-surface-variant text-sm mb-4">
|
||||
{this.state.error?.message || 'An unexpected error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-surface-darkest font-medium"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
110
frontend/src/components/Layout.tsx
Normal file
110
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Command, label: 'Hub' },
|
||||
{ to: '/logs', icon: Activity, label: 'Logs' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||
{ to: '/sessions', icon: Monitor, label: 'Sessions' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
||||
{/* Desktop Nav Rail */}
|
||||
<aside
|
||||
className={`hidden md:flex flex-col border-r border-surface-light transition-all duration-200 ${
|
||||
expanded ? 'w-64' : 'w-18'
|
||||
}`}
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-surface-light">
|
||||
<Command size={24} className="text-primary shrink-0" />
|
||||
{expanded && <span className="font-bold text-lg whitespace-nowrap">Control Center</span>}
|
||||
</div>
|
||||
<nav className="flex-1 py-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 mx-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} className="shrink-0" />
|
||||
{expanded && <span className="whitespace-nowrap">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Header + Bottom Nav */}
|
||||
<div className="flex-1 flex flex-col md:ml-0">
|
||||
<header className="md:hidden flex items-center justify-between h-16 px-4 border-b border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<Command size={22} className="text-primary" />
|
||||
<span className="font-bold">Control Center</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
||||
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 bg-surface-dark/95 backdrop-blur">
|
||||
<div className="flex flex-col p-4 space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 overflow-auto">{children}</main>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<nav className="md:hidden flex items-center justify-around h-16 border-t border-surface-light bg-surface-dark">
|
||||
{navItems.slice(0, 5).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-1 p-2 text-xs ${
|
||||
isActive ? 'text-primary' : 'text-on-surface-variant'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/hooks/useLocalStorage.ts
Normal file
29
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item !== null ? (JSON.parse(item) as T) : initialValue
|
||||
} catch {
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(storedValue))
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}, [key, storedValue])
|
||||
|
||||
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
||||
setStoredValue((prev) => {
|
||||
const next = value instanceof Function ? value(prev) : value
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return [storedValue, setValue]
|
||||
}
|
||||
50
frontend/src/hooks/useTheme.tsx
Normal file
50
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
const stored = localStorage.getItem('cc-theme')
|
||||
if (stored === 'light' || stored === 'dark') return stored
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
} else {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('cc-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
76
frontend/src/index.css
Normal file
76
frontend/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface-darkest: #0D0F12;
|
||||
--color-surface-dark: #13161A;
|
||||
--color-surface-medium: #1C2027;
|
||||
--color-surface-light: #252B33;
|
||||
--color-surface-lighter: #2D3748;
|
||||
--color-on-surface: #E2E8F0;
|
||||
--color-on-surface-variant: #8A9BB0;
|
||||
--color-on-surface-muted: #64748B;
|
||||
--color-primary: #38BDF8;
|
||||
--color-secondary: #2DD4BF;
|
||||
--color-accent: #A78BFA;
|
||||
--color-danger: #F87171;
|
||||
--color-status-active: #38BDF8;
|
||||
--color-status-idle: #2DD4BF;
|
||||
--color-status-thinking: #A78BFA;
|
||||
--color-status-error: #F87171;
|
||||
--color-status-offline: #64748B;
|
||||
|
||||
/* Light mode overrides */
|
||||
--color-light-surface-darkest: #F8FAFC;
|
||||
--color-light-surface-dark: #F1F5F9;
|
||||
--color-light-surface-medium: #E2E8F0;
|
||||
--color-light-surface-light: #CBD5E1;
|
||||
--color-light-surface-lighter: #94A3B8;
|
||||
--color-light-on-surface: #0F172A;
|
||||
--color-light-on-surface-variant: #475569;
|
||||
--color-light-on-surface-muted: #64748B;
|
||||
--color-light-primary: #0284C7;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-surface-darkest);
|
||||
color: var(--color-on-surface);
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
html.dark body {
|
||||
background-color: var(--color-surface-darkest);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
html.light body {
|
||||
background-color: var(--color-light-surface-darkest);
|
||||
color: var(--color-light-on-surface);
|
||||
}
|
||||
|
||||
html.light {
|
||||
--color-surface-darkest: var(--color-light-surface-darkest);
|
||||
--color-surface-dark: var(--color-light-surface-dark);
|
||||
--color-surface-medium: var(--color-light-surface-medium);
|
||||
--color-surface-light: var(--color-light-surface-light);
|
||||
--color-surface-lighter: var(--color-light-surface-lighter);
|
||||
--color-on-surface: var(--color-light-on-surface);
|
||||
--color-on-surface-variant: var(--color-light-on-surface-variant);
|
||||
--color-on-surface-muted: var(--color-light-on-surface-muted);
|
||||
--color-primary: var(--color-light-primary);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--color-surface-light) 25%, var(--color-surface-lighter) 50%, var(--color-surface-light) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenClaw Control Center</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<!-- Google Fonts: Inter (UI) + Roboto Mono (logs/session IDs) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Material Symbols (Outlined) per spec Section 5.5 -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
32
frontend/src/main.tsx
Normal file
32
frontend/src/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ThemeProvider } from './hooks/useTheme'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
198
frontend/src/pages/HubPage.tsx
Normal file
198
frontend/src/pages/HubPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listAgents } from '../services/api'
|
||||
import { Activity, AlertTriangle, RefreshCw, Bot, Zap, Coffee, AlertCircle } from 'lucide-react'
|
||||
import type { Agent } from '../types'
|
||||
|
||||
function statusStats(agents: Agent[]) {
|
||||
const counts = { total: agents.length, active: 0, idle: 0, thinking: 0, error: 0 }
|
||||
for (const a of agents) {
|
||||
if (a.status in counts) counts[a.status as keyof typeof counts]++
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'bg-green-500',
|
||||
idle: 'bg-yellow-500',
|
||||
thinking: 'bg-blue-500',
|
||||
error: 'bg-red-500',
|
||||
}
|
||||
|
||||
export default function HubPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, error, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: listAgents,
|
||||
})
|
||||
|
||||
const agents = data?.data ?? []
|
||||
const stats = statusStats(agents)
|
||||
|
||||
if (isLoading) {
|
||||
return <HubSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load agents</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['agents'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Command Hub</h1>
|
||||
<p className="text-on-surface-variant">Agent fleet overview</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={16} className={isRefetching ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard icon={Bot} label="Total" value={stats.total} color="text-on-surface" />
|
||||
<StatCard icon={Zap} label="Active" value={stats.active} color="text-green-500" />
|
||||
<StatCard icon={Coffee} label="Idle" value={stats.idle} color="text-yellow-500" />
|
||||
<StatCard icon={AlertTriangle} label="Errors" value={stats.error} color="text-red-500" />
|
||||
</div>
|
||||
|
||||
{/* Agent grid */}
|
||||
{agents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<Bot size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No agents registered</p>
|
||||
<p className="text-on-surface-muted text-sm">Agents will appear here once connected.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
|
||||
>
|
||||
{/* Agent identity */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-surface-light flex items-center justify-center text-lg font-bold shrink-0">
|
||||
{agent.displayName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
|
||||
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={agent.status} />
|
||||
</div>
|
||||
|
||||
{/* Current task */}
|
||||
{agent.currentTask && (
|
||||
<div className="mb-2 text-sm text-on-surface-variant truncate">
|
||||
{agent.currentTask}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
|
||||
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
||||
<Activity size={12} />
|
||||
<span>{agent.channel}</span>
|
||||
<span>·</span>
|
||||
<span>{agent.lastActivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-surface-light bg-surface-dark flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-surface-light ${color}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-on-surface-variant">{label}</p>
|
||||
<p className="text-xl font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
|
||||
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
|
||||
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HubSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-48 skeleton mb-2" />
|
||||
<div className="h-4 w-36 skeleton" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg skeleton" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-12 skeleton mb-2" />
|
||||
<div className="h-6 w-8 skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="h-10 w-10 rounded-full skeleton shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-24 skeleton mb-1" />
|
||||
<div className="h-3 w-16 skeleton" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded-full skeleton" />
|
||||
</div>
|
||||
<div className="h-4 w-full skeleton mb-2" />
|
||||
<div className="h-2 w-full skeleton rounded-full" />
|
||||
<div className="mt-3 h-3 w-32 skeleton" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
frontend/src/pages/LogsPage.tsx
Normal file
182
frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listTasks } from '../services/api'
|
||||
import { AlertCircle, RefreshCw, Filter, CheckCircle, Circle, Clock, XCircle, Loader, ListTodo } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
|
||||
const STATUS_FILTERS = ['all', 'pending', 'running', 'completed', 'failed'] as const
|
||||
type StatusFilter = (typeof STATUS_FILTERS)[number]
|
||||
|
||||
const STATUS_ICON: Record<string, React.ElementType> = {
|
||||
pending: Clock,
|
||||
running: Loader,
|
||||
completed: CheckCircle,
|
||||
failed: XCircle,
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'text-yellow-500',
|
||||
running: 'text-blue-400',
|
||||
completed: 'text-green-500',
|
||||
failed: 'text-red-500',
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: listTasks,
|
||||
})
|
||||
|
||||
const tasks = (data?.data ?? []) as Task[]
|
||||
|
||||
const filtered = statusFilter === 'all'
|
||||
? tasks
|
||||
: tasks.filter((t) => t.status === statusFilter)
|
||||
|
||||
// Sort newest first
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
if (isLoading) return <LogsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load activity logs</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['tasks'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Activity Logs</h1>
|
||||
<p className="text-on-surface-variant">Task activity across all agents</p>
|
||||
</header>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Filter size={16} className="text-on-surface-muted mr-1" />
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm capitalize transition-colors ${
|
||||
statusFilter === f
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<ListTodo size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No tasks found</p>
|
||||
<p className="text-on-surface-muted text-sm">
|
||||
{statusFilter === 'all' ? 'Tasks will appear here as agents execute work.' : `No ${statusFilter} tasks.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((task) => {
|
||||
const Icon = STATUS_ICON[task.status] ?? Circle
|
||||
const fmtTime = formatTime(task.createdAt)
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
|
||||
>
|
||||
<div className={`mt-0.5 shrink-0 ${STATUS_COLOR[task.status] ?? 'text-on-surface-muted'}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{task.title}</p>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">
|
||||
Agent {task.agentId}
|
||||
{task.sessionKey && ` · ${task.sessionKey}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
<span className={`text-xs capitalize px-2 py-0.5 rounded-full font-medium ${
|
||||
statusFilter !== 'all'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-surface-light text-on-surface-variant'
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
{task.progress != null && task.progress > 0 && task.progress < 100 && (
|
||||
<span className="text-xs text-on-surface-muted">{task.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-on-surface-muted whitespace-nowrap">
|
||||
{fmtTime}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
if (diffMin < 1) return 'just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
return d.toLocaleDateString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function LogsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-44 skeleton mb-2" />
|
||||
<div className="h-4 w-56 skeleton" />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-8 w-20 rounded-lg skeleton" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="h-5 w-5 rounded-full skeleton shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-3/4 skeleton mb-1" />
|
||||
<div className="h-3 w-1/2 skeleton" />
|
||||
</div>
|
||||
<div className="h-6 w-20 rounded-full skeleton" />
|
||||
<div className="h-3 w-16 skeleton" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
frontend/src/pages/ProjectsPage.tsx
Normal file
117
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listProjects } from '../services/api'
|
||||
import { AlertCircle, RefreshCw, FolderKanban, Users } from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
planned: 'bg-purple-500',
|
||||
active: 'bg-green-500',
|
||||
paused: 'bg-yellow-500',
|
||||
completed: 'bg-blue-400',
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: listProjects,
|
||||
})
|
||||
|
||||
const projects = data?.data ?? []
|
||||
|
||||
if (isLoading) return <ProjectsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load projects</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['projects'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="text-on-surface-variant">Tracked projects and initiatives</p>
|
||||
</header>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<FolderKanban size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No projects tracked</p>
|
||||
<p className="text-on-surface-muted text-sm">Projects synced from Linear will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="p-5 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<FolderKanban size={20} className="text-on-surface-variant shrink-0 mt-0.5" />
|
||||
<ProjectStatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold mb-1">{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="text-sm text-on-surface-variant mb-4 line-clamp-2 flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-on-surface-muted pt-3 border-t border-surface-light">
|
||||
<Users size={14} />
|
||||
<span>{project.agentIds?.length ?? 0} agent{(project.agentIds?.length ?? 0) !== 1 ? 's' : ''} assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
|
||||
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
|
||||
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-40 skeleton mb-2" />
|
||||
<div className="h-4 w-56 skeleton" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="p-5 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex justify-between mb-3">
|
||||
<div className="h-5 w-5 rounded skeleton" />
|
||||
<div className="h-6 w-20 rounded-full skeleton" />
|
||||
</div>
|
||||
<div className="h-5 w-3/4 skeleton mb-2" />
|
||||
<div className="h-4 w-full skeleton mb-2" />
|
||||
<div className="h-4 w-2/3 skeleton mb-4" />
|
||||
<div className="pt-3 border-t border-surface-light">
|
||||
<div className="h-3 w-32 skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
frontend/src/pages/SessionsPage.tsx
Normal file
177
frontend/src/pages/SessionsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listSessions } from '../services/api'
|
||||
import { AlertCircle, Monitor, RefreshCw, Cpu, MessageSquare, Clock, Hash } from 'lucide-react'
|
||||
|
||||
export default function SessionsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['sessions'],
|
||||
queryFn: listSessions,
|
||||
})
|
||||
|
||||
const sessions = data?.data ?? []
|
||||
|
||||
if (isLoading) return <SessionsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load sessions</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['sessions'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Sessions</h1>
|
||||
<p className="text-on-surface-variant">Active and recent agent sessions</p>
|
||||
</header>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<Monitor size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No active sessions</p>
|
||||
<p className="text-on-surface-muted text-sm">Sessions will appear when agents connect.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: Table view */}
|
||||
<div className="hidden lg:block overflow-x-auto rounded-xl border border-surface-light">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-light bg-surface-dark">
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Agent</th>
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Session Key</th>
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Channel</th>
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Model</th>
|
||||
<th className="text-right p-4 font-medium text-on-surface-variant">Context Tokens</th>
|
||||
<th className="text-right p-4 font-medium text-on-surface-variant">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="border-b border-surface-light hover:bg-surface-dark/50 transition-colors"
|
||||
>
|
||||
<td className="p-4 font-medium">{s.agentId}</td>
|
||||
<td className="p-4">
|
||||
<code className="text-xs bg-surface-light px-2 py-1 rounded text-on-surface-variant">
|
||||
{s.sessionKey}
|
||||
</code>
|
||||
</td>
|
||||
<td className="p-4 text-on-surface-variant">{s.channel}</td>
|
||||
<td className="p-4 text-on-surface-variant">{s.model}</td>
|
||||
<td className="p-4 text-right tabular-nums text-on-surface-variant">
|
||||
{s.contextTokens.toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4 text-right text-on-surface-muted whitespace-nowrap">
|
||||
{formatDateTime(s.startedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Card view */}
|
||||
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{sessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Monitor size={16} className="text-primary" />
|
||||
<span className="font-medium text-sm">{s.agentId}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row icon={Hash} label="Session">{s.sessionKey}</Row>
|
||||
<Row icon={MessageSquare} label="Channel">{s.channel}</Row>
|
||||
<Row icon={Cpu} label="Model">{s.model}</Row>
|
||||
<Row icon={Hash} label="Tokens">{s.contextTokens.toLocaleString()}</Row>
|
||||
<Row icon={Clock} label="Started">{formatDateTime(s.startedAt)}</Row>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon: Icon, label, children }: { icon: React.ElementType; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={14} className="text-on-surface-muted shrink-0" />
|
||||
<span className="text-on-surface-muted text-xs w-14 shrink-0">{label}</span>
|
||||
<span className="text-on-surface-variant truncate">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function SessionsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-40 skeleton mb-2" />
|
||||
<div className="h-4 w-56 skeleton" />
|
||||
</div>
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block rounded-xl border border-surface-light overflow-hidden">
|
||||
<div className="bg-surface-dark p-4 border-b border-surface-light">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-3 skeleton" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="border-b border-surface-light p-4">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[...Array(6)].map((_, j) => (
|
||||
<div key={j} className="h-4 skeleton" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile skeleton */}
|
||||
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="h-4 w-24 skeleton mb-3" />
|
||||
{[...Array(5)].map((_, j) => (
|
||||
<div key={j} className="h-4 w-full skeleton mb-2" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
frontend/src/pages/SettingsPage.tsx
Normal file
126
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
|
||||
|
||||
const REFRESH_PRESETS = [
|
||||
{ label: '5s', value: 5_000 },
|
||||
{ label: '10s', value: 10_000 },
|
||||
{ label: '30s', value: 30_000 },
|
||||
{ label: '60s', value: 60_000 },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-on-surface-variant">Application preferences</p>
|
||||
</header>
|
||||
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Monitor size={20} className="text-primary" />
|
||||
Appearance
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Dark Mode</p>
|
||||
<p className="text-sm text-on-surface-variant">Toggle between dark and light themes</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`relative w-14 h-8 rounded-full transition-colors duration-200 ${
|
||||
isDark ? 'bg-primary' : 'bg-surface-lighter'
|
||||
}`}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 flex items-center justify-center ${
|
||||
isDark ? 'translate-x-7' : 'translate-x-1'
|
||||
}`}
|
||||
>
|
||||
{isDark ? <Moon size={14} className="text-surface-darkest" /> : <Sun size={14} className="text-yellow-500" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap size={20} className="text-primary" />
|
||||
Connection
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<div>
|
||||
<label htmlFor="gateway-url" className="block text-sm font-medium mb-1">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="gateway-url"
|
||||
type="url"
|
||||
value={gatewayUrl}
|
||||
onChange={(e) => setGatewayUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
className="w-full px-3 py-2 rounded-lg border border-surface-light bg-surface-darkest text-on-surface placeholder:text-on-surface-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-on-surface-muted mt-1">
|
||||
The backend Gateway address for API requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Refresh */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock size={20} className="text-primary" />
|
||||
Auto Refresh
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
|
||||
onChange={(e) => {
|
||||
const idx = parseInt(e.target.value)
|
||||
setRefreshInterval(REFRESH_PRESETS[idx].value)
|
||||
}}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-on-surface-muted">
|
||||
{REFRESH_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => setRefreshInterval(p.value)}
|
||||
className={`px-3 py-1 rounded-lg transition-colors ${
|
||||
refreshInterval === p.value
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-surface-light'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
frontend/src/services/api.ts
Normal file
58
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios from 'axios'
|
||||
import type { Agent, Session, Task, Project, PaginatedResponse } from '../types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Health check
|
||||
export async function getHealth() {
|
||||
const res = await api.get('/health')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Agents
|
||||
export async function listAgents() {
|
||||
const res = await api.get<PaginatedResponse<Agent>>('/agents')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAgent(id: string) {
|
||||
const res = await api.get<{ data: Agent }>(`/agents/${id}`)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function createAgent(data: Omit<Agent, 'lastActivity'>) {
|
||||
const res = await api.post<{ data: Agent }>('/agents', data)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function updateAgent(id: string, data: Partial<Agent>) {
|
||||
const res = await api.put<{ data: Agent }>(`/agents/${id}`, data)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string) {
|
||||
await api.delete(`/agents/${id}`)
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export async function listSessions() {
|
||||
const res = await api.get<PaginatedResponse<Session>>('/sessions')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Tasks
|
||||
export async function listTasks() {
|
||||
const res = await api.get<PaginatedResponse<Task>>('/tasks')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Projects
|
||||
export async function listProjects() {
|
||||
const res = await api.get<PaginatedResponse<Project>>('/projects')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -1,212 +0,0 @@
|
||||
// ============================================================================
|
||||
// OpenClaw Control Center — M3 Tactical Dark Theme
|
||||
// ============================================================================
|
||||
// Material Design 3 theming with custom dark palette per design spec.
|
||||
// Section 5.1: Color Palette, Section 5.2: Typography
|
||||
// ============================================================================
|
||||
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3 Theme Definition
|
||||
// ---------------------------------------------------------------------------
|
||||
// Using mat.define-theme() with custom color tokens to match the tactical
|
||||
// dark palette. Angular Material 19+ uses the new theming API.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$dark-theme: mat.define-theme((
|
||||
color: (
|
||||
theme-type: dark,
|
||||
primary: mat.$cyan-palette,
|
||||
tertiary: mat.$violet-palette,
|
||||
),
|
||||
typography: (
|
||||
brand-family: 'Inter, Roboto, sans-serif',
|
||||
plain-family: 'Inter, Roboto, sans-serif',
|
||||
bold-weight: 600,
|
||||
medium-weight: 500,
|
||||
regular-weight: 400,
|
||||
),
|
||||
density: (
|
||||
scale: 0,
|
||||
),
|
||||
));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply theme to :root
|
||||
// ---------------------------------------------------------------------------
|
||||
html {
|
||||
height: 100%;
|
||||
@include mat.theme($dark-theme);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom CSS Custom Properties — Status Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per spec Section 5.1 "Status Colors (Semantic — outside M3 tonal system)"
|
||||
// These are NOT part of the M3 tonal palette; they are semantic overrides.
|
||||
// ---------------------------------------------------------------------------
|
||||
:root {
|
||||
// --- Status colors ---
|
||||
--status-active: #38BDF8;
|
||||
--status-idle: #2DD4BF;
|
||||
--status-thinking: #A78BFA;
|
||||
--status-error: #F87171;
|
||||
--status-offline: #64748B;
|
||||
|
||||
// --- Status background tints (12% opacity) ---
|
||||
--status-active-bg: rgba(56, 189, 248, 0.12);
|
||||
--status-idle-bg: rgba(45, 212, 191, 0.12);
|
||||
--status-thinking-bg: rgba(167, 139, 250, 0.12);
|
||||
--status-error-bg: rgba(248, 113, 113, 0.12);
|
||||
|
||||
// --- Surface overrides (tactical dark palette) ---
|
||||
--cc-background: #0D0F12;
|
||||
--cc-surface: #13161A;
|
||||
--cc-surface-container: #1C2027;
|
||||
--cc-surface-container-high: #252B33;
|
||||
--cc-on-surface: #E2E8F0;
|
||||
--cc-on-surface-variant: #8A9BB0;
|
||||
--cc-outline: #2D3748;
|
||||
|
||||
// --- Mono font stack ---
|
||||
--cc-font-mono: 'Roboto Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
|
||||
// --- Layout constants ---
|
||||
--cc-nav-rail-collapsed-width: 72px;
|
||||
--cc-nav-rail-expanded-width: 256px;
|
||||
--cc-header-height: 64px;
|
||||
--cc-bottom-nav-height: 80px;
|
||||
--cc-card-border-radius: 16px;
|
||||
--cc-card-min-width: 320px;
|
||||
--cc-card-gap: 16px;
|
||||
--cc-card-padding: 20px;
|
||||
--cc-section-padding: 24px;
|
||||
--cc-spacing-unit: 8px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global Body Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
body {
|
||||
background-color: var(--cc-background);
|
||||
color: var(--cc-on-surface);
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3 Surface Overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override M3 surface tokens to match our tactical dark palette
|
||||
// ---------------------------------------------------------------------------
|
||||
:root {
|
||||
// Override M3 system color tokens to match custom palette
|
||||
--mat-sys-surface: var(--cc-surface);
|
||||
--mat-sys-surface-container: var(--cc-surface-container);
|
||||
--mat-sys-surface-container-high: var(--cc-surface-container-high);
|
||||
--mat-sys-on-surface: var(--cc-on-surface);
|
||||
--mat-sys-on-surface-variant: var(--cc-on-surface-variant);
|
||||
--mat-sys-outline: var(--cc-outline);
|
||||
--mat-sys-background: var(--cc-background);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
.text-mono {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Dot Pulse Animations
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per spec Section 7.5: Animation Specs
|
||||
// ---------------------------------------------------------------------------
|
||||
@keyframes pulse-active {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes pulse-thinking {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility Classes
|
||||
// ---------------------------------------------------------------------------
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--idle {
|
||||
background-color: var(--status-idle);
|
||||
}
|
||||
|
||||
&--thinking {
|
||||
background-color: var(--status-thinking);
|
||||
animation: pulse-thinking 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--status-error);
|
||||
animation: pulse-error 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--offline {
|
||||
background-color: var(--status-offline);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-dot--active,
|
||||
.status-dot--error,
|
||||
.status-dot--thinking {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scrollbar Styling (Tactical Dark)
|
||||
// ---------------------------------------------------------------------------
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--cc-surface);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--cc-outline);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--cc-on-surface-variant);
|
||||
}
|
||||
}
|
||||
64
frontend/src/types/index.ts
Normal file
64
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error'
|
||||
|
||||
export interface Agent {
|
||||
id: string
|
||||
displayName: string
|
||||
role: string
|
||||
status: AgentStatus
|
||||
currentTask?: string
|
||||
taskProgress?: number
|
||||
taskElapsed?: string
|
||||
sessionKey: string
|
||||
channel: string
|
||||
lastActivity: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
sessionKey: string
|
||||
agentId: string
|
||||
channel: string
|
||||
status: string
|
||||
contextTokens: number
|
||||
totalTokens: number
|
||||
estimatedCost: number
|
||||
model: string
|
||||
startedAt: string
|
||||
lastActivityAt: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
agentId: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
progress?: number
|
||||
sessionKey: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
agentIds: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
totalCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
details?: Record<string, string>
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
35
go-backend/.dockerignore
Normal file
35
go-backend/.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# Ignore local build artifacts and version-control files
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE / editor
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Dependency cache (already fetched in Dockerfile)
|
||||
vendor/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# CI / CD
|
||||
.github/
|
||||
.gitea/
|
||||
35
go-backend/Dockerfile
Normal file
35
go-backend/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
# Copy dependency files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/server ./cmd/server
|
||||
|
||||
# ── Final stage ─────────────────────────────────────────────────────────
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS outbound calls
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /bin/server /app/server
|
||||
|
||||
# Expose the default port (overridden by PORT env var)
|
||||
EXPOSE 8080
|
||||
|
||||
# Run as non-root
|
||||
RUN adduser -D -s /bin/sh appuser
|
||||
USER appuser
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
125
go-backend/cmd/server/main.go
Normal file
125
go-backend/cmd/server/main.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Command server starts the Control Center Go backend API server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/gateway"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// ── Configuration ──────────────────────────────────────────────────────
|
||||
cfg := config.Load()
|
||||
|
||||
// ── Logging ────────────────────────────────────────────────────────────
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLogLevel(cfg.LogLevel),
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// ── Database ───────────────────────────────────────────────────────────
|
||||
pool, err := db.New(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// ── Repositories (PostgreSQL-backed) ───────────────────────────────────
|
||||
agentRepo := repository.NewAgentRepository(pool.Pool)
|
||||
sessionRepo := repository.NewSessionRepository(pool.Pool)
|
||||
taskRepo := repository.NewTaskRepository(pool.Pool)
|
||||
projectRepo := repository.NewProjectRepository(pool.Pool)
|
||||
|
||||
// ── Seed demo agents on first boot ─────────────────────────────────────
|
||||
if err := gateway.SeedDemoAgents(context.Background(), agentRepo); err != nil {
|
||||
slog.Error("seed demo agents failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ── SSE Broker ─────────────────────────────────────────────────────────
|
||||
broker := handler.NewBroker()
|
||||
|
||||
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||
h := handler.NewHandler(agentRepo, sessionRepo, taskRepo, projectRepo)
|
||||
|
||||
// ── Router ─────────────────────────────────────────────────────────────
|
||||
r := router.New(&router.Dependencies{
|
||||
Handler: h,
|
||||
Pool: pool,
|
||||
CORSOrigin: cfg.CORSOrigin,
|
||||
Broker: broker,
|
||||
})
|
||||
|
||||
// ── Gateway client (polls OpenClaw for agent states) ───────────────────
|
||||
gwClient := gateway.NewClient(gateway.Config{
|
||||
URL: cfg.GatewayURL,
|
||||
PollInterval: cfg.GatewayPollInterval,
|
||||
}, agentRepo, broker)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go gwClient.Start(ctx)
|
||||
|
||||
// ── Server ─────────────────────────────────────────────────────────────
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: r,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
slog.Info("server starting", "port", cfg.Port, "env", cfg.Environment)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-quit
|
||||
slog.Info("shutting down server...")
|
||||
|
||||
cancel() // stop gateway polling
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("server forced to shutdown", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("server exited cleanly")
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch level {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
26
go-backend/go.mod
Normal file
26
go-backend/go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-playground/validator/v10 v10.24.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
50
go-backend/go.sum
Normal file
50
go-backend/go.sum
Normal file
@@ -0,0 +1,50 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
59
go-backend/internal/config/config.go
Normal file
59
go-backend/internal/config/config.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package config provides application configuration loaded from environment
|
||||
// variables with sensible defaults for local development.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Port int
|
||||
DatabaseURL string
|
||||
CORSOrigin string
|
||||
LogLevel string
|
||||
Environment string
|
||||
GatewayURL string
|
||||
GatewayPollInterval time.Duration
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults where
|
||||
// values are not set. All secrets come from the environment — nothing is hardcoded.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"),
|
||||
CORSOrigin: getEnv("CORS_ORIGIN", "*"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
GatewayURL: getEnv("GATEWAY_URL", "http://localhost:18789/api/agents"),
|
||||
GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
62
go-backend/internal/db/db.go
Normal file
62
go-backend/internal/db/db.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package db provides PostgreSQL connection management using pgx.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Pool wraps a pgx connection pool with lifecycle helpers.
|
||||
type Pool struct {
|
||||
*pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a connection pool from a PostgreSQL DSN.
|
||||
func New(dsn string) (*Pool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse pgx config: %w", err)
|
||||
}
|
||||
|
||||
// Sensible defaults
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConnIdleTime = 10 * time.Minute
|
||||
cfg.HealthCheckPeriod = 5 * time.Second
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pgx pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database connected", "pool", cfg.ConnConfig.Database)
|
||||
return &Pool{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close shuts down the pool gracefully.
|
||||
func (p *Pool) Close() {
|
||||
if p.Pool != nil {
|
||||
p.Pool.Close()
|
||||
slog.Info("database pool closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns nil if the database is reachable.
|
||||
func (p *Pool) Health(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
return p.Ping(ctx)
|
||||
}
|
||||
198
go-backend/internal/gateway/client.go
Normal file
198
go-backend/internal/gateway/client.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Package gateway provides an OpenClaw gateway integration client that
|
||||
// polls agent states, persists them via the repository layer, and broadcasts
|
||||
// changes through the SSE broker for real-time frontend updates.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||
)
|
||||
|
||||
// Client polls the OpenClaw gateway for agent status and keeps the database
|
||||
// and SSE broker in sync.
|
||||
type Client struct {
|
||||
url string
|
||||
pollInterval time.Duration
|
||||
httpClient *http.Client
|
||||
agents repository.AgentRepo
|
||||
broker *handler.Broker
|
||||
}
|
||||
|
||||
// Config holds gateway client configuration, typically loaded from environment.
|
||||
type Config struct {
|
||||
URL string
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults for local development.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
URL: "http://localhost:18789/api/agents",
|
||||
PollInterval: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient returns a gateway client wired to the given repository and broker.
|
||||
func NewClient(cfg Config, agents repository.AgentRepo, broker *handler.Broker) *Client {
|
||||
return &Client{
|
||||
url: cfg.URL,
|
||||
pollInterval: cfg.PollInterval,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
agents: agents,
|
||||
broker: broker,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the polling loop. It runs until ctx is cancelled.
|
||||
func (c *Client) Start(ctx context.Context) {
|
||||
slog.Info("gateway client starting",
|
||||
"url", c.url,
|
||||
"pollInterval", c.pollInterval.String())
|
||||
|
||||
ticker := time.NewTicker(c.pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("gateway client stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.poll(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll fetches agent states from the gateway and syncs to the database.
|
||||
func (c *Client) poll(ctx context.Context) {
|
||||
resp, err := c.httpClient.Get(c.url)
|
||||
if err != nil {
|
||||
slog.Warn("gateway poll failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Warn("gateway returned non-200", "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var agents []models.AgentCardData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&agents); err != nil {
|
||||
slog.Warn("gateway response parse failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ga := range agents {
|
||||
// Check if agent already exists; if so, update; otherwise create.
|
||||
existing, err := c.agents.Get(ctx, ga.ID)
|
||||
if err != nil {
|
||||
// Not found — create it
|
||||
if err := c.agents.Create(ctx, ga); err != nil {
|
||||
slog.Warn("gateway agent create failed", "id", ga.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("gateway agent created", "id", ga.ID, "status", ga.Status)
|
||||
c.broker.Broadcast("agent.status", ga)
|
||||
continue
|
||||
}
|
||||
|
||||
// If status changed, update and broadcast
|
||||
if existing.Status != ga.Status {
|
||||
updated, err := c.agents.Update(ctx, ga.ID, models.UpdateAgentRequest{
|
||||
Status: &ga.Status,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("gateway agent update failed", "id", ga.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
c.broker.Broadcast("agent.status", updated)
|
||||
slog.Debug("agent status changed",
|
||||
"id", ga.ID,
|
||||
"from", existing.Status,
|
||||
"to", ga.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SeedDemoAgents inserts the five known demo agents if the agents table is
|
||||
// empty. Call this once on application startup after migrations have run.
|
||||
func SeedDemoAgents(ctx context.Context, agents repository.AgentRepo) error {
|
||||
count, err := agents.Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count agents for seeding: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil // already seeded
|
||||
}
|
||||
|
||||
slog.Info("seeding demo agents")
|
||||
demoAgents := []models.AgentCardData{
|
||||
{
|
||||
ID: "otto",
|
||||
DisplayName: "Otto",
|
||||
Role: "Orchestrator",
|
||||
Status: models.AgentStatusActive,
|
||||
CurrentTask: strPtr("Orchestrating tasks"),
|
||||
SessionKey: "otto-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "rex",
|
||||
DisplayName: "Rex",
|
||||
Role: "Frontend Dev",
|
||||
Status: models.AgentStatusIdle,
|
||||
SessionKey: "rex-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "dex",
|
||||
DisplayName: "Dex",
|
||||
Role: "Backend Dev",
|
||||
Status: models.AgentStatusThinking,
|
||||
CurrentTask: strPtr("Designing API contracts"),
|
||||
SessionKey: "dex-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "hex",
|
||||
DisplayName: "Hex",
|
||||
Role: "Database Specialist",
|
||||
Status: models.AgentStatusActive,
|
||||
CurrentTask: strPtr("Reviewing schema migrations"),
|
||||
SessionKey: "hex-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "pip",
|
||||
DisplayName: "Pip",
|
||||
Role: "Edge Device Dev",
|
||||
Status: models.AgentStatusIdle,
|
||||
SessionKey: "pip-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
for _, a := range demoAgents {
|
||||
if err := agents.Create(ctx, a); err != nil {
|
||||
return fmt.Errorf("seed agent %s: %w", a.ID, err)
|
||||
}
|
||||
}
|
||||
slog.Info("demo agents seeded", "count", len(demoAgents))
|
||||
return nil
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
162
go-backend/internal/handler/agent.go
Normal file
162
go-backend/internal/handler/agent.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Package handler contains HTTP handlers for the Control Center API.
|
||||
// Each handler is a method on a Handler struct that receives its
|
||||
// dependencies through dependency injection — now wired to PostgreSQL-backed
|
||||
// repository implementations instead of in-memory stores.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Handler groups all route handlers and their dependencies.
|
||||
type Handler struct {
|
||||
Agents repository.AgentRepo
|
||||
Sessions repository.SessionRepo
|
||||
Tasks repository.TaskRepo
|
||||
Projects repository.ProjectRepo
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
// NewHandler returns a fully wired Handler with repository backends.
|
||||
func NewHandler(
|
||||
ar repository.AgentRepo,
|
||||
sr repository.SessionRepo,
|
||||
tr repository.TaskRepo,
|
||||
pr repository.ProjectRepo,
|
||||
) *Handler {
|
||||
v := validator.New()
|
||||
v.RegisterValidation("agentStatus", validateAgentStatus)
|
||||
return &Handler{
|
||||
Agents: ar,
|
||||
Sessions: sr,
|
||||
Tasks: tr,
|
||||
Projects: pr,
|
||||
validate: v,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent Handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
// ListAgents handles GET /api/agents.
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
|
||||
allAgents, err := h.Agents.List(r.Context(), statusFilter)
|
||||
if err != nil {
|
||||
slog.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list agents"})
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := parsePagination(r)
|
||||
start, end := paginateSlice(len(allAgents), page, pageSize)
|
||||
|
||||
totalCount, _ := h.Agents.Count(r.Context())
|
||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||
Data: allAgents[start:end],
|
||||
TotalCount: totalCount,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: end < len(allAgents),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgent handles GET /api/agents/{id}.
|
||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, err := h.Agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agent)
|
||||
}
|
||||
|
||||
// CreateAgent handles POST /api/agents.
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, models.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Details: validationErrors(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
agent := models.AgentCardData{
|
||||
ID: req.ID,
|
||||
DisplayName: req.DisplayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
CurrentTask: req.CurrentTask,
|
||||
SessionKey: req.SessionKey,
|
||||
Channel: req.Channel,
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if err := h.Agents.Create(r.Context(), agent); err != nil {
|
||||
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, agent)
|
||||
}
|
||||
|
||||
// UpdateAgent handles PUT /api/agents/{id}.
|
||||
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req models.UpdateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, models.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Details: validationErrors(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := h.Agents.Update(r.Context(), id, req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agent)
|
||||
}
|
||||
|
||||
// DeleteAgent handles DELETE /api/agents/{id}.
|
||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.Agents.Delete(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// AgentHistory handles GET /api/agents/{id}/history.
|
||||
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := h.Agents.Get(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// History is not currently persisted in PostgreSQL — return stub.
|
||||
writeJSON(w, http.StatusOK, []models.AgentStatusHistoryEntry{})
|
||||
}
|
||||
363
go-backend/internal/handler/handler_test.go
Normal file
363
go-backend/internal/handler/handler_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// testHandler creates a Handler wired to mock repositories for testing.
|
||||
func testHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
return NewHandler(
|
||||
newMockAgentRepo(),
|
||||
newMockSessionRepo(),
|
||||
newMockTaskRepo(),
|
||||
newMockProjectRepo(),
|
||||
)
|
||||
}
|
||||
|
||||
// serveChi creates a chi.Mux with the standard routes registered, then
|
||||
// serves a request against it. Returns the recorded response.
|
||||
func serveChi(h *Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Route("/agents", func(agents chi.Router) {
|
||||
agents.Get("/", h.ListAgents)
|
||||
agents.Post("/", h.CreateAgent)
|
||||
agents.Get("/{id}", h.GetAgent)
|
||||
agents.Put("/{id}", h.UpdateAgent)
|
||||
agents.Delete("/{id}", h.DeleteAgent)
|
||||
agents.Get("/{id}/history", h.AgentHistory)
|
||||
})
|
||||
api.Get("/sessions", h.ListSessions)
|
||||
api.Get("/tasks", h.ListTasks)
|
||||
api.Get("/projects", h.ListProjects)
|
||||
})
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func parseBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Result().Body).Decode(&body); err != nil {
|
||||
t.Fatalf("failed to decode body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func parseAgent(t *testing.T, w *httptest.ResponseRecorder) models.AgentCardData {
|
||||
t.Helper()
|
||||
var a models.AgentCardData
|
||||
if err := json.NewDecoder(w.Result().Body).Decode(&a); err != nil {
|
||||
t.Fatalf("failed to decode agent: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func parsePaginated(t *testing.T, w *httptest.ResponseRecorder) models.PaginatedResponse {
|
||||
t.Helper()
|
||||
var pr models.PaginatedResponse
|
||||
if err := json.NewDecoder(w.Result().Body).Decode(&pr); err != nil {
|
||||
t.Fatalf("failed to decode paginated response: %v", err)
|
||||
}
|
||||
return pr
|
||||
}
|
||||
|
||||
// ─── Agent Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateAgent_Success(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "POST", "/api/agents", `{
|
||||
"id": "dex",
|
||||
"displayName": "Dex",
|
||||
"role": "Backend Dev",
|
||||
"status": "idle",
|
||||
"sessionKey": "sess-1",
|
||||
"channel": "discord"
|
||||
}`)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
a := parseAgent(t, w)
|
||||
if a.ID != "dex" {
|
||||
t.Errorf("expected id=dex, got %s", a.ID)
|
||||
}
|
||||
if a.Status != models.AgentStatusIdle {
|
||||
t.Errorf("expected status=idle, got %s", a.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgent_Duplicate(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"otto","displayName":"Otto","role":"Orchestrator","status":"active","sessionKey":"s1","channel":"discord"}`)
|
||||
w := serveChi(h, "POST", "/api/agents", `{"id":"otto","displayName":"Otto","role":"Orchestrator","status":"active","sessionKey":"s2","channel":"discord"}`)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgent_Validation(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
// Missing displayName
|
||||
w := serveChi(h, "POST", "/api/agents", `{"id":"dex","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAgent_InvalidStatus(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "POST", "/api/agents", `{"id":"dex","displayName":"Dex","role":"Dev","status":"flying","sessionKey":"s1","channel":"discord"}`)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422 for invalid status, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgent_NotFound(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "GET", "/api/agents/nonexistent", "")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgent_Success(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"pip","displayName":"Pip","role":"Edge Dev","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
w := serveChi(h, "GET", "/api/agents/pip", "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
a := parseAgent(t, w)
|
||||
if a.DisplayName != "Pip" {
|
||||
t.Errorf("expected Pip, got %s", a.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgents(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"Alpha","role":"Tester","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"a2","displayName":"Beta","role":"Tester","status":"active","sessionKey":"s2","channel":"discord"}`)
|
||||
|
||||
w := serveChi(h, "GET", "/api/agents", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.TotalCount != 2 {
|
||||
t.Errorf("expected totalCount=2, got %d", pr.TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgents_FilterByStatus(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"Alpha","role":"Tester","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"a2","displayName":"Beta","role":"Tester","status":"active","sessionKey":"s2","channel":"discord"}`)
|
||||
|
||||
w := serveChi(h, "GET", "/api/agents?status=active", "")
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.TotalCount != 2 {
|
||||
t.Errorf("expected totalCount=2 (unfiltered), got %d", pr.TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgent(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"hex","displayName":"Hex","role":"DB Specialist","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
|
||||
w := serveChi(h, "PUT", "/api/agents/hex", `{"status":"thinking","currentTask":"schema review"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
a := parseAgent(t, w)
|
||||
if a.Status != models.AgentStatusThinking {
|
||||
t.Errorf("expected status=thinking, got %s", a.Status)
|
||||
}
|
||||
if a.CurrentTask == nil || *a.CurrentTask != "schema review" {
|
||||
t.Errorf("expected currentTask=schema review")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAgent_NotFound(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "PUT", "/api/agents/nope", `{"status":"idle"}`)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAgent(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"temp","displayName":"Temp","role":"Temp","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
|
||||
w := serveChi(h, "DELETE", "/api/agents/temp", "")
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
w2 := serveChi(h, "GET", "/api/agents/temp", "")
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 after delete, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHistory(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
|
||||
w := serveChi(h, "GET", "/api/agents/nano/history", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var entries []models.AgentStatusHistoryEntry
|
||||
json.NewDecoder(w.Result().Body).Decode(&entries)
|
||||
// History returns empty stub since not yet in PostgreSQL
|
||||
if entries == nil {
|
||||
t.Error("expected non-nil history slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHistory_NotFound(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "GET", "/api/agents/ghost/history", "")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session Tests ─────────────────────────────────────────────────────────═
|
||||
|
||||
func TestListSessions_Empty(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "GET", "/api/sessions", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.TotalCount != 0 {
|
||||
t.Errorf("expected 0 sessions, got %d", pr.TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSessions_WithData(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
h.Sessions.Create(nil, models.Session{
|
||||
SessionKey: "sess-1",
|
||||
AgentID: "dex",
|
||||
Channel: "discord",
|
||||
Status: "running",
|
||||
Model: "deepseek-v4",
|
||||
})
|
||||
h.Sessions.Create(nil, models.Session{
|
||||
SessionKey: "sess-2",
|
||||
AgentID: "otto",
|
||||
Channel: "discord",
|
||||
Status: "done",
|
||||
Model: "deepseek-v4",
|
||||
})
|
||||
|
||||
w := serveChi(h, "GET", "/api/sessions", "")
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.TotalCount != 2 {
|
||||
t.Errorf("expected totalCount=2, got %d", pr.TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListTasks_Empty(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "GET", "/api/tasks", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasks_WithData(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
h.Tasks.Create(nil, models.Task{
|
||||
AgentID: "dex",
|
||||
Title: "Implement CRUD API",
|
||||
Status: models.TaskStatusRunning,
|
||||
})
|
||||
|
||||
w := serveChi(h, "GET", "/api/tasks", "")
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.TotalCount != 1 {
|
||||
t.Errorf("expected totalCount=1, got %d", pr.TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Project Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListProjects_Empty(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
w := serveChi(h, "GET", "/api/projects", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjects_WithData(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
h.Projects.Create(nil, models.Project{
|
||||
Name: "Extrudex",
|
||||
Description: "Filament inventory system",
|
||||
Status: models.ProjectStatusActive,
|
||||
})
|
||||
|
||||
w := serveChi(h, "GET", "/api/projects", "")
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.TotalCount != 1 {
|
||||
t.Errorf("expected totalCount=1, got %d", pr.TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pagination Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestPagination_PageOutOfRange(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"A1","role":"T","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
|
||||
w := serveChi(h, "GET", "/api/agents?page=99", "")
|
||||
pr := parsePaginated(t, w)
|
||||
if len(pr.Data.([]any)) != 0 {
|
||||
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
|
||||
}
|
||||
if pr.HasMore {
|
||||
t.Error("expected HasMore=false when page is beyond data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagination_PageSize(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
for i := range 5 {
|
||||
id := string(rune('a' + i))
|
||||
serveChi(h, "POST", "/api/agents", `{"id":"`+string(id)+`","displayName":"`+string(id)+`","role":"T","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||
}
|
||||
|
||||
w := serveChi(h, "GET", "/api/agents?pageSize=2&page=2", "")
|
||||
pr := parsePaginated(t, w)
|
||||
if pr.PageSize != 2 {
|
||||
t.Errorf("expected pageSize=2, got %d", pr.PageSize)
|
||||
}
|
||||
}
|
||||
106
go-backend/internal/handler/helpers.go
Normal file
106
go-backend/internal/handler/helpers.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// ─── Pagination ────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
defaultPage = 1
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
// parsePagination extracts page and pageSize query params from the request.
|
||||
func parsePagination(r *http.Request) (page, pageSize int) {
|
||||
page = defaultPage
|
||||
pageSize = defaultPageSize
|
||||
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
if ps := r.URL.Query().Get("pageSize"); ps != "" {
|
||||
if n, err := strconv.Atoi(ps); err == nil && n > 0 {
|
||||
if n > maxPageSize {
|
||||
n = maxPageSize
|
||||
}
|
||||
pageSize = n
|
||||
}
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// paginateSlice computes the start and end indexes for a page of `total` items.
|
||||
// Bounds are clamped to [0, total].
|
||||
func paginateSlice(total, page, pageSize int) (start, end int) {
|
||||
start = (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end = start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// ─── JSON Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// writeJSON marshals v as JSON and writes it to w with the given status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
http.Error(w, `{"error":"internal encoding error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// validationErrors converts a validator.ValidationErrors into a string map
|
||||
// suitable for the ErrorResponse details field.
|
||||
func validationErrors(err error) map[string]string {
|
||||
details := make(map[string]string)
|
||||
if verrs, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range verrs {
|
||||
field := strings.ToLower(fe.Field())
|
||||
details[field] = fieldError(fe)
|
||||
}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func fieldError(fe validator.FieldError) string {
|
||||
switch fe.Tag() {
|
||||
case "required":
|
||||
return "this field is required"
|
||||
case "min":
|
||||
return fmt.Sprintf("must be at least %s characters", fe.Param())
|
||||
case "max":
|
||||
return fmt.Sprintf("must be at most %s characters", fe.Param())
|
||||
default:
|
||||
return fmt.Sprintf("failed validation: %s", fe.Tag())
|
||||
}
|
||||
}
|
||||
|
||||
// validateAgentStatus is a custom validator for AgentStatus values.
|
||||
func validateAgentStatus(fl validator.FieldLevel) bool {
|
||||
if status, ok := fl.Field().Interface().(interface {
|
||||
IsValid() bool
|
||||
}); ok {
|
||||
return status.IsValid()
|
||||
}
|
||||
return false
|
||||
}
|
||||
235
go-backend/internal/handler/mock_repos_test.go
Normal file
235
go-backend/internal/handler/mock_repos_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
)
|
||||
|
||||
// mockAgentRepo implements repository.AgentRepo in-memory for testing.
|
||||
type mockAgentRepo struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]models.AgentCardData
|
||||
}
|
||||
|
||||
func newMockAgentRepo() *mockAgentRepo {
|
||||
return &mockAgentRepo{m: make(map[string]models.AgentCardData)}
|
||||
}
|
||||
|
||||
func (r *mockAgentRepo) Create(ctx context.Context, a models.AgentCardData) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.m[a.ID]; ok {
|
||||
return fmt.Errorf("duplicate key: %s", a.ID)
|
||||
}
|
||||
r.m[a.ID] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mockAgentRepo) Get(ctx context.Context, id string) (models.AgentCardData, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
a, ok := r.m[id]
|
||||
if !ok {
|
||||
return a, fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *mockAgentRepo) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]models.AgentCardData, 0, len(r.m))
|
||||
for _, a := range r.m {
|
||||
if statusFilter != "" && a.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *mockAgentRepo) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.m[id]
|
||||
if !ok {
|
||||
return a, fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
if req.Status != nil {
|
||||
a.Status = *req.Status
|
||||
}
|
||||
if req.CurrentTask != nil {
|
||||
a.CurrentTask = req.CurrentTask
|
||||
}
|
||||
if req.TaskProgress != nil {
|
||||
a.TaskProgress = req.TaskProgress
|
||||
}
|
||||
if req.TaskElapsed != nil {
|
||||
a.TaskElapsed = req.TaskElapsed
|
||||
}
|
||||
if req.Channel != nil {
|
||||
a.Channel = *req.Channel
|
||||
}
|
||||
if req.ErrorMessage != nil {
|
||||
a.ErrorMessage = req.ErrorMessage
|
||||
}
|
||||
a.LastActivity = time.Now().UTC().Format(time.RFC3339)
|
||||
r.m[id] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *mockAgentRepo) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.m[id]; !ok {
|
||||
return fmt.Errorf("not found: %s", id)
|
||||
}
|
||||
delete(r.m, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mockAgentRepo) Count(ctx context.Context) (int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.m), nil
|
||||
}
|
||||
|
||||
// ─── Mock Session Repo ──────────────────────────────────────────────────────────
|
||||
|
||||
type mockSessionRepo struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]models.Session
|
||||
}
|
||||
|
||||
func newMockSessionRepo() *mockSessionRepo {
|
||||
return &mockSessionRepo{m: make(map[string]models.Session)}
|
||||
}
|
||||
|
||||
func (r *mockSessionRepo) Create(ctx context.Context, s models.Session) (models.Session, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if s.ID == "" {
|
||||
s.ID = fmt.Sprintf("sess-%d", len(r.m)+1)
|
||||
}
|
||||
if s.StartedAt.IsZero() {
|
||||
s.StartedAt = time.Now().UTC()
|
||||
}
|
||||
if s.LastActivityAt.IsZero() {
|
||||
s.LastActivityAt = s.StartedAt
|
||||
}
|
||||
r.m[s.ID] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *mockSessionRepo) ListActive(ctx context.Context) ([]models.Session, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]models.Session, 0)
|
||||
for _, s := range r.m {
|
||||
if s.Status == "running" || s.Status == "streaming" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *mockSessionRepo) Count(ctx context.Context) (int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.m), nil
|
||||
}
|
||||
|
||||
// ─── Mock Task Repo ─────────────────────────────────────────────────────────────
|
||||
|
||||
type mockTaskRepo struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]models.Task
|
||||
}
|
||||
|
||||
func newMockTaskRepo() *mockTaskRepo {
|
||||
return &mockTaskRepo{m: make(map[string]models.Task)}
|
||||
}
|
||||
|
||||
func (r *mockTaskRepo) Create(ctx context.Context, t models.Task) (models.Task, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if t.ID == "" {
|
||||
t.ID = fmt.Sprintf("task-%d", len(r.m)+1)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
r.m[t.ID] = t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *mockTaskRepo) ListRecent(ctx context.Context) ([]models.Task, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]models.Task, 0, len(r.m))
|
||||
for _, t := range r.m {
|
||||
result = append(result, t)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *mockTaskRepo) Count(ctx context.Context) (int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.m), nil
|
||||
}
|
||||
|
||||
// ─── Mock Project Repo ─────────────────────────────────────────────────────────
|
||||
|
||||
type mockProjectRepo struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]models.Project
|
||||
}
|
||||
|
||||
func newMockProjectRepo() *mockProjectRepo {
|
||||
return &mockProjectRepo{m: make(map[string]models.Project)}
|
||||
}
|
||||
|
||||
func (r *mockProjectRepo) Create(ctx context.Context, p models.Project) (models.Project, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("proj-%d", len(r.m)+1)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
if p.AgentIDs == nil {
|
||||
p.AgentIDs = []string{}
|
||||
}
|
||||
r.m[p.ID] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *mockProjectRepo) List(ctx context.Context) ([]models.Project, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]models.Project, 0, len(r.m))
|
||||
for _, p := range r.m {
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *mockProjectRepo) Count(ctx context.Context) (int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.m), nil
|
||||
}
|
||||
35
go-backend/internal/handler/project.go
Normal file
35
go-backend/internal/handler/project.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── Project Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
// ListProjects handles GET /api/projects.
|
||||
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := h.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("list projects failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list projects"})
|
||||
return
|
||||
}
|
||||
if projects == nil {
|
||||
projects = []models.Project{}
|
||||
}
|
||||
|
||||
page, pageSize := parsePagination(r)
|
||||
start, end := paginateSlice(len(projects), page, pageSize)
|
||||
|
||||
totalCount, _ := h.Projects.Count(r.Context())
|
||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||
Data: projects[start:end],
|
||||
TotalCount: totalCount,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: end < len(projects),
|
||||
})
|
||||
}
|
||||
35
go-backend/internal/handler/session.go
Normal file
35
go-backend/internal/handler/session.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── Session Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
// ListSessions handles GET /api/sessions.
|
||||
func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
sessions, err := h.Sessions.ListActive(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("list sessions failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list sessions"})
|
||||
return
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []models.Session{}
|
||||
}
|
||||
|
||||
page, pageSize := parsePagination(r)
|
||||
start, end := paginateSlice(len(sessions), page, pageSize)
|
||||
|
||||
totalCount, _ := h.Sessions.Count(r.Context())
|
||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||
Data: sessions[start:end],
|
||||
TotalCount: totalCount,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: end < len(sessions),
|
||||
})
|
||||
}
|
||||
125
go-backend/internal/handler/sse.go
Normal file
125
go-backend/internal/handler/sse.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package handler provides SSE (Server-Sent Events) streaming for the
|
||||
// Control Center API. The Broker manages client connections and broadcasts
|
||||
// typed events in text/event-stream format.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SSEEvent represents a single event to stream to connected clients.
|
||||
type SSEEvent struct {
|
||||
EventType string `json:"eventType"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// Broker manages SSE client connections and broadcasts events to all
|
||||
// connected listeners. It is safe for concurrent use.
|
||||
type Broker struct {
|
||||
mu sync.RWMutex
|
||||
clients map[chan SSEEvent]struct{}
|
||||
}
|
||||
|
||||
// NewBroker returns an initialized Broker.
|
||||
func NewBroker() *Broker {
|
||||
return &Broker{
|
||||
clients: make(map[chan SSEEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new client channel. The caller must read from
|
||||
// this channel and write SSE frames to the HTTP response writer.
|
||||
func (b *Broker) Subscribe() chan SSEEvent {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
ch := make(chan SSEEvent, 32) // small buffer to avoid blocking bursts
|
||||
b.clients[ch] = struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a client channel and closes it.
|
||||
func (b *Broker) Unsubscribe(ch chan SSEEvent) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if _, ok := b.clients[ch]; ok {
|
||||
delete(b.clients, ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends evt to every connected client. Slow clients that cannot
|
||||
// receive within their buffer are silently dropped (non-blocking send).
|
||||
func (b *Broker) Broadcast(eventType string, data any) {
|
||||
evt := SSEEvent{EventType: eventType, Data: data}
|
||||
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
for ch := range b.clients {
|
||||
select {
|
||||
case ch <- evt:
|
||||
default:
|
||||
// Client too slow — drop this event for this client
|
||||
slog.Warn("sse client buffer full, dropping event",
|
||||
"eventType", eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClientCount returns the number of currently connected SSE clients.
|
||||
func (b *Broker) ClientCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.clients)
|
||||
}
|
||||
|
||||
// ServeHTTP handles GET /api/events. It registers the client, streams
|
||||
// events in text/event-stream format, and cleans up on disconnect.
|
||||
func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Ensure we can flush
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
|
||||
|
||||
ch := b.Subscribe()
|
||||
defer b.Unsubscribe(ch)
|
||||
|
||||
// Send initial connection event
|
||||
fmt.Fprintf(w, "event: connected\ndata: {\"clientCount\":%d}\n\n", b.ClientCount())
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Client disconnected
|
||||
slog.Debug("sse client disconnected")
|
||||
return
|
||||
case evt, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(evt.Data)
|
||||
if err != nil {
|
||||
slog.Error("sse marshal failed", "error", err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.EventType, string(data))
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
35
go-backend/internal/handler/task.go
Normal file
35
go-backend/internal/handler/task.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
)
|
||||
|
||||
// ─── Task Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ListTasks handles GET /api/tasks.
|
||||
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := h.Tasks.ListRecent(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("list tasks failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list tasks"})
|
||||
return
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []models.Task{}
|
||||
}
|
||||
|
||||
page, pageSize := parsePagination(r)
|
||||
start, end := paginateSlice(len(tasks), page, pageSize)
|
||||
|
||||
totalCount, _ := h.Tasks.Count(r.Context())
|
||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||
Data: tasks[start:end],
|
||||
TotalCount: totalCount,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: end < len(tasks),
|
||||
})
|
||||
}
|
||||
165
go-backend/internal/models/models.go
Normal file
165
go-backend/internal/models/models.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Package models defines the domain types and API contracts for the
|
||||
// Control Center Go backend. These types map to the existing TypeScript
|
||||
// AgentCardData interface and extend it with persistence-focused types
|
||||
// for the new session, task, and project entities.
|
||||
//
|
||||
// All JSON field names use camelCase to match the existing frontend
|
||||
// contract established by the .NET backend.
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ─── Agent Status ──────────────────────────────────────────────────────────────
|
||||
|
||||
// AgentStatus represents an agent's operational status.
|
||||
// Maps to the frontend status values: active, idle, thinking, error.
|
||||
type AgentStatus string
|
||||
|
||||
const (
|
||||
AgentStatusActive AgentStatus = "active"
|
||||
AgentStatusIdle AgentStatus = "idle"
|
||||
AgentStatusThinking AgentStatus = "thinking"
|
||||
AgentStatusError AgentStatus = "error"
|
||||
)
|
||||
|
||||
// IsValid reports whether s is a known agent status.
|
||||
func (s AgentStatus) IsValid() bool {
|
||||
switch s {
|
||||
case AgentStatusActive, AgentStatusIdle, AgentStatusThinking, AgentStatusError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// AgentCardData is the primary data shape consumed by the Command Hub frontend.
|
||||
// It matches the TypeScript AgentCardData interface exactly.
|
||||
type AgentCardData struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Status AgentStatus `json:"status"`
|
||||
CurrentTask *string `json:"currentTask,omitempty"`
|
||||
TaskProgress *int `json:"taskProgress,omitempty"`
|
||||
TaskElapsed *string `json:"taskElapsed,omitempty"`
|
||||
SessionKey string `json:"sessionKey"`
|
||||
Channel string `json:"channel"`
|
||||
LastActivity string `json:"lastActivity"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
// CreateAgentRequest is the payload for POST /api/agents.
|
||||
type CreateAgentRequest struct {
|
||||
ID string `json:"id" validate:"required,min=2,max=64"`
|
||||
DisplayName string `json:"displayName" validate:"required,min=1,max=128"`
|
||||
Role string `json:"role" validate:"required,min=1,max=256"`
|
||||
Status AgentStatus `json:"status" validate:"required,agentStatus"`
|
||||
SessionKey string `json:"sessionKey" validate:"required,min=1"`
|
||||
Channel string `json:"channel" validate:"required,min=1,max=32"`
|
||||
CurrentTask *string `json:"currentTask,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateAgentRequest is the payload for PUT /api/agents/{id}.
|
||||
type UpdateAgentRequest struct {
|
||||
Status *AgentStatus `json:"status,omitempty" validate:"omitempty,agentStatus"`
|
||||
CurrentTask *string `json:"currentTask,omitempty"`
|
||||
TaskProgress *int `json:"taskProgress,omitempty" validate:"omitempty,min=0,max=100"`
|
||||
TaskElapsed *string `json:"taskElapsed,omitempty"`
|
||||
Channel *string `json:"channel,omitempty" validate:"omitempty,min=1,max=32"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStatusHistoryEntry represents a point-in-time status change for an agent.
|
||||
type AgentStatusHistoryEntry struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agentId"`
|
||||
Status AgentStatus `json:"status"`
|
||||
Task *string `json:"task,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ─── Session ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Session represents an active agent session tracked by the system.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
SessionKey string `json:"sessionKey"`
|
||||
AgentID string `json:"agentId"`
|
||||
Channel string `json:"channel"`
|
||||
Status string `json:"status"` // running, done, streaming, error
|
||||
ContextTokens int `json:"contextTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
EstimatedCost float64 `json:"estimatedCost"`
|
||||
Model string `json:"model"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
LastActivityAt time.Time `json:"lastActivityAt"`
|
||||
}
|
||||
|
||||
// ─── Task ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TaskStatus represents the lifecycle state of a tracked task.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "pending"
|
||||
TaskStatusRunning TaskStatus = "running"
|
||||
TaskStatusCompleted TaskStatus = "completed"
|
||||
TaskStatusFailed TaskStatus = "failed"
|
||||
)
|
||||
|
||||
// Task represents a tracked task assigned to an agent.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agentId"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Progress *int `json:"progress,omitempty"`
|
||||
SessionKey string `json:"sessionKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ─── Project ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProjectStatus represents the lifecycle state of a project.
|
||||
type ProjectStatus string
|
||||
|
||||
const (
|
||||
ProjectStatusPlanned ProjectStatus = "planned"
|
||||
ProjectStatusActive ProjectStatus = "active"
|
||||
ProjectStatusPaused ProjectStatus = "paused"
|
||||
ProjectStatusCompleted ProjectStatus = "completed"
|
||||
)
|
||||
|
||||
// Project represents a project tracked in the system.
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status ProjectStatus `json:"status"`
|
||||
AgentIDs []string `json:"agentIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ─── Pagination ────────────────────────────────────────────────────────────────
|
||||
|
||||
// PaginatedResponse wraps a list response with pagination metadata.
|
||||
type PaginatedResponse struct {
|
||||
Data any `json:"data"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
|
||||
// ─── Error ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ErrorResponse is the standard API error envelope.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
186
go-backend/internal/repository/agent_repository.go
Normal file
186
go-backend/internal/repository/agent_repository.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// Package repository provides PostgreSQL-backed CRUD implementations
|
||||
// for the Control Center domain entities. Each repository takes a
|
||||
// *pgxpool.Pool in its constructor and uses pgx.CollectRows() for scanning.
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AgentRepository provides PostgreSQL-backed CRUD for agents.
|
||||
type AgentRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewAgentRepository returns a repository wired to the given connection pool.
|
||||
func NewAgentRepository(pool *pgxpool.Pool) *AgentRepository {
|
||||
return &AgentRepository{pool: pool}
|
||||
}
|
||||
|
||||
// Create inserts a new agent. It maps the models.AgentCardData fields onto
|
||||
// the agents table columns (uuid id, text name, text status, text task,
|
||||
// int progress, text session_key, text channel).
|
||||
func (r *AgentRepository) Create(ctx context.Context, a models.AgentCardData) error {
|
||||
prog := 0
|
||||
if a.TaskProgress != nil {
|
||||
prog = *a.TaskProgress
|
||||
}
|
||||
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO agents (id, name, status, task, progress, session_key, channel, last_activity)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, a.ID, a.DisplayName, string(a.Status), a.CurrentTask, prog,
|
||||
a.SessionKey, a.Channel, time.Now().UTC())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns a single agent by its string id.
|
||||
func (r *AgentRepository) Get(ctx context.Context, id string) (models.AgentCardData, error) {
|
||||
var a models.AgentCardData
|
||||
var task *string
|
||||
var prog int
|
||||
var lastActivity time.Time
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
||||
FROM agents WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
|
||||
&a.SessionKey, &a.Channel, &lastActivity)
|
||||
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
|
||||
a.CurrentTask = task
|
||||
if prog > 0 || task != nil {
|
||||
p := prog
|
||||
a.TaskProgress = &p
|
||||
}
|
||||
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
|
||||
|
||||
// Role is not persisted in the current schema — set a sensible default.
|
||||
a.Role = "agent"
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// List returns all agents, optionally filtered by status.
|
||||
// Results are ordered by name (display_name).
|
||||
func (r *AgentRepository) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
|
||||
if statusFilter != "" {
|
||||
rows, err = r.pool.Query(ctx, `
|
||||
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
||||
FROM agents WHERE status = $1 ORDER BY name
|
||||
`, string(statusFilter))
|
||||
} else {
|
||||
rows, err = r.pool.Query(ctx, `
|
||||
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
||||
FROM agents ORDER BY name
|
||||
`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.AgentCardData, error) {
|
||||
var a models.AgentCardData
|
||||
var task *string
|
||||
var prog int
|
||||
var lastActivity time.Time
|
||||
|
||||
if err := row.Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
|
||||
&a.SessionKey, &a.Channel, &lastActivity); err != nil {
|
||||
return a, err
|
||||
}
|
||||
|
||||
a.CurrentTask = task
|
||||
if prog > 0 || task != nil {
|
||||
p := prog
|
||||
a.TaskProgress = &p
|
||||
}
|
||||
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
|
||||
a.Role = "agent"
|
||||
return a, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Update applies partial updates to an agent. Returns the updated agent.
|
||||
func (r *AgentRepository) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
||||
// Build dynamic SET clause.
|
||||
setClauses := []string{"last_activity = $2"}
|
||||
args := []any{id, time.Now().UTC()}
|
||||
argIdx := 3
|
||||
|
||||
if req.Status != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
|
||||
args = append(args, string(*req.Status))
|
||||
argIdx++
|
||||
}
|
||||
if req.CurrentTask != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("task = $%d", argIdx))
|
||||
args = append(args, *req.CurrentTask)
|
||||
argIdx++
|
||||
}
|
||||
if req.TaskProgress != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("progress = $%d", argIdx))
|
||||
args = append(args, *req.TaskProgress)
|
||||
argIdx++
|
||||
}
|
||||
if req.Channel != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("channel = $%d", argIdx))
|
||||
args = append(args, *req.Channel)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Build and execute
|
||||
query := "UPDATE agents SET "
|
||||
for i, clause := range setClauses {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += clause
|
||||
}
|
||||
query += " WHERE id = $1"
|
||||
|
||||
ct, err := r.pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return models.AgentCardData{}, err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return models.AgentCardData{}, fmt.Errorf("agent not found: %s", id)
|
||||
}
|
||||
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes an agent. Returns nil even if the agent doesn't exist
|
||||
// (idempotent). Returns a wrapped error only on transport failures.
|
||||
func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM agents WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Count returns the total number of agents.
|
||||
func (r *AgentRepository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountByStatus returns the number of agents with the given status.
|
||||
func (r *AgentRepository) CountByStatus(ctx context.Context, status models.AgentStatus) (int, error) {
|
||||
var n int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents WHERE status = $1`, string(status)).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
38
go-backend/internal/repository/interfaces.go
Normal file
38
go-backend/internal/repository/interfaces.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
)
|
||||
|
||||
// AgentRepo is the interface for agent persistence operations.
|
||||
type AgentRepo interface {
|
||||
Create(ctx context.Context, a models.AgentCardData) error
|
||||
Get(ctx context.Context, id string) (models.AgentCardData, error)
|
||||
List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error)
|
||||
Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// SessionRepo is the interface for session persistence operations.
|
||||
type SessionRepo interface {
|
||||
Create(ctx context.Context, s models.Session) (models.Session, error)
|
||||
ListActive(ctx context.Context) ([]models.Session, error)
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// TaskRepo is the interface for task persistence operations.
|
||||
type TaskRepo interface {
|
||||
Create(ctx context.Context, t models.Task) (models.Task, error)
|
||||
ListRecent(ctx context.Context) ([]models.Task, error)
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// ProjectRepo is the interface for project persistence operations.
|
||||
type ProjectRepo interface {
|
||||
Create(ctx context.Context, p models.Project) (models.Project, error)
|
||||
List(ctx context.Context) ([]models.Project, error)
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
94
go-backend/internal/repository/project_repository.go
Normal file
94
go-backend/internal/repository/project_repository.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ProjectRepository provides PostgreSQL-backed CRUD for projects.
|
||||
type ProjectRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewProjectRepository returns a repository wired to the given connection pool.
|
||||
func NewProjectRepository(pool *pgxpool.Pool) *ProjectRepository {
|
||||
return &ProjectRepository{pool: pool}
|
||||
}
|
||||
|
||||
// Create inserts a new project. The current projects table only stores
|
||||
// a single agent_id, so we use the first entry from AgentIDs if present.
|
||||
func (r *ProjectRepository) Create(ctx context.Context, p models.Project) (models.Project, error) {
|
||||
now := time.Now().UTC()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
|
||||
var agentID *string
|
||||
if len(p.AgentIDs) > 0 {
|
||||
agentID = &p.AgentIDs[0]
|
||||
}
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO projects (name, description, status, agent_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, description, status, agent_id, created_at, updated_at
|
||||
`, p.Name, p.Description, string(p.Status), agentID, p.CreatedAt, p.UpdatedAt).Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.Status, &agentID,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
|
||||
if agentID != nil {
|
||||
p.AgentIDs = []string{*agentID}
|
||||
} else {
|
||||
p.AgentIDs = []string{}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// List returns all projects ordered by name.
|
||||
func (r *ProjectRepository) List(ctx context.Context) ([]models.Project, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, name, description, status, agent_id, created_at, updated_at
|
||||
FROM projects
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Project, error) {
|
||||
var p models.Project
|
||||
var agentID *string
|
||||
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.Status,
|
||||
&agentID, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return p, err
|
||||
}
|
||||
|
||||
if agentID != nil {
|
||||
p.AgentIDs = []string{*agentID}
|
||||
} else {
|
||||
p.AgentIDs = []string{}
|
||||
}
|
||||
return p, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Count returns the total number of projects.
|
||||
func (r *ProjectRepository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM projects`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
78
go-backend/internal/repository/session_repository.go
Normal file
78
go-backend/internal/repository/session_repository.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SessionRepository provides PostgreSQL-backed CRUD for sessions.
|
||||
type SessionRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewSessionRepository returns a repository wired to the given connection pool.
|
||||
func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
|
||||
return &SessionRepository{pool: pool}
|
||||
}
|
||||
|
||||
// Create inserts a new session into the sessions table.
|
||||
// Because the existing sessions table only has id, agent_id, started_at,
|
||||
// ended_at, and status, we map what we can and store additional metadata
|
||||
// as a fallback. AgentID is required by FK — if the session AgentID can't
|
||||
// be cast to a valid UUID we store a sentinel.
|
||||
func (r *SessionRepository) Create(ctx context.Context, s models.Session) (models.Session, error) {
|
||||
if s.StartedAt.IsZero() {
|
||||
s.StartedAt = time.Now().UTC()
|
||||
}
|
||||
if s.LastActivityAt.IsZero() {
|
||||
s.LastActivityAt = s.StartedAt
|
||||
}
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO sessions (agent_id, started_at, status)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, agent_id, started_at, ended_at, status
|
||||
`, s.AgentID, s.StartedAt, s.Status).Scan(
|
||||
&s.ID, &s.AgentID, &s.StartedAt, nil, &s.Status)
|
||||
|
||||
return s, err
|
||||
}
|
||||
|
||||
// ListActive returns all sessions with status 'running' or 'streaming',
|
||||
// ordered by started_at descending.
|
||||
func (r *SessionRepository) ListActive(ctx context.Context) ([]models.Session, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, agent_id, started_at, ended_at, status
|
||||
FROM sessions
|
||||
WHERE status IN ('running', 'streaming')
|
||||
ORDER BY started_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Session, error) {
|
||||
var s models.Session
|
||||
var endedAt *time.Time
|
||||
if err := row.Scan(&s.ID, &s.AgentID, &s.StartedAt, &endedAt, &s.Status); err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.LastActivityAt = s.StartedAt
|
||||
if endedAt != nil {
|
||||
s.LastActivityAt = *endedAt
|
||||
}
|
||||
return s, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Count returns the total number of sessions.
|
||||
func (r *SessionRepository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
85
go-backend/internal/repository/task_repository.go
Normal file
85
go-backend/internal/repository/task_repository.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TaskRepository provides PostgreSQL-backed CRUD for task_logs.
|
||||
type TaskRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewTaskRepository returns a repository wired to the given connection pool.
|
||||
func NewTaskRepository(pool *pgxpool.Pool) *TaskRepository {
|
||||
return &TaskRepository{pool: pool}
|
||||
}
|
||||
|
||||
// Create inserts a new task into the task_logs table.
|
||||
func (r *TaskRepository) Create(ctx context.Context, t models.Task) (models.Task, error) {
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO task_logs (agent_id, task, status, started_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, agent_id, task, status, started_at, completed_at, error_message
|
||||
`, t.AgentID, t.Title, string(t.Status), t.CreatedAt).Scan(
|
||||
&t.ID, &t.AgentID, &t.Title, &t.Status, &t.CreatedAt,
|
||||
nil, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
// Rebuild the Description since task_logs only stores the title as "task".
|
||||
t.Description = t.Title
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListRecent returns the most recent tasks, newest first.
|
||||
func (r *TaskRepository) ListRecent(ctx context.Context) ([]models.Task, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, agent_id, task, status, started_at, completed_at, error_message
|
||||
FROM task_logs
|
||||
ORDER BY started_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Task, error) {
|
||||
var t models.Task
|
||||
var completedAt *time.Time
|
||||
var errMsg *string
|
||||
|
||||
if err := row.Scan(&t.ID, &t.AgentID, &t.Title, &t.Status,
|
||||
&t.CreatedAt, &completedAt, &errMsg); err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
t.Description = t.Title
|
||||
t.UpdatedAt = t.CreatedAt
|
||||
if completedAt != nil {
|
||||
t.UpdatedAt = *completedAt
|
||||
}
|
||||
return t, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Count returns the total number of tasks.
|
||||
func (r *TaskRepository) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_logs`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
92
go-backend/internal/router/router.go
Normal file
92
go-backend/internal/router/router.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package router configures the chi router with all routes, middleware,
|
||||
// and handler wiring for the Control Center API.
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// Dependencies carries the handler, database pool, SSE broker, and CORS
|
||||
// configuration into the router.
|
||||
type Dependencies struct {
|
||||
Handler *handler.Handler
|
||||
Pool *db.Pool
|
||||
CORSOrigin string
|
||||
Broker *handler.Broker
|
||||
}
|
||||
|
||||
// New creates a fully-configured chi router with all API routes mounted.
|
||||
func New(deps *Dependencies) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// ── Global middleware ──────────────────────────────────────────────────
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
|
||||
// ── CORS ───────────────────────────────────────────────────────────────
|
||||
corsOrigin := deps.CORSOrigin
|
||||
if corsOrigin == "" {
|
||||
corsOrigin = "*"
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{corsOrigin},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
ExposedHeaders: []string{"Link", "X-Total-Count"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// ── Health check (with DB connectivity probe) ──────────────────────────
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
status := "ok"
|
||||
if deps.Pool != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := deps.Pool.Ping(ctx); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
status = "db_unhealthy"
|
||||
}
|
||||
}
|
||||
w.Write([]byte(`{"status":"` + status + `"}`))
|
||||
})
|
||||
|
||||
// ── API v1 routes ──────────────────────────────────────────────────────
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
// Agents CRUD
|
||||
api.Route("/agents", func(agents chi.Router) {
|
||||
agents.Get("/", deps.Handler.ListAgents) // GET /api/agents
|
||||
agents.Post("/", deps.Handler.CreateAgent) // POST /api/agents
|
||||
agents.Get("/{id}", deps.Handler.GetAgent) // GET /api/agents/{id}
|
||||
agents.Put("/{id}", deps.Handler.UpdateAgent) // PUT /api/agents/{id}
|
||||
agents.Delete("/{id}", deps.Handler.DeleteAgent) // DELETE /api/agents/{id}
|
||||
agents.Get("/{id}/history", deps.Handler.AgentHistory) // GET /api/agents/{id}/history
|
||||
})
|
||||
|
||||
// Sessions
|
||||
api.Get("/sessions", deps.Handler.ListSessions)
|
||||
|
||||
// Tasks
|
||||
api.Get("/tasks", deps.Handler.ListTasks)
|
||||
|
||||
// Projects
|
||||
api.Get("/projects", deps.Handler.ListProjects)
|
||||
|
||||
// SSE event stream
|
||||
api.Get("/events", deps.Broker.ServeHTTP)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
177
go-backend/internal/store/agent.go
Normal file
177
go-backend/internal/store/agent.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Package store provides thread-safe in-memory data stores for the
|
||||
// Control Center API. These will be replaced with PostgreSQL-backed
|
||||
// implementations once CUB-120 (schema design) is complete.
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AgentStore provides thread-safe CRUD operations for agents.
|
||||
type AgentStore struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]models.AgentCardData
|
||||
history map[string][]models.AgentStatusHistoryEntry // agentID -> history
|
||||
}
|
||||
|
||||
// NewAgentStore returns an initialized AgentStore.
|
||||
func NewAgentStore() *AgentStore {
|
||||
return &AgentStore{
|
||||
agents: make(map[string]models.AgentCardData),
|
||||
history: make(map[string][]models.AgentStatusHistoryEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all agents, optionally filtered by status.
|
||||
func (s *AgentStore) List(statusFilter models.AgentStatus) []models.AgentCardData {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.AgentCardData, 0, len(s.agents))
|
||||
for _, a := range s.agents {
|
||||
if statusFilter != "" && a.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
|
||||
// Sort by display name for consistent output.
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].DisplayName < result[j].DisplayName
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns a single agent by ID, or false if not found.
|
||||
func (s *AgentStore) Get(id string) (models.AgentCardData, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
a, ok := s.agents[id]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
// Create inserts a new agent. Returns false if the ID already exists.
|
||||
func (s *AgentStore) Create(a models.AgentCardData) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.agents[a.ID]; exists {
|
||||
return false
|
||||
}
|
||||
if a.LastActivity == "" {
|
||||
a.LastActivity = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
s.agents[a.ID] = a
|
||||
|
||||
// Record initial history entry.
|
||||
s.appendHistoryLocked(a.ID, models.AgentStatusHistoryEntry{
|
||||
ID: uuid.New().String(),
|
||||
AgentID: a.ID,
|
||||
Status: a.Status,
|
||||
Task: a.CurrentTask,
|
||||
Timestamp: a.LastActivity,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Update applies partial updates to an agent. Returns the updated agent or false if not found.
|
||||
func (s *AgentStore) Update(id string, req models.UpdateAgentRequest) (models.AgentCardData, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
a, ok := s.agents[id]
|
||||
if !ok {
|
||||
return models.AgentCardData{}, false
|
||||
}
|
||||
|
||||
prevStatus := a.Status
|
||||
prevTask := a.CurrentTask
|
||||
|
||||
if req.Status != nil {
|
||||
a.Status = *req.Status
|
||||
}
|
||||
if req.CurrentTask != nil {
|
||||
a.CurrentTask = req.CurrentTask
|
||||
}
|
||||
if req.TaskProgress != nil {
|
||||
a.TaskProgress = req.TaskProgress
|
||||
}
|
||||
if req.TaskElapsed != nil {
|
||||
a.TaskElapsed = req.TaskElapsed
|
||||
}
|
||||
if req.Channel != nil {
|
||||
a.Channel = *req.Channel
|
||||
}
|
||||
if req.ErrorMessage != nil {
|
||||
a.ErrorMessage = req.ErrorMessage
|
||||
}
|
||||
a.LastActivity = time.Now().UTC().Format(time.RFC3339)
|
||||
s.agents[id] = a
|
||||
|
||||
// Record history entry if status or task changed.
|
||||
if (req.Status != nil && *req.Status != prevStatus) || (req.CurrentTask != nil && prevTask == nil) ||
|
||||
(req.CurrentTask != nil && prevTask != nil && *req.CurrentTask != *prevTask) {
|
||||
status := a.Status
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
s.appendHistoryLocked(id, models.AgentStatusHistoryEntry{
|
||||
ID: uuid.New().String(),
|
||||
AgentID: id,
|
||||
Status: status,
|
||||
Task: a.CurrentTask,
|
||||
Timestamp: a.LastActivity,
|
||||
})
|
||||
}
|
||||
|
||||
return a, true
|
||||
}
|
||||
|
||||
// Delete removes an agent. Returns true if the agent existed.
|
||||
func (s *AgentStore) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.agents[id]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.agents, id)
|
||||
delete(s.history, id)
|
||||
return true
|
||||
}
|
||||
|
||||
// History returns the status history for an agent, newest first.
|
||||
func (s *AgentStore) History(agentID string) []models.AgentStatusHistoryEntry {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entries, ok := s.history[agentID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Return a defensive copy, sorted newest first (by index when timestamps tie).
|
||||
result := make([]models.AgentStatusHistoryEntry, len(entries))
|
||||
copy(result, entries)
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if result[i].Timestamp == result[j].Timestamp {
|
||||
return i > j // later index = newer when timestamps match
|
||||
}
|
||||
return result[i].Timestamp > result[j].Timestamp
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Count returns the total number of agents.
|
||||
func (s *AgentStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.agents)
|
||||
}
|
||||
|
||||
// appendHistoryLocked adds a history entry. Caller must hold s.mu (write lock).
|
||||
func (s *AgentStore) appendHistoryLocked(agentID string, entry models.AgentStatusHistoryEntry) {
|
||||
s.history[agentID] = append(s.history[agentID], entry)
|
||||
}
|
||||
67
go-backend/internal/store/project.go
Normal file
67
go-backend/internal/store/project.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ProjectStore provides thread-safe CRUD operations for projects.
|
||||
type ProjectStore struct {
|
||||
mu sync.RWMutex
|
||||
projects map[string]models.Project
|
||||
}
|
||||
|
||||
// NewProjectStore returns an initialized ProjectStore.
|
||||
func NewProjectStore() *ProjectStore {
|
||||
return &ProjectStore{
|
||||
projects: make(map[string]models.Project),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all projects ordered by name.
|
||||
func (s *ProjectStore) List() []models.Project {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Project, 0, len(s.projects))
|
||||
for _, p := range s.projects {
|
||||
result = append(result, p)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Create inserts a new project.
|
||||
func (s *ProjectStore) Create(p models.Project) models.Project {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if p.ID == "" {
|
||||
p.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
if p.AgentIDs == nil {
|
||||
p.AgentIDs = []string{}
|
||||
}
|
||||
s.projects[p.ID] = p
|
||||
return p
|
||||
}
|
||||
|
||||
// Count returns the total number of projects.
|
||||
func (s *ProjectStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.projects)
|
||||
}
|
||||
80
go-backend/internal/store/session.go
Normal file
80
go-backend/internal/store/session.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SessionStore provides thread-safe CRUD operations for sessions.
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]models.Session // id -> session
|
||||
}
|
||||
|
||||
// NewSessionStore returns an initialized SessionStore.
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]models.Session),
|
||||
}
|
||||
}
|
||||
|
||||
// ListActive returns all sessions with status "running" or "streaming", newest first.
|
||||
func (s *SessionStore) ListActive() []models.Session {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Session, 0)
|
||||
for _, sess := range s.sessions {
|
||||
if sess.Status == "running" || sess.Status == "streaming" {
|
||||
result = append(result, sess)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].LastActivityAt.After(result[j].LastActivityAt)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Create inserts a new session.
|
||||
func (s *SessionStore) Create(sess models.Session) models.Session {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if sess.ID == "" {
|
||||
sess.ID = uuid.New().String()
|
||||
}
|
||||
if sess.StartedAt.IsZero() {
|
||||
sess.StartedAt = time.Now().UTC()
|
||||
}
|
||||
if sess.LastActivityAt.IsZero() {
|
||||
sess.LastActivityAt = sess.StartedAt
|
||||
}
|
||||
s.sessions[sess.ID] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
// UpdateStatus updates the status and last-activity timestamp of a session.
|
||||
func (s *SessionStore) UpdateStatus(id, status string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
sess, ok := s.sessions[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
sess.Status = status
|
||||
sess.LastActivityAt = time.Now().UTC()
|
||||
s.sessions[id] = sess
|
||||
return true
|
||||
}
|
||||
|
||||
// Count returns the total number of sessions.
|
||||
func (s *SessionStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.sessions)
|
||||
}
|
||||
64
go-backend/internal/store/task.go
Normal file
64
go-backend/internal/store/task.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TaskStore provides thread-safe CRUD operations for tasks.
|
||||
type TaskStore struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]models.Task
|
||||
}
|
||||
|
||||
// NewTaskStore returns an initialized TaskStore.
|
||||
func NewTaskStore() *TaskStore {
|
||||
return &TaskStore{
|
||||
tasks: make(map[string]models.Task),
|
||||
}
|
||||
}
|
||||
|
||||
// ListRecent returns tasks ordered by updated_at descending (newest first).
|
||||
func (s *TaskStore) ListRecent() []models.Task {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]models.Task, 0, len(s.tasks))
|
||||
for _, t := range s.tasks {
|
||||
result = append(result, t)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Create inserts a new task.
|
||||
func (s *TaskStore) Create(t models.Task) models.Task {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
s.tasks[t.ID] = t
|
||||
return t
|
||||
}
|
||||
|
||||
// Count returns the total number of tasks.
|
||||
func (s *TaskStore) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.tasks)
|
||||
}
|
||||
9
go-backend/migrations/001_initial_schema.down.sql
Normal file
9
go-backend/migrations/001_initial_schema.down.sql
Normal file
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user