Compare commits
5 Commits
e8ced74429
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 519e872027 | |||
| 2b4b9b3e96 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| 8b8cb8210c |
45
.env.example
Normal file
45
.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Control Center - Environment Variables
|
||||||
|
# ======================================
|
||||||
|
|
||||||
|
# ── Backend Variables ───────────────────────────────────────────────────
|
||||||
|
# Server configuration
|
||||||
|
PORT=8080
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Database connection (PostgreSQL DSN)
|
||||||
|
# Format: postgresql://user:password@host:port/database?sslmode=disable
|
||||||
|
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
||||||
|
|
||||||
|
# Gateway (OpenClaw) connection
|
||||||
|
# URL to the OpenClaw gateway API for polling agent states
|
||||||
|
GATEWAY_URL=http://localhost:18789/api/agents
|
||||||
|
# Polling interval for agent state updates
|
||||||
|
GATEWAY_POLL_INTERVAL=5s
|
||||||
|
|
||||||
|
# ── Frontend Variables (via Vite) ───────────────────────────────────────
|
||||||
|
# The Vite config exposes these as import.meta.env.VITE_*
|
||||||
|
# Set via environment variable when building: VITE_API_URL
|
||||||
|
# VITE_API_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# ── Docker Compose Specific ─────────────────────────────────────────────
|
||||||
|
# When using docker-compose, these are set in the services section
|
||||||
|
# See docker-compose.yml for service-specific environment variables
|
||||||
|
|
||||||
|
# ── Database Configuration ─────────────────────────────────────────────
|
||||||
|
# Set in the db service environment section of docker-compose.yml
|
||||||
|
# POSTGRES_USER=controlcenter
|
||||||
|
# POSTGRES_PASSWORD=controlcenter
|
||||||
|
# POSTGRES_DB=controlcenter
|
||||||
|
|
||||||
|
# ── Development Notes ───────────────────────────────────────────────────
|
||||||
|
# For local development without Docker:
|
||||||
|
# 1. Start PostgreSQL locally
|
||||||
|
# 2. Run: go run ./cmd/server/main.go
|
||||||
|
# 3. Run: npm run dev in frontend/
|
||||||
|
#
|
||||||
|
# For Docker deployment:
|
||||||
|
# 1. Copy .env.example to .env (backend only)
|
||||||
|
# 2. Run: docker compose up -d
|
||||||
|
# 3. Access frontend at http://localhost:3000
|
||||||
268
README-deployment.md
Normal file
268
README-deployment.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Control Center Deployment Guide
|
||||||
|
|
||||||
|
This document covers the Docker Compose deployment and kiosk configuration for the Control Center Go + React application.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services (backend, frontend, database)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop and remove volumes (database data)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Frontend │ Port 3000 (host) → 80 (container)
|
||||||
|
│ React + nginx │ Serves SPA, proxies /api/ to backend
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ HTTP
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Backend │ Port 8080 (host) → 8080 (container)
|
||||||
|
│ Go HTTP API │ PostgreSQL-backed REST API
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ PostgreSQL
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ PostgreSQL │ Port 5432 (internal only)
|
||||||
|
│ Database │ Persistent volume at /var/lib/postgresql/data
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Backend (`go-backend`)
|
||||||
|
|
||||||
|
- **Image**: Custom `alpine:latest` with Go binary
|
||||||
|
- **Port**: 8080
|
||||||
|
- **Build**: Multi-stage from `go-backend/Dockerfile`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `PORT` (default: 8080)
|
||||||
|
- `DATABASE_URL` (PostgreSQL DSN)
|
||||||
|
- `CORS_ORIGIN` (default: `*`)
|
||||||
|
- `LOG_LEVEL` (default: `info`)
|
||||||
|
- `ENVIRONMENT` (default: `development`)
|
||||||
|
- `GATEWAY_URL` (OpenClaw gateway endpoint)
|
||||||
|
|
||||||
|
### Frontend (`frontend`)
|
||||||
|
|
||||||
|
- **Image**: `nginx:1.27-alpine`
|
||||||
|
- **Port**: 80 (internal) → 3000 (host)
|
||||||
|
- **Build**: Multi-stage from `frontend/Dockerfile`
|
||||||
|
- Node 22 for build
|
||||||
|
- Nginx 1.27 for serving
|
||||||
|
- **Config**: Custom nginx config in `frontend/nginx.conf`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `VITE_API_URL` (passed at build time via Vite config)
|
||||||
|
|
||||||
|
### Database (`db`)
|
||||||
|
|
||||||
|
- **Image**: `postgres:16-alpine`
|
||||||
|
- **Port**: 5432 (internal only)
|
||||||
|
- **Volume**: `postgres-data:/var/lib/postgresql/data`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `POSTGRES_USER` (default: `controlcenter`)
|
||||||
|
- `POSTGRES_PASSWORD` (default: `controlcenter`)
|
||||||
|
- `POSTGRES_DB` (default: `controlcenter`)
|
||||||
|
|
||||||
|
## Kiosk Mode
|
||||||
|
|
||||||
|
For dedicated display installations (e.g., control center dashboard), Chromium can run in kiosk mode.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Install the systemd service** (on Debian/Ubuntu with systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp kiosk/control-center-kiosk.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Enable auto-start**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable control-center-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the service**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start control-center-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check status and logs**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status control-center-kiosk
|
||||||
|
sudo journalctl -u control-center-kiosk -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Launch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
./kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and disable service
|
||||||
|
sudo systemctl stop control-center-kiosk
|
||||||
|
sudo systemctl disable control-center-kiosk
|
||||||
|
sudo rm /etc/systemd/system/control-center-kiosk.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiosk Requirements
|
||||||
|
|
||||||
|
- **Browser**: `chromium-browser` (install via `apt-get install chromium`)
|
||||||
|
- **Display**: X11 session with `DISPLAY=:0`
|
||||||
|
- **User**: Must run as a user with X11 access (typically `overseer`)
|
||||||
|
- **Permissions**: Read access to the project directory
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
### Backend (`go-backend/.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=8080
|
||||||
|
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVIRONMENT=development
|
||||||
|
GATEWAY_URL=http://localhost:18789/api/agents
|
||||||
|
GATEWAY_POLL_INTERVAL=5s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (build-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Set via `services.<name>.environment` in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=...
|
||||||
|
frontend:
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=...
|
||||||
|
db:
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=...
|
||||||
|
- POSTGRES_PASSWORD=...
|
||||||
|
- POSTGRES_DB=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development (non-Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd go-backend
|
||||||
|
go run ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If using pgx/migrate or similar
|
||||||
|
# The database is created automatically on first connection if it doesn't exist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't connect to database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database container status
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# View database logs
|
||||||
|
docker compose logs db
|
||||||
|
|
||||||
|
# Test database connectivity from backend
|
||||||
|
docker compose exec backend ping db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend can't reach backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check network connectivity
|
||||||
|
docker compose exec frontend ping backend
|
||||||
|
|
||||||
|
# Verify backend is running
|
||||||
|
docker compose logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiosk browser won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Chromium installation
|
||||||
|
which chromium-browser
|
||||||
|
|
||||||
|
# Check X11 forwarding
|
||||||
|
echo $DISPLAY
|
||||||
|
|
||||||
|
# Manual launch for debugging
|
||||||
|
./kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port conflicts
|
||||||
|
|
||||||
|
If ports 8080, 3000, or 5432 are already in use, modify `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "8081:8080" # Change host port
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "3001:80" # Change host port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **HTTPS**: Add a reverse proxy (nginx/Traefik) for SSL termination
|
||||||
|
2. **Database security**: Use strong passwords, enable SSL
|
||||||
|
3. **CORS**: Restrict `CORS_ORIGIN` to production domain
|
||||||
|
4. **Logs**: Configure log aggregation (e.g., ELK, Loki)
|
||||||
|
5. **Backups**: Regular PostgreSQL volume backups
|
||||||
|
6. **Monitoring**: Add health checks and alerting
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File/Directory | Purpose |
|
||||||
|
|----------------|---------|
|
||||||
|
| `docker-compose.yml` | Service definitions and configuration |
|
||||||
|
| `.env.example` | Environment variable template |
|
||||||
|
| `go-backend/Dockerfile` | Backend build definition |
|
||||||
|
| `frontend/Dockerfile` | Frontend build definition |
|
||||||
|
| `frontend/nginx.conf` | Nginx config for SPA routing |
|
||||||
|
| `kiosk/start-kiosk.sh` | Kiosk browser startup script |
|
||||||
|
| `kiosk/control-center-kiosk.service` | Systemd unit for auto-start |
|
||||||
72
docker-compose.yml
Normal file
72
docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Control Center - Go + React + PostgreSQL Deployment
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── Backend Service (Go) ───────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./go-backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://controlcenter:controlcenter@db:5432/controlcenter?sslmode=disable
|
||||||
|
- CORS_ORIGIN=http://localhost:3000
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- ENVIRONMENT=production
|
||||||
|
- PORT=8080
|
||||||
|
- GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Frontend Service (React) ───────────────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8080
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Database Service (PostgreSQL 16) ───────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: control-center-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=controlcenter
|
||||||
|
- POSTGRES_PASSWORD=controlcenter
|
||||||
|
- POSTGRES_DB=controlcenter
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U controlcenter -d controlcenter"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
control-center-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
29
frontend/src/hooks/useLocalStorage.ts
Normal file
29
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key)
|
||||||
|
return item !== null ? (JSON.parse(item) as T) : initialValue
|
||||||
|
} catch {
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(storedValue))
|
||||||
|
} catch {
|
||||||
|
// storage full or unavailable
|
||||||
|
}
|
||||||
|
}, [key, storedValue])
|
||||||
|
|
||||||
|
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
||||||
|
setStoredValue((prev) => {
|
||||||
|
const next = value instanceof Function ? value(prev) : value
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return [storedValue, setValue]
|
||||||
|
}
|
||||||
50
frontend/src/hooks/useTheme.tsx
Normal file
50
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light'
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme
|
||||||
|
toggleTheme: () => void
|
||||||
|
isDark: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'dark'
|
||||||
|
const stored = localStorage.getItem('cc-theme')
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(getInitialTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
root.classList.remove('light')
|
||||||
|
} else {
|
||||||
|
root.classList.add('light')
|
||||||
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
localStorage.setItem('cc-theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@
|
|||||||
--color-status-thinking: #A78BFA;
|
--color-status-thinking: #A78BFA;
|
||||||
--color-status-error: #F87171;
|
--color-status-error: #F87171;
|
||||||
--color-status-offline: #64748B;
|
--color-status-offline: #64748B;
|
||||||
|
|
||||||
|
/* Light mode overrides */
|
||||||
|
--color-light-surface-darkest: #F8FAFC;
|
||||||
|
--color-light-surface-dark: #F1F5F9;
|
||||||
|
--color-light-surface-medium: #E2E8F0;
|
||||||
|
--color-light-surface-light: #CBD5E1;
|
||||||
|
--color-light-surface-lighter: #94A3B8;
|
||||||
|
--color-light-on-surface: #0F172A;
|
||||||
|
--color-light-on-surface-variant: #475569;
|
||||||
|
--color-light-on-surface-muted: #64748B;
|
||||||
|
--color-light-primary: #0284C7;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -27,3 +38,39 @@ body {
|
|||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
font-family: 'Inter', 'Roboto', sans-serif;
|
font-family: 'Inter', 'Roboto', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark theme (default) */
|
||||||
|
html.dark body {
|
||||||
|
background-color: var(--color-surface-darkest);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
html.light body {
|
||||||
|
background-color: var(--color-light-surface-darkest);
|
||||||
|
color: var(--color-light-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
--color-surface-darkest: var(--color-light-surface-darkest);
|
||||||
|
--color-surface-dark: var(--color-light-surface-dark);
|
||||||
|
--color-surface-medium: var(--color-light-surface-medium);
|
||||||
|
--color-surface-light: var(--color-light-surface-light);
|
||||||
|
--color-surface-lighter: var(--color-light-surface-lighter);
|
||||||
|
--color-on-surface: var(--color-light-on-surface);
|
||||||
|
--color-on-surface-variant: var(--color-light-on-surface-variant);
|
||||||
|
--color-on-surface-muted: var(--color-light-on-surface-muted);
|
||||||
|
--color-primary: var(--color-light-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--color-surface-light) 25%, var(--color-surface-lighter) 50%, var(--color-surface-light) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
|
import { ThemeProvider } from './hooks/useTheme'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
@@ -19,11 +20,13 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,91 +1,198 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { listAgents } from '../services/api'
|
import { listAgents } from '../services/api'
|
||||||
import { Activity, AlertTriangle } from 'lucide-react'
|
import { Activity, AlertTriangle, RefreshCw, Bot, Zap, Coffee, AlertCircle } from 'lucide-react'
|
||||||
|
import type { Agent } from '../types'
|
||||||
|
|
||||||
|
function statusStats(agents: Agent[]) {
|
||||||
|
const counts = { total: agents.length, active: 0, idle: 0, thinking: 0, error: 0 }
|
||||||
|
for (const a of agents) {
|
||||||
|
if (a.status in counts) counts[a.status as keyof typeof counts]++
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-500',
|
||||||
|
idle: 'bg-yellow-500',
|
||||||
|
thinking: 'bg-blue-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
export default function HubPage() {
|
export default function HubPage() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading, error, refetch, isRefetching } = useQuery({
|
||||||
queryKey: ['agents'],
|
queryKey: ['agents'],
|
||||||
queryFn: listAgents,
|
queryFn: listAgents,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const agents = data?.data ?? []
|
||||||
|
const stats = statusStats(agents)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <HubSkeleton />
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-danger">
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<AlertTriangle size={24} className="mr-2" />
|
<AlertCircle size={48} className="text-danger" />
|
||||||
Failed to load agents
|
<p className="text-danger text-lg">Failed to load agents</p>
|
||||||
|
<button
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['agents'] })}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} /> Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const agents = data?.data ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header>
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Command Hub</h1>
|
<h1 className="text-2xl font-bold">Command Hub</h1>
|
||||||
<p className="text-on-surface-variant">Agent fleet overview</p>
|
<p className="text-on-surface-variant">Agent fleet overview</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isRefetching ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<StatCard icon={Bot} label="Total" value={stats.total} color="text-on-surface" />
|
||||||
|
<StatCard icon={Zap} label="Active" value={stats.active} color="text-green-500" />
|
||||||
|
<StatCard icon={Coffee} label="Idle" value={stats.idle} color="text-yellow-500" />
|
||||||
|
<StatCard icon={AlertTriangle} label="Errors" value={stats.error} color="text-red-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent grid */}
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||||
|
<Bot size={40} className="text-on-surface-muted" />
|
||||||
|
<p className="text-on-surface-muted text-lg">No agents registered</p>
|
||||||
|
<p className="text-on-surface-muted text-sm">Agents will appear here once connected.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
|
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-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>
|
<div>
|
||||||
<h3 className="font-semibold">{agent.displayName}</h3>
|
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
|
||||||
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusDot status={agent.status} />
|
</div>
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Current task */}
|
||||||
{agent.currentTask && (
|
{agent.currentTask && (
|
||||||
<div className="text-sm text-on-surface-variant mb-2">
|
<div className="mb-2 text-sm text-on-surface-variant truncate">
|
||||||
{agent.currentTask}
|
{agent.currentTask}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{agent.taskProgress !== undefined && (
|
{/* Progress bar */}
|
||||||
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden">
|
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
|
||||||
|
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary rounded-full transition-all"
|
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||||
style={{ width: `${agent.taskProgress}%` }}
|
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
||||||
<Activity size={12} />
|
<Activity size={12} />
|
||||||
{agent.channel} · {agent.lastActivity}
|
<span>{agent.channel}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{agent.lastActivity}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ status }: { status: string }) {
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
active: 'bg-status-active',
|
|
||||||
idle: 'bg-status-idle',
|
|
||||||
thinking: 'bg-status-thinking',
|
|
||||||
error: 'bg-status-error',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${colorMap[status] ?? 'bg-status-offline'}`} />
|
|
||||||
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,182 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { listTasks } from '../services/api'
|
||||||
|
import { AlertCircle, RefreshCw, Filter, CheckCircle, Circle, Clock, XCircle, Loader, ListTodo } from 'lucide-react'
|
||||||
|
import type { Task } from '../types'
|
||||||
|
|
||||||
|
const STATUS_FILTERS = ['all', 'pending', 'running', 'completed', 'failed'] as const
|
||||||
|
type StatusFilter = (typeof STATUS_FILTERS)[number]
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<string, React.ElementType> = {
|
||||||
|
pending: Clock,
|
||||||
|
running: Loader,
|
||||||
|
completed: CheckCircle,
|
||||||
|
failed: XCircle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
pending: 'text-yellow-500',
|
||||||
|
running: 'text-blue-400',
|
||||||
|
completed: 'text-green-500',
|
||||||
|
failed: 'text-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['tasks'],
|
||||||
|
queryFn: listTasks,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks = (data?.data ?? []) as Task[]
|
||||||
|
|
||||||
|
const filtered = statusFilter === 'all'
|
||||||
|
? tasks
|
||||||
|
: tasks.filter((t) => t.status === statusFilter)
|
||||||
|
|
||||||
|
// Sort newest first
|
||||||
|
const sorted = [...filtered].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) return <LogsSkeleton />
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Logs</h1>
|
<AlertCircle size={48} className="text-danger" />
|
||||||
<p className="text-on-surface-variant">Activity logs will appear here.</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,117 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { listProjects } from '../services/api'
|
||||||
|
import { AlertCircle, RefreshCw, FolderKanban, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
planned: 'bg-purple-500',
|
||||||
|
active: 'bg-green-500',
|
||||||
|
paused: 'bg-yellow-500',
|
||||||
|
completed: 'bg-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: listProjects,
|
||||||
|
})
|
||||||
|
|
||||||
|
const projects = data?.data ?? []
|
||||||
|
|
||||||
|
if (isLoading) return <ProjectsSkeleton />
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Projects</h1>
|
<AlertCircle size={48} className="text-danger" />
|
||||||
<p className="text-on-surface-variant">Tracked projects will appear here.</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,177 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { listSessions } from '../services/api'
|
||||||
|
import { AlertCircle, Monitor, RefreshCw, Cpu, MessageSquare, Clock, Hash } from 'lucide-react'
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['sessions'],
|
||||||
|
queryFn: listSessions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = data?.data ?? []
|
||||||
|
|
||||||
|
if (isLoading) return <SessionsSkeleton />
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Sessions</h1>
|
<AlertCircle size={48} className="text-danger" />
|
||||||
<p className="text-on-surface-variant">Active sessions will appear here.</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,126 @@
|
|||||||
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||||
|
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
const REFRESH_PRESETS = [
|
||||||
|
{ label: '5s', value: 5_000 },
|
||||||
|
{ label: '10s', value: 10_000 },
|
||||||
|
{ label: '30s', value: 30_000 },
|
||||||
|
{ label: '60s', value: 60_000 },
|
||||||
|
]
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const { isDark, toggleTheme } = useTheme()
|
||||||
|
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||||
|
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div 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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
<p className="font-medium">Dark Mode</p>
|
||||||
<p className="text-on-surface-variant">System settings will appear here.</p>
|
<p className="text-sm text-on-surface-variant">Toggle between dark and light themes</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={`relative w-14 h-8 rounded-full transition-colors duration-200 ${
|
||||||
|
isDark ? 'bg-primary' : 'bg-surface-lighter'
|
||||||
|
}`}
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 flex items-center justify-center ${
|
||||||
|
isDark ? 'translate-x-7' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDark ? <Moon size={14} className="text-surface-darkest" /> : <Sun size={14} className="text-yellow-500" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Connection */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Zap size={20} className="text-primary" />
|
||||||
|
Connection
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="gateway-url" className="block text-sm font-medium mb-1">
|
||||||
|
Gateway URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gateway-url"
|
||||||
|
type="url"
|
||||||
|
value={gatewayUrl}
|
||||||
|
onChange={(e) => setGatewayUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-surface-light bg-surface-darkest text-on-surface placeholder:text-on-surface-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-on-surface-muted mt-1">
|
||||||
|
The backend Gateway address for API requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Refresh */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Clock size={20} className="text-primary" />
|
||||||
|
Auto Refresh
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||||
|
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="3"
|
||||||
|
step="1"
|
||||||
|
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const idx = parseInt(e.target.value)
|
||||||
|
setRefreshInterval(REFRESH_PRESETS[idx].value)
|
||||||
|
}}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-on-surface-muted">
|
||||||
|
{REFRESH_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
onClick={() => setRefreshInterval(p.value)}
|
||||||
|
className={`px-3 py-1 rounded-lg transition-colors ${
|
||||||
|
refreshInterval === p.value
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'hover:bg-surface-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
kiosk/control-center-kiosk.service
Normal file
42
kiosk/control-center-kiosk.service
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Control Center Kiosk Service
|
||||||
|
# =============================
|
||||||
|
# Systemd unit file for auto-starting the Control Center kiosk on boot
|
||||||
|
#
|
||||||
|
# Install: sudo cp control-center-kiosk.service /etc/systemd/system/
|
||||||
|
# Enable: sudo systemctl enable control-center-kiosk
|
||||||
|
# Start: sudo systemctl start control-center-kiosk
|
||||||
|
# Status: sudo systemctl status control-center-kiosk
|
||||||
|
# Logs: sudo journalctl -u control-center-kiosk -f
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Control Center Kiosk - Chrome Browser Dashboard
|
||||||
|
Documentation=https://code.cubecraftcreations.com/CubeCraft-Creations/Control-Center
|
||||||
|
After=graphical-session.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/home/overseer/projects/Control-Center/kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=DISPLAY=:0
|
||||||
|
Environment=XAUTHORITY=/home/overseer/.Xauthority
|
||||||
|
WorkingDirectory=/home/overseer/projects/Control-Center
|
||||||
|
User=overseer
|
||||||
|
Group=overseer
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=control-center-kiosk
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/home/overseer/.config/chromium
|
||||||
|
ReadWritePaths=/var/log/journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
88
kiosk/start-kiosk.sh
Executable file
88
kiosk/start-kiosk.sh
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Control Center Kiosk Startup Script
|
||||||
|
# ====================================
|
||||||
|
# This script launches Chromium in kiosk mode for the Control Center dashboard
|
||||||
|
# Usage: ./start-kiosk.sh [frontend-url]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
FRONTEND_URL="${1:-http://localhost:3000}"
|
||||||
|
BROWSER_WINDOW="chromium-browser"
|
||||||
|
|
||||||
|
# ── Functions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
log "Stopping kiosk browser..."
|
||||||
|
pkill -f "chromium-browser.*--kiosk" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
|
# ── Check prerequisites ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check_browser() {
|
||||||
|
if ! command -v chromium-browser &> /dev/null; then
|
||||||
|
log "ERROR: chromium-browser not found"
|
||||||
|
log "Install with: sudo apt-get install chromium"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_x_server() {
|
||||||
|
if [ -z "$DISPLAY" ]; then
|
||||||
|
log "ERROR: DISPLAY environment variable not set"
|
||||||
|
log "This script requires an X server session"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "Starting Control Center Kiosk..."
|
||||||
|
log "Frontend URL: $FRONTEND_URL"
|
||||||
|
|
||||||
|
check_browser
|
||||||
|
check_x_server
|
||||||
|
|
||||||
|
# Clean up any existing browser instances
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
# Launch Chromium in kiosk mode
|
||||||
|
# --kiosk: Fullscreen without browser UI
|
||||||
|
# --incognito: Clean session
|
||||||
|
# --noerrdialogs: Suppress error dialogs
|
||||||
|
# --disable-notifications: Disable notifications
|
||||||
|
# --disable-extensions: Disable extensions
|
||||||
|
# --disable-plugins-discovery: Disable plugins
|
||||||
|
# --disable-sync: Disable sync
|
||||||
|
# --disable-web-security: Allow CORS (needed for local API calls)
|
||||||
|
# --ignore-certificate-errors: Ignore SSL errors (for local dev)
|
||||||
|
# --gpu: Enable GPU acceleration
|
||||||
|
# --start-fullscreen: Start in fullscreen mode
|
||||||
|
chromium-browser \
|
||||||
|
--kiosk \
|
||||||
|
--incognito \
|
||||||
|
--noerrdialogs \
|
||||||
|
--disable-notifications \
|
||||||
|
--disable-extensions \
|
||||||
|
--disable-plugins-discovery \
|
||||||
|
--disable-sync \
|
||||||
|
--disable-web-security \
|
||||||
|
--ignore-certificate-errors \
|
||||||
|
--gpu \
|
||||||
|
--start-fullscreen \
|
||||||
|
"$FRONTEND_URL" &
|
||||||
|
|
||||||
|
KIOSK_PID=$!
|
||||||
|
log "Kiosk browser started (PID: $KIOSK_PID)"
|
||||||
|
|
||||||
|
# Wait for browser to exit
|
||||||
|
wait $KIOSK_PID
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user