Initial commit: IT0 AI-powered server cluster operations platform
Full-stack monorepo with DDD + Clean Architecture: - Backend: 7 NestJS microservices + 5 shared libraries (TypeScript) - Mobile: Flutter app with Riverpod (Dart) - Web Admin: Next.js dashboard with Zustand + React Query - Voice: Python voice service (STT/TTS/VAD) - Infra: Docker Compose, K8s manifests, Turborepo build Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
00f8801d51
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=it0
|
||||||
|
DB_PASSWORD=it0_dev
|
||||||
|
DB_DATABASE=it0
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-jwt-secret-change-in-production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
|
# Agent Engine
|
||||||
|
AGENT_ENGINE_TYPE=claude_code_cli
|
||||||
|
ANTHROPIC_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# Vault
|
||||||
|
VAULT_MASTER_KEY=your-vault-master-key-change-in-production
|
||||||
|
|
||||||
|
# Voice Service
|
||||||
|
VOICE_SERVICE_URL=http://localhost:3008
|
||||||
|
|
||||||
|
# Twilio (for phone calls)
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
|
TWILIO_PHONE_NUMBER=
|
||||||
|
|
||||||
|
# Services
|
||||||
|
AUTH_SERVICE_PORT=3001
|
||||||
|
AGENT_SERVICE_PORT=3002
|
||||||
|
OPS_SERVICE_PORT=3003
|
||||||
|
INVENTORY_SERVICE_PORT=3004
|
||||||
|
MONITOR_SERVICE_PORT=3005
|
||||||
|
COMM_SERVICE_PORT=3006
|
||||||
|
AUDIT_SERVICE_PORT=3007
|
||||||
|
VOICE_SERVICE_PORT=3008
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment & Secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Testing & Coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Flutter
|
||||||
|
it0_app/.dart_tool/
|
||||||
|
it0_app/.flutter-plugins
|
||||||
|
it0_app/.flutter-plugins-dependencies
|
||||||
|
it0_app/.packages
|
||||||
|
it0_app/.pub/
|
||||||
|
it0_app/build/
|
||||||
|
it0_app/android/.gradle/
|
||||||
|
it0_app/android/app/build/
|
||||||
|
it0_app/android/build/
|
||||||
|
it0_app/android/local.properties
|
||||||
|
it0_app/ios/Pods/
|
||||||
|
it0_app/ios/.symlinks/
|
||||||
|
it0_app/ios/Flutter/Generated.xcconfig
|
||||||
|
it0_app/ios/Flutter/flutter_export_environment.sh
|
||||||
|
*.freezed.dart
|
||||||
|
*.g.dart
|
||||||
|
*.gr.dart
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
.eggs/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Models (large files)
|
||||||
|
models/
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
nul
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# IT0 — AI-Powered Server Cluster Operations Platform
|
||||||
|
|
||||||
|
Intelligent operations platform that combines AI agents with human oversight for managing server clusters.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Backend**: NestJS microservices (TypeScript) with DDD + Clean Architecture
|
||||||
|
- **Mobile**: Flutter app with Riverpod state management
|
||||||
|
- **Web Admin**: Next.js dashboard with Zustand + React Query
|
||||||
|
- **Voice**: Python service for voice-based interaction (STT/TTS/VAD)
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| auth-service | Authentication, RBAC, API key management |
|
||||||
|
| agent-service | AI agent orchestration (Claude CLI + API) |
|
||||||
|
| inventory-service | Server, cluster, credential management |
|
||||||
|
| monitor-service | Metrics collection, alerting, health checks |
|
||||||
|
| ops-service | Task execution, approvals, standing orders |
|
||||||
|
| comm-service | Multi-channel notifications, escalation |
|
||||||
|
| audit-service | Audit logging, compliance trail |
|
||||||
|
| voice-service | Voice pipeline (Python) |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Flutter
|
||||||
|
cd it0_app && flutter pub get && flutter run
|
||||||
|
|
||||||
|
# Web Admin
|
||||||
|
cd it0-web-admin && pnpm install && pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node.js 20+, Dart 3.x, Python 3.11+
|
||||||
|
- **Database**: PostgreSQL (schema-per-tenant)
|
||||||
|
- **Cache/Events**: Redis Streams
|
||||||
|
- **AI**: Anthropic Claude (CLI + API)
|
||||||
|
- **Build**: pnpm workspaces + Turborepo
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Required
|
||||||
|
ANTHROPIC_API_KEY=your-api-key-here
|
||||||
|
JWT_SECRET=change-this-to-a-random-string
|
||||||
|
VAULT_MASTER_KEY=change-this-to-a-random-string
|
||||||
|
|
||||||
|
# Optional - Twilio (for phone calls)
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
|
TWILIO_PHONE_NUMBER=
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# GPU-enabled voice service overlay
|
||||||
|
# Usage: docker compose -f docker-compose.yml -f docker-compose.voice.yml up voice-service
|
||||||
|
|
||||||
|
services:
|
||||||
|
voice-service:
|
||||||
|
environment:
|
||||||
|
- DEVICE=cuda
|
||||||
|
volumes:
|
||||||
|
- ../../models:/app/models
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3008/health"]
|
||||||
|
interval: 30s
|
||||||
|
start_period: 60s
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ===== Infrastructure =====
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: it0
|
||||||
|
POSTGRES_PASSWORD: it0_dev
|
||||||
|
POSTGRES_DB: it0
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U it0"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ===== API Gateway =====
|
||||||
|
api-gateway:
|
||||||
|
build: ../../packages/gateway
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
- "8001:8001"
|
||||||
|
depends_on:
|
||||||
|
- auth-service
|
||||||
|
- agent-service
|
||||||
|
- ops-service
|
||||||
|
- inventory-service
|
||||||
|
- monitor-service
|
||||||
|
- comm-service
|
||||||
|
- audit-service
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "kong", "health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
# ===== Backend Services =====
|
||||||
|
auth-service:
|
||||||
|
build: ../../packages/services/auth-service
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-dev-jwt-secret}
|
||||||
|
- AUTH_SERVICE_PORT=3001
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
agent-service:
|
||||||
|
build: ../../packages/services/agent-service
|
||||||
|
ports:
|
||||||
|
- "3002:3002"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- AGENT_ENGINE_TYPE=claude_code_cli
|
||||||
|
- AGENT_SERVICE_PORT=3002
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
ops-service:
|
||||||
|
build: ../../packages/services/ops-service
|
||||||
|
ports:
|
||||||
|
- "3003:3003"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- OPS_SERVICE_PORT=3003
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
inventory-service:
|
||||||
|
build: ../../packages/services/inventory-service
|
||||||
|
ports:
|
||||||
|
- "3004:3004"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- VAULT_MASTER_KEY=${VAULT_MASTER_KEY:-dev-vault-key}
|
||||||
|
- INVENTORY_SERVICE_PORT=3004
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
monitor-service:
|
||||||
|
build: ../../packages/services/monitor-service
|
||||||
|
ports:
|
||||||
|
- "3005:3005"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- MONITOR_SERVICE_PORT=3005
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
comm-service:
|
||||||
|
build: ../../packages/services/comm-service
|
||||||
|
ports:
|
||||||
|
- "3006:3006"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
||||||
|
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
||||||
|
- TWILIO_PHONE_NUMBER=${TWILIO_PHONE_NUMBER}
|
||||||
|
- COMM_SERVICE_PORT=3006
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
audit-service:
|
||||||
|
build: ../../packages/services/audit-service
|
||||||
|
ports:
|
||||||
|
- "3007:3007"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USERNAME=it0
|
||||||
|
- DB_PASSWORD=it0_dev
|
||||||
|
- DB_DATABASE=it0
|
||||||
|
- AUDIT_SERVICE_PORT=3007
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
voice-service:
|
||||||
|
build: ../../packages/services/voice-service
|
||||||
|
ports:
|
||||||
|
- "3008:3008"
|
||||||
|
environment:
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- AGENT_SERVICE_URL=http://agent-service:3002
|
||||||
|
- WHISPER_MODEL=large-v3
|
||||||
|
- KOKORO_MODEL=kokoro-82m
|
||||||
|
- DEVICE=cpu
|
||||||
|
depends_on:
|
||||||
|
- agent-service
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
# ===== Frontend =====
|
||||||
|
web-admin:
|
||||||
|
build: ../../it0-web-admin
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- API_BASE_URL=http://api-gateway:8000
|
||||||
|
- NEXT_PUBLIC_API_BASE_URL=/api/proxy
|
||||||
|
- NEXT_PUBLIC_WS_URL=ws://localhost:8000
|
||||||
|
depends_on:
|
||||||
|
- api-gateway
|
||||||
|
networks:
|
||||||
|
- it0-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
it0-network:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: it0
|
||||||
|
labels:
|
||||||
|
app: it0
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: it0
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: db-credentials
|
||||||
|
key: username
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: db-credentials
|
||||||
|
key: password
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: it0
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: postgres-data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: it0
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: it0
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: redis
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: redis
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: redis
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 6379
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: redis
|
||||||
|
namespace: it0
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: redis
|
||||||
|
ports:
|
||||||
|
- port: 6379
|
||||||
|
targetPort: 6379
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,16 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: true,
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/proxy/:path*',
|
||||||
|
destination: `${process.env.API_BASE_URL || 'http://localhost:8000'}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"name": "it0-web-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^14.2.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
|
||||||
|
"@reduxjs/toolkit": "^2.2.0",
|
||||||
|
"react-redux": "^9.1.0",
|
||||||
|
"zustand": "^4.5.0",
|
||||||
|
"@tanstack/react-query": "^5.45.0",
|
||||||
|
"@tanstack/react-table": "^8.17.0",
|
||||||
|
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.0",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.0",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.0",
|
||||||
|
"@radix-ui/react-toast": "^1.1.0",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.0",
|
||||||
|
"lucide-react": "^0.378.0",
|
||||||
|
|
||||||
|
"react-hook-form": "^7.51.0",
|
||||||
|
"zod": "^3.23.0",
|
||||||
|
"@hookform/resolvers": "^3.3.0",
|
||||||
|
|
||||||
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
|
|
||||||
|
"recharts": "^2.12.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"sonner": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "^14.2.0",
|
||||||
|
"prettier": "^3.2.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"@testing-library/react": "^15.0.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,601 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface HookScript {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
event: 'PreToolUse' | 'PostToolUse' | 'PreNotification' | 'PostNotification';
|
||||||
|
toolPattern: string;
|
||||||
|
script: string;
|
||||||
|
timeout: number;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookFormData {
|
||||||
|
name: string;
|
||||||
|
event: HookScript['event'];
|
||||||
|
toolPattern: string;
|
||||||
|
script: string;
|
||||||
|
timeout: number;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HooksResponse {
|
||||||
|
data: HookScript[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EVENT_TYPES: HookScript['event'][] = [
|
||||||
|
'PreToolUse',
|
||||||
|
'PostToolUse',
|
||||||
|
'PreNotification',
|
||||||
|
'PostNotification',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_BADGE_STYLES: Record<HookScript['event'], string> = {
|
||||||
|
PreToolUse: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
PostToolUse: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
PreNotification: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
PostNotification: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: HookFormData = {
|
||||||
|
name: '',
|
||||||
|
event: 'PreToolUse',
|
||||||
|
toolPattern: '*',
|
||||||
|
script: '',
|
||||||
|
timeout: 30,
|
||||||
|
enabled: true,
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EventBadge({ event }: { event: HookScript['event'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
EVENT_BADGE_STYLES[event],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{event}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook form dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HookDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: HookFormData;
|
||||||
|
errors: Partial<Record<keyof HookFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof HookFormData, value: string | number | boolean) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-2xl bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="pre-bash-audit"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* event type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Event Type</label>
|
||||||
|
<select
|
||||||
|
value={form.event}
|
||||||
|
onChange={(e) => onChange('event', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{EVENT_TYPES.map((evt) => (
|
||||||
|
<option key={evt} value={evt}>
|
||||||
|
{evt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tool pattern */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tool Pattern</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.toolPattern}
|
||||||
|
onChange={(e) => onChange('toolPattern', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="Bash, *, Read,Write"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Comma-separated tool names or * for all tools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* script content */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Script <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.script}
|
||||||
|
onChange={(e) => onChange('script', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full min-h-[300px] bg-gray-950 text-green-400 font-mono text-sm p-4 rounded-md border resize-y',
|
||||||
|
errors.script ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder={'#!/bin/bash\n# Hook script body\n# Exit 0 to allow, non-zero to block (PreToolUse only)'}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{errors.script && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.script}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* timeout */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Timeout (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.timeout}
|
||||||
|
onChange={(e) => onChange('timeout', parseInt(e.target.value, 10) || 30)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
min={1}
|
||||||
|
max={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* enabled toggle */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium">Enabled</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={form.enabled}
|
||||||
|
onClick={() => onChange('enabled', !form.enabled)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||||
|
form.enabled ? 'bg-primary' : 'bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-4 w-4 rounded-full bg-white transition-transform',
|
||||||
|
form.enabled ? 'translate-x-6' : 'translate-x-1',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description of what this hook does..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
hookName,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
hookName: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Hook</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{hookName}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function HooksPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingHook, setEditingHook] = useState<HookScript | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<HookScript | null>(null);
|
||||||
|
const [form, setForm] = useState<HookFormData>({ ...EMPTY_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof HookFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.hooks.list(),
|
||||||
|
queryFn: () => apiClient<HooksResponse>('/api/v1/agent/hooks'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hooks = data?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<HookScript>('/api/v1/agent/hooks', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.hooks.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<HookScript>(`/api/v1/agent/hooks/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.hooks.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/agent/hooks/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.hooks.all });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||||
|
apiClient<HookScript>(`/api/v1/agent/hooks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { enabled },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.hooks.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof HookFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
if (!form.script.trim()) next.script = 'Script content is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingHook(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingHook(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((hook: HookScript) => {
|
||||||
|
setEditingHook(hook);
|
||||||
|
setForm({
|
||||||
|
name: hook.name,
|
||||||
|
event: hook.event,
|
||||||
|
toolPattern: hook.toolPattern,
|
||||||
|
script: hook.script,
|
||||||
|
timeout: hook.timeout,
|
||||||
|
enabled: hook.enabled,
|
||||||
|
description: hook.description ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof HookFormData, value: string | number | boolean) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
event: form.event,
|
||||||
|
toolPattern: form.toolPattern.trim(),
|
||||||
|
script: form.script,
|
||||||
|
timeout: form.timeout,
|
||||||
|
enabled: form.enabled,
|
||||||
|
description: form.description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingHook) {
|
||||||
|
updateMutation.mutate({ id: editingHook.id, body });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingHook, validate, createMutation, updateMutation]);
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Hook Scripts</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Lifecycle scripts for agent tool execution
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Hook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info banner */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
Hook scripts run at specific lifecycle points during agent execution. PreToolUse hooks
|
||||||
|
can block operations, PostToolUse hooks capture results for auditing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load hooks: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading hooks...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Event</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Tool Pattern</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Script</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Enabled</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{hooks.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No hooks configured. Click "Add Hook" to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
hooks.map((hook) => (
|
||||||
|
<tr
|
||||||
|
key={hook.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">{hook.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<EventBadge event={hook.event} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{hook.toolPattern || '*'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{hook.script
|
||||||
|
? `${hook.script.split('\n').length} lines`
|
||||||
|
: '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={hook.enabled}
|
||||||
|
onClick={() =>
|
||||||
|
toggleMutation.mutate({
|
||||||
|
id: hook.id,
|
||||||
|
enabled: !hook.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
|
hook.enabled ? 'bg-primary' : 'bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
|
hook.enabled ? 'translate-x-[18px]' : 'translate-x-[2px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(hook)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(hook)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<HookDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingHook ? 'Edit Hook' : 'Add Hook'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isSaving}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
hookName={deleteTarget?.name ?? ''}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
/* ---------- types ---------- */
|
||||||
|
|
||||||
|
interface AgentConfig {
|
||||||
|
id?: string;
|
||||||
|
engine: 'claude-cli' | 'claude-api';
|
||||||
|
system_prompt: string;
|
||||||
|
max_turns: number;
|
||||||
|
max_budget: number;
|
||||||
|
allowed_tools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: AgentConfig = {
|
||||||
|
engine: 'claude-cli',
|
||||||
|
system_prompt: '',
|
||||||
|
max_turns: 10,
|
||||||
|
max_budget: 5.0,
|
||||||
|
allowed_tools: ['Bash', 'Read', 'Write', 'Glob', 'Grep'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const AVAILABLE_TOOLS = [
|
||||||
|
{ name: 'Bash', description: 'Execute shell commands' },
|
||||||
|
{ name: 'Read', description: 'Read file contents' },
|
||||||
|
{ name: 'Write', description: 'Write / create files' },
|
||||||
|
{ name: 'Edit', description: 'Edit existing files' },
|
||||||
|
{ name: 'Glob', description: 'Search files by pattern' },
|
||||||
|
{ name: 'Grep', description: 'Search file contents' },
|
||||||
|
{ name: 'WebFetch', description: 'Fetch web content' },
|
||||||
|
{ name: 'WebSearch', description: 'Search the web' },
|
||||||
|
{ name: 'NotebookEdit', description: 'Edit Jupyter notebooks' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- page ---------- */
|
||||||
|
|
||||||
|
export default function AgentConfigPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/* ---- load existing config ---- */
|
||||||
|
const {
|
||||||
|
data: savedConfig,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.agentConfig.current(),
|
||||||
|
queryFn: () => apiClient<AgentConfig>('/api/v1/agent-config'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- local form state ---- */
|
||||||
|
const [engine, setEngine] = useState<AgentConfig['engine']>(DEFAULT_CONFIG.engine);
|
||||||
|
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_CONFIG.system_prompt);
|
||||||
|
const [maxTurns, setMaxTurns] = useState(DEFAULT_CONFIG.max_turns);
|
||||||
|
const [maxBudget, setMaxBudget] = useState(DEFAULT_CONFIG.max_budget);
|
||||||
|
const [allowedTools, setAllowedTools] = useState<string[]>(DEFAULT_CONFIG.allowed_tools);
|
||||||
|
|
||||||
|
/* ---- seed form when config loads ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
if (savedConfig) {
|
||||||
|
setEngine(savedConfig.engine);
|
||||||
|
setSystemPrompt(savedConfig.system_prompt);
|
||||||
|
setMaxTurns(savedConfig.max_turns);
|
||||||
|
setMaxBudget(savedConfig.max_budget);
|
||||||
|
setAllowedTools(savedConfig.allowed_tools);
|
||||||
|
}
|
||||||
|
}, [savedConfig]);
|
||||||
|
|
||||||
|
/* ---- save mutation ---- */
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: saveConfig, isPending: isSaving } = useMutation({
|
||||||
|
mutationFn: (config: Omit<AgentConfig, 'id'>) => {
|
||||||
|
const method = savedConfig?.id ? 'PUT' : 'POST';
|
||||||
|
const endpoint = savedConfig?.id
|
||||||
|
? `/api/v1/agent-config/${savedConfig.id}`
|
||||||
|
: '/api/v1/agent-config';
|
||||||
|
return apiClient<AgentConfig>(endpoint, { method, body: config });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agentConfig.all });
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setTimeout(() => setSaveSuccess(false), 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- handlers ---- */
|
||||||
|
|
||||||
|
function handleToolToggle(toolName: string) {
|
||||||
|
setAllowedTools((prev) =>
|
||||||
|
prev.includes(toolName)
|
||||||
|
? prev.filter((t) => t !== toolName)
|
||||||
|
: [...prev, toolName],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectAllTools() {
|
||||||
|
setAllowedTools(AVAILABLE_TOOLS.map((t) => t.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeselectAllTools() {
|
||||||
|
setAllowedTools([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setEngine(DEFAULT_CONFIG.engine);
|
||||||
|
setSystemPrompt(DEFAULT_CONFIG.system_prompt);
|
||||||
|
setMaxTurns(DEFAULT_CONFIG.max_turns);
|
||||||
|
setMaxBudget(DEFAULT_CONFIG.max_budget);
|
||||||
|
setAllowedTools(DEFAULT_CONFIG.allowed_tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
saveConfig({
|
||||||
|
engine,
|
||||||
|
system_prompt: systemPrompt,
|
||||||
|
max_turns: maxTurns,
|
||||||
|
max_budget: maxBudget,
|
||||||
|
allowed_tools: allowedTools,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- loading / error states ---- */
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Loading agent configuration...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Agent Configuration</h1>
|
||||||
|
<p className="text-sm text-destructive mb-4">
|
||||||
|
Failed to load configuration. Using defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- render ---- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Agent Configuration</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage AI engine settings, system prompts, and allowed tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ---- Engine Selection ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Engine</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Select which Claude engine the agent should use.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="engine"
|
||||||
|
value="claude-cli"
|
||||||
|
checked={engine === 'claude-cli'}
|
||||||
|
onChange={() => setEngine('claude-cli')}
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Claude CLI</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Run via local Claude CLI binary
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="engine"
|
||||||
|
value="claude-api"
|
||||||
|
checked={engine === 'claude-api'}
|
||||||
|
onChange={() => setEngine('claude-api')}
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Claude API</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Call Anthropic API directly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- System Prompt ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">System Prompt</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
The base system prompt prepended to every agent interaction.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
placeholder="You are an IT operations agent..."
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono
|
||||||
|
placeholder:text-muted-foreground focus:outline-none focus:ring-2
|
||||||
|
focus:ring-ring resize-y"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 text-right">
|
||||||
|
{systemPrompt.length} characters
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Max Turns ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Max Turns</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Maximum number of agentic turns the agent may take per task.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={maxTurns}
|
||||||
|
onChange={(e) => setMaxTurns(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={maxTurns}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Math.max(1, Math.min(100, Number(e.target.value) || 1));
|
||||||
|
setMaxTurns(v);
|
||||||
|
}}
|
||||||
|
className="w-20 rounded-md border bg-background px-3 py-1.5 text-sm text-center
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Max Budget ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Max Budget</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Maximum dollar spend per task execution (USD).
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.5}
|
||||||
|
value={maxBudget}
|
||||||
|
onChange={(e) => setMaxBudget(Math.max(0, Number(e.target.value) || 0))}
|
||||||
|
className="w-32 rounded-md border bg-background px-3 py-1.5 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Allowed Tools ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Allowed Tools</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select which tools the agent is allowed to invoke.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSelectAllTools}
|
||||||
|
className="px-2 py-1 rounded border hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeselectAllTools}
|
||||||
|
className="px-2 py-1 rounded border hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Deselect All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{AVAILABLE_TOOLS.map((tool) => (
|
||||||
|
<label
|
||||||
|
key={tool.name}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-md border cursor-pointer
|
||||||
|
hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowedTools.includes(tool.name)}
|
||||||
|
onChange={() => handleToolToggle(tool.name)}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{tool.name}</span>
|
||||||
|
<p className="text-xs text-muted-foreground">{tool.description}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
|
{allowedTools.length} of {AVAILABLE_TOOLS.length} tools selected
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Action buttons ---- */}
|
||||||
|
<div className="flex items-center gap-3 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||||
|
bg-primary text-primary-foreground text-sm font-medium
|
||||||
|
hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-colors"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isSaving ? 'Saving...' : 'Save Configuration'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border
|
||||||
|
text-sm font-medium hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{saveSuccess && (
|
||||||
|
<span className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
Configuration saved successfully.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,669 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SkillDetail {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
promptTemplate: string;
|
||||||
|
allowedTools: string[];
|
||||||
|
isBuiltIn: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const AVAILABLE_TOOLS = [
|
||||||
|
'Bash',
|
||||||
|
'Read',
|
||||||
|
'Write',
|
||||||
|
'Glob',
|
||||||
|
'Grep',
|
||||||
|
'Edit',
|
||||||
|
'ssh',
|
||||||
|
'kubectl',
|
||||||
|
'docker',
|
||||||
|
'systemctl',
|
||||||
|
'curl',
|
||||||
|
'ping',
|
||||||
|
'scp',
|
||||||
|
'rsync',
|
||||||
|
'git',
|
||||||
|
'npm',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Toggle switch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
|
checked ? 'bg-primary' : 'bg-muted',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
|
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
name,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
name: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Skill</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{name}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: format date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function SkillDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const name = params.name as string;
|
||||||
|
const decodedName = decodeURIComponent(name);
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [isEditingPrompt, setIsEditingPrompt] = useState(false);
|
||||||
|
const [promptDraft, setPromptDraft] = useState('');
|
||||||
|
const [isEditingTools, setIsEditingTools] = useState(false);
|
||||||
|
const [toolsDraft, setToolsDraft] = useState<string[]>([]);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: skill,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery<SkillDetail>({
|
||||||
|
queryKey: queryKeys.skills.detail(decodedName),
|
||||||
|
queryFn: () => apiClient<SkillDetail>(`/api/v1/agent/skills/${encodeURIComponent(decodedName)}`),
|
||||||
|
enabled: !!decodedName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: Partial<SkillDetail>) =>
|
||||||
|
apiClient<SkillDetail>(`/api/v1/agent/skills/${encodeURIComponent(decodedName)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.skills.detail(decodedName) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.skills.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/agent/skills/${encodeURIComponent(decodedName)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.skills.all });
|
||||||
|
router.push('/agent-config/skills');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicateMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!skill) throw new Error('Skill not loaded');
|
||||||
|
return apiClient<SkillDetail>('/api/v1/agent/skills', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: `${skill.name} (Copy)`,
|
||||||
|
description: skill.description,
|
||||||
|
promptTemplate: skill.promptTemplate,
|
||||||
|
allowedTools: skill.allowedTools,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (newSkill) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.skills.all });
|
||||||
|
router.push(`/agent-config/skills/${encodeURIComponent(newSkill.name)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers ---------------------------------------------------------------
|
||||||
|
const handleStartEditPrompt = useCallback(() => {
|
||||||
|
if (!skill) return;
|
||||||
|
setPromptDraft(skill.promptTemplate);
|
||||||
|
setIsEditingPrompt(true);
|
||||||
|
}, [skill]);
|
||||||
|
|
||||||
|
const handleSavePrompt = useCallback(() => {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ promptTemplate: promptDraft },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEditingPrompt(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [promptDraft, updateMutation]);
|
||||||
|
|
||||||
|
const handleCancelEditPrompt = useCallback(() => {
|
||||||
|
setIsEditingPrompt(false);
|
||||||
|
setPromptDraft('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartEditTools = useCallback(() => {
|
||||||
|
if (!skill) return;
|
||||||
|
setToolsDraft([...skill.allowedTools]);
|
||||||
|
setIsEditingTools(true);
|
||||||
|
}, [skill]);
|
||||||
|
|
||||||
|
const handleToggleTool = useCallback((tool: string) => {
|
||||||
|
setToolsDraft((prev) =>
|
||||||
|
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveTools = useCallback(() => {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ allowedTools: toolsDraft },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEditingTools(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [toolsDraft, updateMutation]);
|
||||||
|
|
||||||
|
const handleCancelEditTools = useCallback(() => {
|
||||||
|
setIsEditingTools(false);
|
||||||
|
setToolsDraft([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleEnabled = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
updateMutation.mutate({ enabled: value });
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render: Loading --------------------------------------------------------
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/agent-config/skills')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back to Skills
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading skill...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render: Error ----------------------------------------------------------
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/agent-config/skills')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back to Skills
|
||||||
|
</button>
|
||||||
|
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load skill: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render: Not found ------------------------------------------------------
|
||||||
|
if (!skill) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/agent-config/skills')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back to Skills
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Skill not found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render: Main -----------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/agent-config/skills')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div className="h-6 w-px bg-border" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{skill.name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{skill.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{skill.isBuiltIn && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
Built-in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
skill.enabled
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{skill.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mutation error banner */}
|
||||||
|
{(updateMutation.error || duplicateMutation.error) && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
{((updateMutation.error || duplicateMutation.error) as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Overview card */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Overview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 font-mono">{skill.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<p className="mt-1">
|
||||||
|
{skill.isBuiltIn ? (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
Built-in
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 text-muted-foreground">
|
||||||
|
{skill.description || 'No description provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Created
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 text-muted-foreground">
|
||||||
|
{formatDate(skill.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Updated
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 text-muted-foreground">
|
||||||
|
{formatDate(skill.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Template section */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Prompt Template</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditingPrompt ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditPrompt}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSavePrompt}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditPrompt}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingPrompt ? (
|
||||||
|
<textarea
|
||||||
|
value={promptDraft}
|
||||||
|
onChange={(e) => setPromptDraft(e.target.value)}
|
||||||
|
className="w-full bg-gray-950 text-green-400 font-mono text-sm p-4 rounded-md min-h-[300px] resize-y border-0 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap min-h-[300px]">
|
||||||
|
{skill.promptTemplate || 'No prompt template defined.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allowed Tools section */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Allowed Tools</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditingTools ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditTools}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveTools}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditTools}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingTools ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{AVAILABLE_TOOLS.map((tool) => (
|
||||||
|
<label
|
||||||
|
key={tool}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolsDraft.includes(tool)}
|
||||||
|
onChange={() => handleToggleTool(tool)}
|
||||||
|
className="accent-primary h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{tool}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skill.allowedTools.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No tools configured.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
skill.allowedTools.map((tool) => (
|
||||||
|
<span
|
||||||
|
key={tool}
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-md bg-muted text-xs font-mono"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
{/* Configuration card */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Configuration</h2>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Type */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<p className="mt-1.5">
|
||||||
|
{skill.isBuiltIn ? (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
Built-in
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enabled toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={skill.enabled}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onChange={handleToggleEnabled}
|
||||||
|
label={skill.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allowed Tools count */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Allowed Tools
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 font-medium">
|
||||||
|
{skill.allowedTools.length} tool{skill.allowedTools.length !== 1 ? 's' : ''} configured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions card */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Duplicate */}
|
||||||
|
<button
|
||||||
|
onClick={() => duplicateMutation.mutate()}
|
||||||
|
disabled={duplicateMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{duplicateMutation.isPending ? 'Duplicating...' : 'Duplicate Skill'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete (only for non-built-in) */}
|
||||||
|
{!skill.isBuiltIn && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete Skill
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skill.isBuiltIn && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Built-in skills cannot be deleted.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Metadata</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Name</span>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{skill.name}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Created</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(skill.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Updated</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(skill.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
name={skill.name}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setShowDeleteDialog(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,553 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: 'inspection' | 'deployment' | 'maintenance' | 'security' | 'monitoring' | 'custom';
|
||||||
|
script: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: Skill['category'];
|
||||||
|
script: string;
|
||||||
|
tags: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsResponse {
|
||||||
|
data: Skill[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SKILL_QUERY_KEY = ['skills'] as const;
|
||||||
|
|
||||||
|
const CATEGORIES: { label: string; value: Skill['category'] }[] = [
|
||||||
|
{ label: 'Inspection', value: 'inspection' },
|
||||||
|
{ label: 'Deployment', value: 'deployment' },
|
||||||
|
{ label: 'Maintenance', value: 'maintenance' },
|
||||||
|
{ label: 'Security', value: 'security' },
|
||||||
|
{ label: 'Monitoring', value: 'monitoring' },
|
||||||
|
{ label: 'Custom', value: 'custom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORY_STYLES: Record<Skill['category'], string> = {
|
||||||
|
inspection: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
deployment: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
maintenance: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||||
|
security: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
monitoring: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
custom: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: SkillFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
category: 'custom',
|
||||||
|
script: '',
|
||||||
|
tags: '',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Category badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function CategoryBadge({ category }: { category: Skill['category'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
CATEGORY_STYLES[category],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enabled / Disabled badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EnabledBadge({ enabled }: { enabled: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
enabled
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skill form dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SkillDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: SkillFormData;
|
||||||
|
errors: Partial<Record<keyof SkillFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof SkillFormData, value: string | boolean) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="check-disk-usage"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="What does this skill do..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* category */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => onChange('category', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<option key={cat.value} value={cat.value}>
|
||||||
|
{cat.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* script */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Script</label>
|
||||||
|
<textarea
|
||||||
|
value={form.script}
|
||||||
|
onChange={(e) => onChange('script', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm font-mono min-h-[200px] resize-y"
|
||||||
|
placeholder={"#!/bin/bash\n# Skill script content..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tags</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(e) => onChange('tags', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="disk, health, linux (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* enabled toggle */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => onChange('enabled', e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary" />
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-medium">Enabled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
skillName,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
skillName: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Skill</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{skillName}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function SkillsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Skill | null>(null);
|
||||||
|
const [form, setForm] = useState<SkillFormData>({ ...EMPTY_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof SkillFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: SKILL_QUERY_KEY,
|
||||||
|
queryFn: () => apiClient<SkillsResponse>('/api/v1/agent/skills'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const skills = data?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<Skill>('/api/v1/agent/skills', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<Skill>(`/api/v1/agent/skills/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/agent/skills/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof SkillFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingSkill(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingSkill(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((skill: Skill) => {
|
||||||
|
setEditingSkill(skill);
|
||||||
|
setForm({
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description ?? '',
|
||||||
|
category: skill.category,
|
||||||
|
script: skill.script ?? '',
|
||||||
|
tags: (skill.tags ?? []).join(', '),
|
||||||
|
enabled: skill.enabled,
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof SkillFormData, value: string | boolean) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
category: form.category,
|
||||||
|
script: form.script,
|
||||||
|
tags: form.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
enabled: form.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingSkill) {
|
||||||
|
updateMutation.mutate({ id: editingSkill.id, body });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingSkill, validate, createMutation, updateMutation]);
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Skills</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage Claude Code skills for the AI agent
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Skill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load skills: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading skills...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && !error && skills.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
No skills configured yet. Add your first skill to get started.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Add Skill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills grid */}
|
||||||
|
{!isLoading && !error && skills.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<div
|
||||||
|
key={skill.id}
|
||||||
|
className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Header: name + enabled badge */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-sm">{skill.name}</h3>
|
||||||
|
<EnabledBadge enabled={skill.enabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
||||||
|
{skill.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category badge */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<CategoryBadge category={skill.category} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{skill.tags && skill.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{skill.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full bg-muted"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(skill)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(skill)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<SkillDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingSkill ? 'Edit Skill' : 'Add Skill'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isSaving}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
skillName={deleteTarget?.name ?? ''}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
actionType: string;
|
||||||
|
actorType: string;
|
||||||
|
actorId: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
description: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogsResponse {
|
||||||
|
data: AuditLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
actionType: string;
|
||||||
|
actorType: string;
|
||||||
|
resourceType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ACTION_TYPES = ['', 'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'EXECUTE', 'APPROVE', 'REJECT'];
|
||||||
|
const ACTOR_TYPES = ['', 'user', 'system', 'agent'];
|
||||||
|
const RESOURCE_TYPES = ['', 'server', 'task', 'standing_order', 'runbook', 'credential', 'user'];
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
actionType: '',
|
||||||
|
actorType: '',
|
||||||
|
resourceType: '',
|
||||||
|
});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Build query params from filters
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
};
|
||||||
|
if (filters.dateFrom) params.dateFrom = filters.dateFrom;
|
||||||
|
if (filters.dateTo) params.dateTo = filters.dateTo;
|
||||||
|
if (filters.actionType) params.actionType = filters.actionType;
|
||||||
|
if (filters.actorType) params.actorType = filters.actorType;
|
||||||
|
if (filters.resourceType) params.resourceType = filters.resourceType;
|
||||||
|
return params;
|
||||||
|
}, [filters, page, pageSize]);
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
const sp = new URLSearchParams(queryParams);
|
||||||
|
return sp.toString();
|
||||||
|
}, [queryParams]);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.auditLogs.list(queryParams),
|
||||||
|
queryFn: () => apiClient<AuditLogsResponse>(`/api/v1/audit/logs?${queryString}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = data?.data ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const updateFilter = useCallback((key: keyof Filters, value: string) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setPage(1); // reset to first page on filter change
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePageSizeChange = useCallback((newSize: number) => {
|
||||||
|
setPageSize(newSize);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── CSV Export ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const exportCsv = useCallback(() => {
|
||||||
|
if (logs.length === 0) return;
|
||||||
|
|
||||||
|
const headers = ['Timestamp', 'Action Type', 'Actor Type', 'Actor ID', 'Resource Type', 'Resource ID', 'Description'];
|
||||||
|
const rows = logs.map((log) => [
|
||||||
|
log.timestamp,
|
||||||
|
log.actionType,
|
||||||
|
log.actorType,
|
||||||
|
log.actorId,
|
||||||
|
log.resourceType,
|
||||||
|
log.resourceId,
|
||||||
|
`"${(log.description || '').replace(/"/g, '""')}"`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Audit Logs</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
View immutable audit trail of all operations and configuration changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={exportCsv}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
className="px-4 py-2 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-4 p-4 bg-card border rounded-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Date From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) => updateFilter('dateFrom', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Date To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) => updateFilter('dateTo', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Action Type</label>
|
||||||
|
<select
|
||||||
|
value={filters.actionType}
|
||||||
|
onChange={(e) => updateFilter('actionType', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{ACTION_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t || 'All'}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Actor Type</label>
|
||||||
|
<select
|
||||||
|
value={filters.actorType}
|
||||||
|
onChange={(e) => updateFilter('actorType', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{ACTOR_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t || 'All'}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Resource Type</label>
|
||||||
|
<select
|
||||||
|
value={filters.resourceType}
|
||||||
|
onChange={(e) => updateFilter('resourceType', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{RESOURCE_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t || 'All'}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{isLoading && <p className="text-muted-foreground py-4">Loading audit logs...</p>}
|
||||||
|
{error && <p className="text-red-500 py-4">Error loading logs: {(error as Error).message}</p>}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto border rounded-lg">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50 text-left">
|
||||||
|
<th className="py-2 px-3 font-medium w-8"></th>
|
||||||
|
<th className="py-2 px-3 font-medium">Timestamp</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Action</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Actor Type</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Actor ID</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Resource Type</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Resource ID</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<LogRow
|
||||||
|
key={log.id}
|
||||||
|
log={log}
|
||||||
|
isExpanded={expandedId === log.id}
|
||||||
|
onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||||
|
No audit logs found for the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>Rows per page:</span>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||||
|
className="px-2 py-1 bg-input border rounded text-sm"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ml-2">
|
||||||
|
{total > 0
|
||||||
|
? `${(page - 1) * pageSize + 1}--${Math.min(page * pageSize, total)} of ${total}`
|
||||||
|
: '0 results'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
CREATE: 'bg-green-100 text-green-700',
|
||||||
|
UPDATE: 'bg-blue-100 text-blue-700',
|
||||||
|
DELETE: 'bg-red-100 text-red-700',
|
||||||
|
LOGIN: 'bg-purple-100 text-purple-700',
|
||||||
|
EXECUTE: 'bg-orange-100 text-orange-700',
|
||||||
|
APPROVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
REJECT: 'bg-rose-100 text-rose-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
function LogRow({
|
||||||
|
log,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
log: AuditLog;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const formattedTime = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new Date(log.timestamp).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return log.timestamp;
|
||||||
|
}
|
||||||
|
}, [log.timestamp]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn('border-b cursor-pointer hover:bg-accent/50 transition-colors', isExpanded && 'bg-accent/30')}
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground">
|
||||||
|
<span className={cn('inline-block transition-transform text-xs', isExpanded && 'rotate-90')}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground whitespace-nowrap">{formattedTime}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', ACTION_COLORS[log.actionType] ?? 'bg-muted text-muted-foreground')}>
|
||||||
|
{log.actionType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground">{log.actorType}</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{log.actorId}</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground">{log.resourceType}</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{log.resourceId}</td>
|
||||||
|
<td className="py-2 px-3 max-w-xs truncate">{log.description}</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-b">
|
||||||
|
<td colSpan={8} className="p-4 bg-muted/30">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Full Detail</p>
|
||||||
|
<pre className="text-xs bg-background border rounded-md p-3 overflow-x-auto max-h-64 overflow-y-auto">
|
||||||
|
<code>{JSON.stringify(log.detail ?? log, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,714 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// -- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AgentSession {
|
||||||
|
id: string;
|
||||||
|
taskDescription: string;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
commandCount: number;
|
||||||
|
serverTargets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
type:
|
||||||
|
| 'command_executed'
|
||||||
|
| 'output_received'
|
||||||
|
| 'approval_requested'
|
||||||
|
| 'approval_granted'
|
||||||
|
| 'approval_denied'
|
||||||
|
| 'error'
|
||||||
|
| 'session_started'
|
||||||
|
| 'session_completed';
|
||||||
|
timestamp: string;
|
||||||
|
data: {
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
riskLevel?: string;
|
||||||
|
error?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsResponse {
|
||||||
|
data: AgentSession[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionEventsResponse {
|
||||||
|
data: SessionEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
status: string;
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Constants ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ label: 'All', value: '' },
|
||||||
|
{ label: 'Completed', value: 'completed' },
|
||||||
|
{ label: 'Failed', value: 'failed' },
|
||||||
|
{ label: 'Cancelled', value: 'cancelled' },
|
||||||
|
{ label: 'Running', value: 'running' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_BADGE_STYLES: Record<string, string> = {
|
||||||
|
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
cancelled: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPE_STYLES: Record<string, string> = {
|
||||||
|
command_executed: 'bg-blue-100 text-blue-800',
|
||||||
|
output_received: 'bg-gray-100 text-gray-800',
|
||||||
|
approval_requested: 'bg-yellow-100 text-yellow-800',
|
||||||
|
approval_granted: 'bg-green-100 text-green-800',
|
||||||
|
approval_denied: 'bg-red-100 text-red-800',
|
||||||
|
error: 'bg-red-100 text-red-800',
|
||||||
|
session_started: 'bg-indigo-100 text-indigo-800',
|
||||||
|
session_completed: 'bg-green-100 text-green-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
command_executed: 'Command Executed',
|
||||||
|
output_received: 'Output Received',
|
||||||
|
approval_requested: 'Approval Requested',
|
||||||
|
approval_granted: 'Approval Granted',
|
||||||
|
approval_denied: 'Approval Denied',
|
||||||
|
error: 'Error',
|
||||||
|
session_started: 'Session Started',
|
||||||
|
session_completed: 'Session Completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_STYLES: Record<string, string> = {
|
||||||
|
L0: 'bg-green-100 text-green-800',
|
||||||
|
L1: 'bg-yellow-100 text-yellow-800',
|
||||||
|
L2: 'bg-orange-100 text-orange-800',
|
||||||
|
L3: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = [0.5, 1, 2, 4];
|
||||||
|
|
||||||
|
// -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDuration(ms?: number): string {
|
||||||
|
if (ms == null) return '--';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return `${hours}h ${remainingMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(eventIso: string, sessionStartIso: string): string {
|
||||||
|
try {
|
||||||
|
const diff = new Date(eventIso).getTime() - new Date(sessionStartIso).getTime();
|
||||||
|
if (diff < 0) return '+0s';
|
||||||
|
return `+${formatDuration(diff)}`;
|
||||||
|
} catch {
|
||||||
|
return eventIso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateId(id: string, maxLen = 12): string {
|
||||||
|
if (id.length <= maxLen) return id;
|
||||||
|
return id.slice(0, maxLen) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Main Component ----------------------------------------------------------
|
||||||
|
|
||||||
|
export default function SessionReplayPage() {
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
status: '',
|
||||||
|
search: '',
|
||||||
|
});
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||||
|
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||||
|
const [visibleEventIndex, setVisibleEventIndex] = useState<number>(-1);
|
||||||
|
const playbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const timelineEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Build query params
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (filters.dateFrom) params.dateFrom = filters.dateFrom;
|
||||||
|
if (filters.dateTo) params.dateTo = filters.dateTo;
|
||||||
|
if (filters.status) params.status = filters.status;
|
||||||
|
if (filters.search) params.search = filters.search;
|
||||||
|
return params;
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
const sp = new URLSearchParams(queryParams);
|
||||||
|
return sp.toString();
|
||||||
|
}, [queryParams]);
|
||||||
|
|
||||||
|
// Fetch sessions
|
||||||
|
const {
|
||||||
|
data: sessionsData,
|
||||||
|
isLoading: sessionsLoading,
|
||||||
|
error: sessionsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.sessions.list(queryParams),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<SessionsResponse>(
|
||||||
|
`/api/v1/agent/sessions${queryString ? `?${queryString}` : ''}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = sessionsData?.data ?? [];
|
||||||
|
const total = sessionsData?.total ?? 0;
|
||||||
|
|
||||||
|
// Fetch session events when a session is selected
|
||||||
|
const {
|
||||||
|
data: eventsData,
|
||||||
|
isLoading: eventsLoading,
|
||||||
|
error: eventsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.sessions.events(selectedSessionId ?? ''),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<SessionEventsResponse>(
|
||||||
|
`/api/v1/agent/sessions/${selectedSessionId}/events`,
|
||||||
|
),
|
||||||
|
enabled: !!selectedSessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = eventsData?.data ?? [];
|
||||||
|
const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null;
|
||||||
|
|
||||||
|
// Reset playback when selecting a new session
|
||||||
|
useEffect(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setVisibleEventIndex(-1);
|
||||||
|
setExpandedOutputs(new Set());
|
||||||
|
if (playbackTimerRef.current) {
|
||||||
|
clearTimeout(playbackTimerRef.current);
|
||||||
|
playbackTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [selectedSessionId]);
|
||||||
|
|
||||||
|
// Auto-scroll to the latest visible event during playback
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying && timelineEndRef.current) {
|
||||||
|
timelineEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [visibleEventIndex, isPlaying]);
|
||||||
|
|
||||||
|
// Playback logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || events.length === 0) return;
|
||||||
|
|
||||||
|
const nextIndex = visibleEventIndex + 1;
|
||||||
|
if (nextIndex >= events.length) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay based on time difference between events
|
||||||
|
let delayMs = 1000;
|
||||||
|
if (nextIndex > 0 && nextIndex < events.length) {
|
||||||
|
const prevTime = new Date(events[nextIndex - 1].timestamp).getTime();
|
||||||
|
const nextTime = new Date(events[nextIndex].timestamp).getTime();
|
||||||
|
const realDiff = nextTime - prevTime;
|
||||||
|
// Cap at 3 seconds real-time and scale by speed
|
||||||
|
delayMs = Math.min(3000, Math.max(200, realDiff)) / playbackSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackTimerRef.current = setTimeout(() => {
|
||||||
|
setVisibleEventIndex(nextIndex);
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playbackTimerRef.current) {
|
||||||
|
clearTimeout(playbackTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, visibleEventIndex, events, playbackSpeed]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const updateFilter = useCallback((key: keyof Filters, value: string) => {
|
||||||
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback((id: string) => {
|
||||||
|
setSelectedSessionId((prev) => (prev === id ? null : id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleOutputExpansion = useCallback((eventId: string) => {
|
||||||
|
setExpandedOutputs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(eventId)) {
|
||||||
|
next.delete(eventId);
|
||||||
|
} else {
|
||||||
|
next.add(eventId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
// If at the end, restart from beginning
|
||||||
|
if (visibleEventIndex >= events.length - 1) {
|
||||||
|
setVisibleEventIndex(-1);
|
||||||
|
}
|
||||||
|
setIsPlaying(true);
|
||||||
|
}, [events, visibleEventIndex]);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowAll = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setVisibleEventIndex(events.length - 1);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setVisibleEventIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Determine which events to show
|
||||||
|
const visibleEvents =
|
||||||
|
visibleEventIndex < 0 && !isPlaying
|
||||||
|
? events
|
||||||
|
: events.slice(0, visibleEventIndex + 1);
|
||||||
|
|
||||||
|
// -- Render ----------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Session Replay</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Review agent session execution history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4 p-4 bg-card border rounded-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Date From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) => updateFilter('dateFrom', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Date To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) => updateFilter('dateTo', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => updateFilter('status', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => updateFilter('search', e.target.value)}
|
||||||
|
placeholder="Session ID or task description..."
|
||||||
|
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error for sessions */}
|
||||||
|
{sessionsLoading && (
|
||||||
|
<p className="text-muted-foreground py-4">Loading sessions...</p>
|
||||||
|
)}
|
||||||
|
{sessionsError && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load sessions: {(sessionsError as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sessions Table */}
|
||||||
|
{!sessionsLoading && !sessionsError && (
|
||||||
|
<div className="overflow-x-auto border rounded-lg mb-6">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50 text-left">
|
||||||
|
<th className="py-2 px-3 font-medium">Session ID</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Task Description</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Status</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Duration</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Commands</th>
|
||||||
|
<th className="py-2 px-3 font-medium">Started At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<tr
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => handleSelectSession(session.id)}
|
||||||
|
className={cn(
|
||||||
|
'border-b cursor-pointer hover:bg-accent/50 transition-colors',
|
||||||
|
selectedSessionId === session.id && 'bg-accent/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs" title={session.id}>
|
||||||
|
{truncateId(session.id)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-2 px-3 max-w-xs truncate"
|
||||||
|
title={session.taskDescription}
|
||||||
|
>
|
||||||
|
{session.taskDescription}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
STATUS_BADGE_STYLES[session.status] ??
|
||||||
|
'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{session.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDuration(session.durationMs)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground text-center">
|
||||||
|
{session.commandCount}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatTimestamp(session.startedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No sessions found for the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t bg-muted/30">
|
||||||
|
Showing {sessions.length} of {total} sessions
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Replay Panel */}
|
||||||
|
{selectedSession && (
|
||||||
|
<div className="border rounded-lg bg-card">
|
||||||
|
{/* Session Info Header */}
|
||||||
|
<div className="p-4 border-b bg-muted/30">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h2 className="text-lg font-semibold">Session Replay</h2>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
STATUS_BADGE_STYLES[selectedSession.status] ??
|
||||||
|
'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedSession.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">ID:</span>{' '}
|
||||||
|
<span className="font-mono">{selectedSession.id}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Duration:</span>{' '}
|
||||||
|
{formatDuration(selectedSession.durationMs)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Commands:</span>{' '}
|
||||||
|
{selectedSession.commandCount}
|
||||||
|
</span>
|
||||||
|
{selectedSession.serverTargets.length > 0 && (
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Servers:</span>{' '}
|
||||||
|
{selectedSession.serverTargets.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSessionId(null)}
|
||||||
|
className="px-3 py-1.5 text-xs border rounded-md hover:bg-accent transition-colors self-start"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Description */}
|
||||||
|
<p className="text-sm mt-2">{selectedSession.taskDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-4 py-3 border-b bg-muted/10">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isPlaying ? (
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handlePause}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleShowAll}
|
||||||
|
disabled={events.length === 0}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Show All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<span className="text-xs text-muted-foreground">Speed:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{PLAYBACK_SPEEDS.map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
onClick={() => setPlaybackSpeed(speed)}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 text-xs rounded-md border transition-colors',
|
||||||
|
playbackSpeed === speed
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'hover:bg-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{visibleEventIndex >= 0
|
||||||
|
? `${Math.min(visibleEventIndex + 1, events.length)} / ${events.length} events`
|
||||||
|
: `${events.length} events`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events Loading / Error */}
|
||||||
|
{eventsLoading && (
|
||||||
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
|
Loading session events...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eventsError && (
|
||||||
|
<div className="p-4 text-red-500 text-sm">
|
||||||
|
Error loading events: {(eventsError as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Timeline */}
|
||||||
|
{!eventsLoading && !eventsError && (
|
||||||
|
<div className="p-4 max-h-[600px] overflow-y-auto">
|
||||||
|
{visibleEvents.length === 0 && events.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
No events recorded for this session.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleEvents.length === 0 && events.length > 0 && (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
|
Press Play to begin the session replay.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleEvents.map((event) => (
|
||||||
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
sessionStartedAt={selectedSession.startedAt}
|
||||||
|
isOutputExpanded={expandedOutputs.has(event.id)}
|
||||||
|
onToggleOutput={() => toggleOutputExpansion(event.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div ref={timelineEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Event Card Component ----------------------------------------------------
|
||||||
|
|
||||||
|
function EventCard({
|
||||||
|
event,
|
||||||
|
sessionStartedAt,
|
||||||
|
isOutputExpanded,
|
||||||
|
onToggleOutput,
|
||||||
|
}: {
|
||||||
|
event: SessionEvent;
|
||||||
|
sessionStartedAt: string;
|
||||||
|
isOutputExpanded: boolean;
|
||||||
|
onToggleOutput: () => void;
|
||||||
|
}) {
|
||||||
|
const relativeTime = useMemo(
|
||||||
|
() => formatRelativeTime(event.timestamp, sessionStartedAt),
|
||||||
|
[event.timestamp, sessionStartedAt],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-3 transition-colors hover:bg-muted/20">
|
||||||
|
{/* Event header */}
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">
|
||||||
|
{relativeTime}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
EVENT_TYPE_STYLES[event.type] ?? 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{EVENT_TYPE_LABELS[event.type] ?? event.type}
|
||||||
|
</span>
|
||||||
|
{event.data.riskLevel && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
RISK_LEVEL_STYLES[event.data.riskLevel] ??
|
||||||
|
'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{event.data.riskLevel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{event.data.exitCode != null && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-mono',
|
||||||
|
event.data.exitCode === 0 ? 'text-green-600' : 'text-red-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
exit: {event.data.exitCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command display */}
|
||||||
|
{event.data.command && (
|
||||||
|
<div className="bg-gray-950 text-green-400 font-mono text-xs p-3 rounded-md overflow-x-auto mb-2">
|
||||||
|
<code>{event.data.command}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Output display */}
|
||||||
|
{event.data.output && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-gray-900 text-gray-300 font-mono text-xs p-3 rounded-md overflow-x-auto',
|
||||||
|
!isOutputExpanded && 'max-h-40 overflow-y-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<pre className="whitespace-pre-wrap break-words">
|
||||||
|
{isOutputExpanded
|
||||||
|
? event.data.output
|
||||||
|
: event.data.output.length > 500
|
||||||
|
? event.data.output.slice(0, 500) + '...'
|
||||||
|
: event.data.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{event.data.output.length > 500 && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleOutput}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground mt-1 underline transition-colors"
|
||||||
|
>
|
||||||
|
{isOutputExpanded ? 'Collapse' : 'Expand full output'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{event.data.error && (
|
||||||
|
<div className="bg-red-950/50 text-red-400 font-mono text-xs p-3 rounded-md overflow-x-auto">
|
||||||
|
<pre className="whitespace-pre-wrap break-words">{event.data.error}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,796 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ChannelType = 'push' | 'sms' | 'voice_call' | 'email' | 'telegram' | 'wechat_work' | 'voice_service';
|
||||||
|
|
||||||
|
interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ChannelType;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
config: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
channels: ChannelType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EscalationStep {
|
||||||
|
stepNumber: number;
|
||||||
|
channels: ChannelType[];
|
||||||
|
delayMinutes: number;
|
||||||
|
contactIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EscalationPolicy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
severity: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
steps: EscalationStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel config field definitions ────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHANNEL_CONFIG_FIELDS: Record<ChannelType, { key: string; label: string; type?: string }[]> = {
|
||||||
|
email: [
|
||||||
|
{ key: 'smtpHost', label: 'SMTP Host' },
|
||||||
|
{ key: 'smtpPort', label: 'SMTP Port' },
|
||||||
|
{ key: 'smtpUser', label: 'SMTP Username' },
|
||||||
|
{ key: 'smtpPass', label: 'SMTP Password', type: 'password' },
|
||||||
|
{ key: 'fromAddress', label: 'From Address' },
|
||||||
|
],
|
||||||
|
telegram: [
|
||||||
|
{ key: 'botToken', label: 'Bot Token', type: 'password' },
|
||||||
|
{ key: 'chatId', label: 'Chat ID' },
|
||||||
|
],
|
||||||
|
sms: [
|
||||||
|
{ key: 'provider', label: 'Provider' },
|
||||||
|
{ key: 'apiKey', label: 'API Key', type: 'password' },
|
||||||
|
{ key: 'fromNumber', label: 'From Number' },
|
||||||
|
],
|
||||||
|
push: [
|
||||||
|
{ key: 'provider', label: 'Provider' },
|
||||||
|
{ key: 'apiKey', label: 'API Key', type: 'password' },
|
||||||
|
{ key: 'appId', label: 'App ID' },
|
||||||
|
],
|
||||||
|
voice_call: [
|
||||||
|
{ key: 'provider', label: 'Provider' },
|
||||||
|
{ key: 'apiKey', label: 'API Key', type: 'password' },
|
||||||
|
{ key: 'fromNumber', label: 'From Number' },
|
||||||
|
],
|
||||||
|
wechat_work: [
|
||||||
|
{ key: 'corpId', label: 'Corp ID' },
|
||||||
|
{ key: 'agentId', label: 'Agent ID' },
|
||||||
|
{ key: 'secret', label: 'Secret', type: 'password' },
|
||||||
|
],
|
||||||
|
voice_service: [
|
||||||
|
{ key: 'provider', label: 'Provider' },
|
||||||
|
{ key: 'apiKey', label: 'API Key', type: 'password' },
|
||||||
|
{ key: 'endpoint', label: 'Endpoint URL' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_CHANNEL_TYPES: ChannelType[] = ['push', 'sms', 'voice_call', 'email', 'telegram', 'wechat_work', 'voice_service'];
|
||||||
|
|
||||||
|
const CHANNEL_LABELS: Record<ChannelType, string> = {
|
||||||
|
push: 'Push Notification',
|
||||||
|
sms: 'SMS',
|
||||||
|
voice_call: 'Voice Call',
|
||||||
|
email: 'Email',
|
||||||
|
telegram: 'Telegram',
|
||||||
|
wechat_work: 'WeChat Work',
|
||||||
|
voice_service: 'Voice Service',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_OPTIONS = ['critical', 'high', 'medium', 'low', 'info'];
|
||||||
|
|
||||||
|
const TABS = ['Channels', 'Contacts', 'Escalation Policies'] as const;
|
||||||
|
type Tab = (typeof TABS)[number];
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CommunicationPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('Channels');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Communication Settings</h1>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex border-b mb-6">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
activeTab === tab
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === 'Channels' && <ChannelsTab />}
|
||||||
|
{activeTab === 'Contacts' && <ContactsTab />}
|
||||||
|
{activeTab === 'Escalation Policies' && <EscalationPoliciesTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab 1: Channels ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChannelsTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.channels.list(),
|
||||||
|
queryFn: () => apiClient<PaginatedResponse<Channel>>('/api/v1/comm/channels'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: (channel: Channel) =>
|
||||||
|
apiClient<Channel>(`/api/v1/comm/channels/${channel.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { enabled: !channel.enabled },
|
||||||
|
}),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.channels.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: ({ id, config }: { id: string; config: Record<string, string> }) =>
|
||||||
|
apiClient<Channel>(`/api/v1/comm/channels/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { config },
|
||||||
|
}),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.channels.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const channels = data?.data ?? [];
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-muted-foreground">Loading channels...</p>;
|
||||||
|
if (error) return <p className="text-red-500">Error loading channels: {(error as Error).message}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels.length === 0 && <p className="text-muted-foreground">No channels configured.</p>}
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<div key={ch.id} className="border rounded-lg bg-card">
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{ch.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{CHANNEL_LABELS[ch.type] ?? ch.type}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full',
|
||||||
|
ch.configured ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ch.configured ? 'Configured' : 'Not Configured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Toggle switch */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMutation.mutate(ch)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||||
|
ch.enabled ? 'bg-primary' : 'bg-muted'
|
||||||
|
)}
|
||||||
|
title={ch.enabled ? 'Disable channel' : 'Enable channel'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||||
|
ch.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(expandedId === ch.id ? null : ch.id)}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{expandedId === ch.id ? 'Hide Config' : 'Show Config'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Expanded config */}
|
||||||
|
{expandedId === ch.id && (
|
||||||
|
<ChannelConfigEditor
|
||||||
|
channel={ch}
|
||||||
|
onSave={(config) => updateConfigMutation.mutate({ id: ch.id, config })}
|
||||||
|
isSaving={updateConfigMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelConfigEditor({
|
||||||
|
channel,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
channel: Channel;
|
||||||
|
onSave: (config: Record<string, string>) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}) {
|
||||||
|
const fields = CHANNEL_CONFIG_FIELDS[channel.type] ?? [];
|
||||||
|
const [localConfig, setLocalConfig] = useState<Record<string, string>>({ ...channel.config });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t p-4 space-y-3">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-sm font-medium mb-1">{field.label}</label>
|
||||||
|
<input
|
||||||
|
type={field.type ?? 'text'}
|
||||||
|
value={localConfig[field.key] ?? ''}
|
||||||
|
onChange={(e) => setLocalConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
className="w-full max-w-md px-3 py-2 bg-input border rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => onSave(localConfig)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Configuration'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab 2: Contacts ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ContactsTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [editingContact, setEditingContact] = useState<Contact | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.contacts.list(),
|
||||||
|
queryFn: () => apiClient<PaginatedResponse<Contact>>('/api/v1/comm/contacts'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Omit<Contact, 'id'>) =>
|
||||||
|
apiClient<Contact>('/api/v1/comm/contacts', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all });
|
||||||
|
setShowDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: Contact) =>
|
||||||
|
apiClient<Contact>(`/api/v1/comm/contacts/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all });
|
||||||
|
setEditingContact(null);
|
||||||
|
setShowDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/comm/contacts/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contacts = data?.data ?? [];
|
||||||
|
|
||||||
|
const handleOpenAdd = () => {
|
||||||
|
setEditingContact(null);
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (contact: Contact) => {
|
||||||
|
setEditingContact(contact);
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{contacts.length} contact(s)</p>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenAdd}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm"
|
||||||
|
>
|
||||||
|
Add Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-muted-foreground">Loading contacts...</p>}
|
||||||
|
{error && <p className="text-red-500">Error: {(error as Error).message}</p>}
|
||||||
|
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left">
|
||||||
|
<th className="py-2 pr-4 font-medium">Name</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Email</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Phone</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Role</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Channels</th>
|
||||||
|
<th className="py-2 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b hover:bg-accent/50">
|
||||||
|
<td className="py-2 pr-4">{c.name}</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">{c.email}</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">{c.phone}</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">{c.role}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{c.channels.map((ch) => (
|
||||||
|
<span key={ch} className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{ch}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(c)}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Delete this contact?')) deleteMutation.mutate(c.id);
|
||||||
|
}}
|
||||||
|
className="text-xs text-red-500 hover:underline"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{contacts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||||
|
No contacts found. Click "Add Contact" to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit Dialog */}
|
||||||
|
{showDialog && (
|
||||||
|
<ContactDialog
|
||||||
|
contact={editingContact}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDialog(false);
|
||||||
|
setEditingContact(null);
|
||||||
|
}}
|
||||||
|
onSave={(c) => {
|
||||||
|
if (editingContact) {
|
||||||
|
updateMutation.mutate({ ...c, id: editingContact.id });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(c);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isSaving={createMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactDialog({
|
||||||
|
contact,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
contact: Contact | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: Omit<Contact, 'id'>) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(contact?.name ?? '');
|
||||||
|
const [email, setEmail] = useState(contact?.email ?? '');
|
||||||
|
const [phone, setPhone] = useState(contact?.phone ?? '');
|
||||||
|
const [role, setRole] = useState(contact?.role ?? '');
|
||||||
|
const [channels, setChannels] = useState<ChannelType[]>(contact?.channels ?? []);
|
||||||
|
|
||||||
|
const toggleChannel = (ch: ChannelType) => {
|
||||||
|
setChannels((prev) => (prev.includes(ch) ? prev.filter((c) => c !== ch) : [...prev, ch]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ name, email, phone, role, channels });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border rounded-lg shadow-lg w-full max-w-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{contact ? 'Edit Contact' : 'Add Contact'}</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email</label>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Phone</label>
|
||||||
|
<input value={phone} onChange={(e) => setPhone(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<input value={role} onChange={(e) => setRole(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" placeholder="e.g. admin, on-call, manager" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Channels</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_CHANNEL_TYPES.map((ch) => (
|
||||||
|
<button
|
||||||
|
key={ch}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleChannel(ch)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-1 rounded border transition-colors',
|
||||||
|
channels.includes(ch) ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-muted hover:border-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ch}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 border rounded-md text-sm hover:bg-accent">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isSaving} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50">
|
||||||
|
{isSaving ? 'Saving...' : contact ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab 3: Escalation Policies ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EscalationPoliciesTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.escalationPolicies.list(),
|
||||||
|
queryFn: () => apiClient<PaginatedResponse<EscalationPolicy>>('/api/v1/comm/escalation-policies'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: contactsData } = useQuery({
|
||||||
|
queryKey: queryKeys.contacts.list(),
|
||||||
|
queryFn: () => apiClient<PaginatedResponse<Contact>>('/api/v1/comm/contacts'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contacts = contactsData?.data ?? [];
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Omit<EscalationPolicy, 'id'>) =>
|
||||||
|
apiClient<EscalationPolicy>('/api/v1/comm/escalation-policies', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.escalationPolicies.all });
|
||||||
|
setShowDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...body }: EscalationPolicy) =>
|
||||||
|
apiClient<EscalationPolicy>(`/api/v1/comm/escalation-policies/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.escalationPolicies.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/comm/escalation-policies/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.escalationPolicies.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const policies = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{policies.length} policy(ies)</p>
|
||||||
|
<button onClick={() => setShowDialog(true)} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm">
|
||||||
|
Add Policy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-muted-foreground">Loading policies...</p>}
|
||||||
|
{error && <p className="text-red-500">Error: {(error as Error).message}</p>}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{policies.map((policy) => (
|
||||||
|
<div key={policy.id} className="border rounded-lg bg-card">
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{policy.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Severity: {policy.severity}</p>
|
||||||
|
</div>
|
||||||
|
{policy.isDefault && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">Default</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(expandedId === policy.id ? null : policy.id)}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{expandedId === policy.id ? 'Collapse' : 'Edit Steps'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Delete this escalation policy?')) deleteMutation.mutate(policy.id);
|
||||||
|
}}
|
||||||
|
className="text-sm text-red-500 hover:underline"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedId === policy.id && (
|
||||||
|
<PolicyStepEditor
|
||||||
|
policy={policy}
|
||||||
|
contacts={contacts}
|
||||||
|
onSave={(updated) => updateMutation.mutate(updated)}
|
||||||
|
isSaving={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && policies.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground py-8">No escalation policies. Click "Add Policy" to create one.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Policy Dialog */}
|
||||||
|
{showDialog && (
|
||||||
|
<PolicyDialog
|
||||||
|
onClose={() => setShowDialog(false)}
|
||||||
|
onSave={(p) => createMutation.mutate(p)}
|
||||||
|
isSaving={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyStepEditor({
|
||||||
|
policy,
|
||||||
|
contacts,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
policy: EscalationPolicy;
|
||||||
|
contacts: Contact[];
|
||||||
|
onSave: (policy: EscalationPolicy) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}) {
|
||||||
|
const [steps, setSteps] = useState<EscalationStep[]>(policy.steps);
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
setSteps((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ stepNumber: prev.length + 1, channels: [], delayMinutes: 5, contactIds: [] },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStep = (idx: number) => {
|
||||||
|
setSteps((prev) => prev.filter((_, i) => i !== idx).map((s, i) => ({ ...s, stepNumber: i + 1 })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStep = (idx: number, updates: Partial<EscalationStep>) => {
|
||||||
|
setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...updates } : s)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStepChannel = (idx: number, ch: ChannelType) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((s, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...s, channels: s.channels.includes(ch) ? s.channels.filter((c) => c !== ch) : [...s.channels, ch] }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStepContact = (idx: number, contactId: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((s, i) =>
|
||||||
|
i === idx
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
contactIds: s.contactIds.includes(contactId)
|
||||||
|
? s.contactIds.filter((c) => c !== contactId)
|
||||||
|
: [...s.contactIds, contactId],
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t p-4 space-y-4">
|
||||||
|
<p className="text-sm font-medium">Escalation Steps</p>
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<div key={idx} className="border rounded-md p-3 space-y-2 bg-background">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Step {step.stepNumber}</span>
|
||||||
|
<button onClick={() => removeStep(idx)} className="text-xs text-red-500 hover:underline">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Delay (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={step.delayMinutes}
|
||||||
|
onChange={(e) => updateStep(idx, { delayMinutes: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-24 px-2 py-1 bg-input border rounded text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Channels</label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ALL_CHANNEL_TYPES.map((ch) => (
|
||||||
|
<button
|
||||||
|
key={ch}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleStepChannel(idx, ch)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||||
|
step.channels.includes(ch) ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ch}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">Contacts</label>
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No contacts available. Add contacts first.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleStepContact(idx, c.id)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||||
|
step.contactIds.includes(c.id) ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={addStep} className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent">
|
||||||
|
+ Add Step
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSave({ ...policy, steps })}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-1.5 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Steps'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyDialog({
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: Omit<EscalationPolicy, 'id'>) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [severity, setSeverity] = useState('critical');
|
||||||
|
const [isDefault, setIsDefault] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ name, severity, isDefault, steps: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border rounded-lg shadow-lg w-full max-w-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Add Escalation Policy</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" placeholder="e.g. Critical Alert Policy" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Severity</label>
|
||||||
|
<select value={severity} onChange={(e) => setSeverity(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md text-sm">
|
||||||
|
{SEVERITY_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="isDefault" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
|
||||||
|
<label htmlFor="isDefault" className="text-sm">Set as default policy</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 border rounded-md text-sm hover:bg-accent">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isSaving} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50">
|
||||||
|
{isSaving ? 'Creating...' : 'Create Policy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Server, AlertTriangle, ListTodo, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
/* ---------- tiny type helpers (backend shapes) ---------- */
|
||||||
|
|
||||||
|
interface ServerItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertEvent {
|
||||||
|
id: string;
|
||||||
|
severity: 'critical' | 'warning' | 'info';
|
||||||
|
message: string;
|
||||||
|
serverId?: string;
|
||||||
|
firedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StandingOrderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise backend responses: backends may return a raw array or { items/data, total }. */
|
||||||
|
function normalise<T>(raw: unknown): { items: T[]; total: number } {
|
||||||
|
if (Array.isArray(raw)) return { items: raw, total: raw.length };
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
const items = (obj.items ?? obj.data ?? []) as T[];
|
||||||
|
const total = (obj.total as number) ?? items.length;
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- severity badge ---------- */
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
colors[severity] ?? 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- status badge ---------- */
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
colors[status] ?? 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- stat card ---------- */
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
count: number | undefined;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, count, icon, loading }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-card rounded-lg border flex items-center gap-4">
|
||||||
|
<div className="p-3 rounded-md bg-primary/10 text-primary">{icon}</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">
|
||||||
|
{loading ? (
|
||||||
|
<span className="inline-block w-8 h-8 rounded bg-muted animate-pulse" />
|
||||||
|
) : (
|
||||||
|
(count ?? 0)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- page ---------- */
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const {
|
||||||
|
data: serversRaw,
|
||||||
|
isLoading: serversLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.servers.list(),
|
||||||
|
queryFn: () => apiClient<unknown>('/api/v1/inventory/servers'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: alertsRaw,
|
||||||
|
isLoading: alertsLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.alerts.events(),
|
||||||
|
queryFn: () => apiClient<unknown>('/api/v1/monitor/alerts/events'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tasksRaw,
|
||||||
|
isLoading: tasksLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.tasks.list(),
|
||||||
|
queryFn: () => apiClient<unknown>('/api/v1/ops/tasks'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: ordersRaw,
|
||||||
|
isLoading: ordersLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.standingOrders.list(),
|
||||||
|
queryFn: () => apiClient<unknown>('/api/v1/ops/standing-orders'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serversData = serversRaw ? normalise<ServerItem>(serversRaw) : undefined;
|
||||||
|
const alertsData = alertsRaw ? normalise<AlertEvent>(alertsRaw) : undefined;
|
||||||
|
const tasksData = tasksRaw ? normalise<TaskItem>(tasksRaw) : undefined;
|
||||||
|
const standingOrdersData = ordersRaw ? normalise<StandingOrderItem>(ordersRaw) : undefined;
|
||||||
|
|
||||||
|
const recentAlerts = (alertsData?.items ?? []).slice(0, 5);
|
||||||
|
const recentTasks = (tasksData?.items ?? []).slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
{/* ---- stat cards ---- */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatCard
|
||||||
|
label="Total Servers"
|
||||||
|
count={serversData?.total}
|
||||||
|
icon={<Server className="h-5 w-5" />}
|
||||||
|
loading={serversLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active Alerts"
|
||||||
|
count={alertsData?.total}
|
||||||
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
|
loading={alertsLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Running Tasks"
|
||||||
|
count={tasksData?.total}
|
||||||
|
icon={<ListTodo className="h-5 w-5" />}
|
||||||
|
loading={tasksLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Standing Orders"
|
||||||
|
count={standingOrdersData?.total}
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
loading={ordersLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- recent alerts ---- */}
|
||||||
|
<div className="bg-card rounded-lg border p-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Recent Alerts</h2>
|
||||||
|
{alertsLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : recentAlerts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No recent alerts.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="pb-2 pr-4 font-medium">Severity</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Message</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Server</th>
|
||||||
|
<th className="pb-2 font-medium">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentAlerts.map((alert) => (
|
||||||
|
<tr key={alert.id} className="border-b last:border-b-0">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<SeverityBadge severity={alert.severity} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 max-w-xs truncate">{alert.message}</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">
|
||||||
|
{alert.serverId ?? '--'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDistanceToNow(new Date(alert.firedAt), { addSuffix: true })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- recent tasks ---- */}
|
||||||
|
<div className="bg-card rounded-lg border p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Recent Tasks</h2>
|
||||||
|
{tasksLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : recentTasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No recent tasks.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="pb-2 pr-4 font-medium">Name</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Status</th>
|
||||||
|
<th className="pb-2 font-medium">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentTasks.map((task) => (
|
||||||
|
<tr key={task.id} className="border-b last:border-b-0">
|
||||||
|
<td className="py-2 pr-4">{task.title}</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDistanceToNow(new Date(task.createdAt), { addSuffix: true })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Sidebar } from '@/presentation/components/layout/sidebar';
|
||||||
|
import { TopBar } from '@/presentation/components/layout/top-bar';
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,734 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Severity = 'critical' | 'warning' | 'info';
|
||||||
|
type Condition = 'gt' | 'lt' | 'gte' | 'lte' | 'eq';
|
||||||
|
type Metric = 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'network_latency' | 'http_error_rate' | 'custom';
|
||||||
|
|
||||||
|
interface AlertRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
metric: Metric;
|
||||||
|
condition: Condition;
|
||||||
|
threshold: number;
|
||||||
|
duration: string;
|
||||||
|
severity: Severity;
|
||||||
|
enabled: boolean;
|
||||||
|
targetServers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertRuleFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
metric: Metric;
|
||||||
|
condition: Condition;
|
||||||
|
threshold: string;
|
||||||
|
duration: string;
|
||||||
|
severity: Severity;
|
||||||
|
enabled: boolean;
|
||||||
|
targetServers: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertRulesResponse {
|
||||||
|
data: AlertRule[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const METRICS: { label: string; value: Metric }[] = [
|
||||||
|
{ label: 'CPU Usage', value: 'cpu_usage' },
|
||||||
|
{ label: 'Memory Usage', value: 'memory_usage' },
|
||||||
|
{ label: 'Disk Usage', value: 'disk_usage' },
|
||||||
|
{ label: 'Network Latency', value: 'network_latency' },
|
||||||
|
{ label: 'HTTP Error Rate', value: 'http_error_rate' },
|
||||||
|
{ label: 'Custom', value: 'custom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONDITIONS: { label: string; value: Condition }[] = [
|
||||||
|
{ label: '> (greater than)', value: 'gt' },
|
||||||
|
{ label: '< (less than)', value: 'lt' },
|
||||||
|
{ label: '>= (greater or equal)', value: 'gte' },
|
||||||
|
{ label: '<= (less or equal)', value: 'lte' },
|
||||||
|
{ label: '= (equal)', value: 'eq' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEVERITIES: { label: string; value: Severity }[] = [
|
||||||
|
{ label: 'Critical', value: 'critical' },
|
||||||
|
{ label: 'Warning', value: 'warning' },
|
||||||
|
{ label: 'Info', value: 'info' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DURATIONS: { label: string; value: string }[] = [
|
||||||
|
{ label: '1 minute', value: '1m' },
|
||||||
|
{ label: '5 minutes', value: '5m' },
|
||||||
|
{ label: '10 minutes', value: '10m' },
|
||||||
|
{ label: '15 minutes', value: '15m' },
|
||||||
|
{ label: '30 minutes', value: '30m' },
|
||||||
|
{ label: '1 hour', value: '1h' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONDITION_SYMBOLS: Record<Condition, string> = {
|
||||||
|
gt: '>',
|
||||||
|
lt: '<',
|
||||||
|
gte: '>=',
|
||||||
|
lte: '<=',
|
||||||
|
eq: '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METRIC_LABELS: Record<Metric, string> = {
|
||||||
|
cpu_usage: 'CPU Usage',
|
||||||
|
memory_usage: 'Memory Usage',
|
||||||
|
disk_usage: 'Disk Usage',
|
||||||
|
network_latency: 'Network Latency',
|
||||||
|
http_error_rate: 'HTTP Error Rate',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: AlertRuleFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
metric: 'cpu_usage',
|
||||||
|
condition: 'gt',
|
||||||
|
threshold: '',
|
||||||
|
duration: '5m',
|
||||||
|
severity: 'warning',
|
||||||
|
enabled: true,
|
||||||
|
targetServers: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Severity badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: Severity }) {
|
||||||
|
const styles: Record<Severity, string> = {
|
||||||
|
critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[severity],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enabled toggle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EnabledToggle({
|
||||||
|
enabled,
|
||||||
|
toggling,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
toggling: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={toggling}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50',
|
||||||
|
enabled ? 'bg-primary' : 'bg-muted-foreground/30',
|
||||||
|
)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
enabled ? 'translate-x-4' : 'translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert rule form dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AlertRuleDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: AlertRuleFormData;
|
||||||
|
errors: Partial<Record<keyof AlertRuleFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof AlertRuleFormData, value: string | number | boolean) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="High CPU Alert"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* metric */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Metric</label>
|
||||||
|
<select
|
||||||
|
value={form.metric}
|
||||||
|
onChange={(e) => onChange('metric', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{METRICS.map((m) => (
|
||||||
|
<option key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* condition and threshold row */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* condition */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Condition</label>
|
||||||
|
<select
|
||||||
|
value={form.condition}
|
||||||
|
onChange={(e) => onChange('condition', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{CONDITIONS.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* threshold */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Threshold <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.threshold}
|
||||||
|
onChange={(e) => onChange('threshold', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.threshold ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="90"
|
||||||
|
step="any"
|
||||||
|
/>
|
||||||
|
{errors.threshold && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.threshold}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* duration and severity row */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* duration */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Duration</label>
|
||||||
|
<select
|
||||||
|
value={form.duration}
|
||||||
|
onChange={(e) => onChange('duration', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{DURATIONS.map((d) => (
|
||||||
|
<option key={d.value} value={d.value}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* severity */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Severity</label>
|
||||||
|
<select
|
||||||
|
value={form.severity}
|
||||||
|
onChange={(e) => onChange('severity', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{SEVERITIES.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* target servers */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Target Servers</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.targetServers}
|
||||||
|
onChange={(e) => onChange('targetServers', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="web-01, web-02, db-01 (comma-separated)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Leave empty to apply to all servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* enabled toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">Enabled</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('enabled', !form.enabled)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||||
|
form.enabled ? 'bg-primary' : 'bg-muted-foreground/30',
|
||||||
|
)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={form.enabled}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
form.enabled ? 'translate-x-4' : 'translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
ruleName,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
ruleName: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Alert Rule</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{ruleName}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function AlertRulesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AlertRule | null>(null);
|
||||||
|
const [form, setForm] = useState<AlertRuleFormData>({ ...EMPTY_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof AlertRuleFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.alerts.rules(),
|
||||||
|
queryFn: () => apiClient<AlertRulesResponse>('/api/v1/monitor/alert-rules'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = data?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<AlertRule>('/api/v1/monitor/alert-rules', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.alerts.rules() });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<AlertRule>(`/api/v1/monitor/alert-rules/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.alerts.rules() });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/monitor/alert-rules/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.alerts.rules() });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||||
|
apiClient<AlertRule>(`/api/v1/monitor/alert-rules/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { enabled },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.alerts.rules() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof AlertRuleFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
if (!form.threshold.trim()) {
|
||||||
|
next.threshold = 'Threshold is required';
|
||||||
|
} else if (isNaN(Number(form.threshold))) {
|
||||||
|
next.threshold = 'Threshold must be a number';
|
||||||
|
}
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((rule: AlertRule) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
setForm({
|
||||||
|
name: rule.name,
|
||||||
|
description: rule.description ?? '',
|
||||||
|
metric: rule.metric,
|
||||||
|
condition: rule.condition,
|
||||||
|
threshold: String(rule.threshold),
|
||||||
|
duration: rule.duration,
|
||||||
|
severity: rule.severity,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
targetServers: (rule.targetServers ?? []).join(', '),
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof AlertRuleFormData, value: string | number | boolean) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
metric: form.metric,
|
||||||
|
condition: form.condition,
|
||||||
|
threshold: Number(form.threshold),
|
||||||
|
duration: form.duration,
|
||||||
|
severity: form.severity,
|
||||||
|
enabled: form.enabled,
|
||||||
|
targetServers: form.targetServers
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRule) {
|
||||||
|
updateMutation.mutate({ id: editingRule.id, body });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingRule, validate, createMutation, updateMutation]);
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Alert Rules</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Configure alert rules to monitor server metrics and receive notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load alert rules: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading alert rules...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Metric</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Condition</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Threshold</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Severity</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Enabled</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No alert rules found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rules.map((rule) => (
|
||||||
|
<tr
|
||||||
|
key={rule.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium">{rule.name}</div>
|
||||||
|
{rule.description && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5 truncate max-w-[200px]">
|
||||||
|
{rule.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted">
|
||||||
|
{METRIC_LABELS[rule.metric] ?? rule.metric}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{CONDITION_SYMBOLS[rule.condition] ?? rule.condition}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{rule.threshold}
|
||||||
|
{rule.duration && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
for {rule.duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<SeverityBadge severity={rule.severity} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<EnabledToggle
|
||||||
|
enabled={rule.enabled}
|
||||||
|
toggling={toggleMutation.isPending}
|
||||||
|
onToggle={() =>
|
||||||
|
toggleMutation.mutate({
|
||||||
|
id: rule.id,
|
||||||
|
enabled: !rule.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(rule)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(rule)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<AlertRuleDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingRule ? 'Edit Alert Rule' : 'Add Alert Rule'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isSaving}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
ruleName={deleteTarget?.name ?? ''}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface HealthCheckResult {
|
||||||
|
id: string;
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
serverHost: string;
|
||||||
|
checkType: 'ping' | 'tcp' | 'http';
|
||||||
|
status: 'healthy' | 'degraded' | 'down';
|
||||||
|
latencyMs: number;
|
||||||
|
uptimePercent: number;
|
||||||
|
lastCheckedAt: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthChecksResponse {
|
||||||
|
data: HealthCheckResult[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'healthy' | 'degraded' | 'down';
|
||||||
|
type RefreshInterval = 0 | 10 | 30 | 60;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Healthy', value: 'healthy' },
|
||||||
|
{ label: 'Degraded', value: 'degraded' },
|
||||||
|
{ label: 'Down', value: 'down' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [
|
||||||
|
{ label: 'Off', value: 0 },
|
||||||
|
{ label: '10s', value: 10 },
|
||||||
|
{ label: '30s', value: 30 },
|
||||||
|
{ label: '60s', value: 60 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateString).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
if (diffMs < 0) return 'just now';
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusDotColor(status: HealthCheckResult['status']): string {
|
||||||
|
const colors: Record<HealthCheckResult['status'], string> = {
|
||||||
|
healthy: 'bg-green-500',
|
||||||
|
degraded: 'bg-yellow-500',
|
||||||
|
down: 'bg-red-500',
|
||||||
|
};
|
||||||
|
return colors[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatencyColor(ms: number): string {
|
||||||
|
if (ms < 100) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (ms < 500) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUptimeColor(percent: number): string {
|
||||||
|
if (percent >= 99.9) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (percent >= 95) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckTypeBadgeColor(type: HealthCheckResult['checkType']): string {
|
||||||
|
const colors: Record<HealthCheckResult['checkType'], string> = {
|
||||||
|
ping: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
tcp: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
http: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400',
|
||||||
|
};
|
||||||
|
return colors[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Summary stat card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatCard({ label, value, dotColor }: { label: string; value: number; dotColor?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{dotColor && <span className={cn('w-2.5 h-2.5 rounded-full', dotColor)} />}
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health check card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HealthCheckCard({ check }: { check: HealthCheckResult }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('w-2.5 h-2.5 rounded-full flex-shrink-0', getStatusDotColor(check.status))} />
|
||||||
|
<h3 className="text-sm font-semibold truncate">{check.serverName}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono mt-0.5 ml-[18px]">{check.serverHost}</p>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium uppercase flex-shrink-0', getCheckTypeBadgeColor(check.checkType))}>
|
||||||
|
{check.checkType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-0.5">Latency</p>
|
||||||
|
<p className={cn('text-sm font-semibold', getLatencyColor(check.latencyMs))}>{check.latencyMs}ms</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-0.5">Uptime (24h)</p>
|
||||||
|
<p className={cn('text-sm font-semibold', getUptimeColor(check.uptimePercent))}>{check.uptimePercent.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-0.5">Last Check</p>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{formatRelativeTime(check.lastCheckedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{check.message && (
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground border-t pt-2 truncate" title={check.message}>
|
||||||
|
{check.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function HealthChecksPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState<RefreshInterval>(0);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.healthChecks.list(),
|
||||||
|
queryFn: () => apiClient<HealthChecksResponse>('/api/v1/monitor/health-checks'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allChecks = data?.data ?? [];
|
||||||
|
|
||||||
|
const filteredChecks = useMemo(() => {
|
||||||
|
if (statusFilter === 'all') return allChecks;
|
||||||
|
return allChecks.filter((c) => c.status === statusFilter);
|
||||||
|
}, [allChecks, statusFilter]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = allChecks.length;
|
||||||
|
const healthy = allChecks.filter((c) => c.status === 'healthy').length;
|
||||||
|
const degraded = allChecks.filter((c) => c.status === 'degraded').length;
|
||||||
|
const down = allChecks.filter((c) => c.status === 'down').length;
|
||||||
|
return { total, healthy, degraded, down };
|
||||||
|
}, [allChecks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshInterval === 0) return;
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.healthChecks.all });
|
||||||
|
}, refreshInterval * 1000);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [refreshInterval, queryClient]);
|
||||||
|
|
||||||
|
const handleRefreshChange = useCallback((value: RefreshInterval) => {
|
||||||
|
setRefreshInterval(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Health Checks</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Monitor server health and availability</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Auto-refresh:</span>
|
||||||
|
<select
|
||||||
|
value={refreshInterval}
|
||||||
|
onChange={(e) => handleRefreshChange(Number(e.target.value) as RefreshInterval)}
|
||||||
|
className="px-2 py-1.5 bg-background border border-input rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{REFRESH_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{refreshInterval > 0 && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||||
|
<StatCard label="Total Servers" value={stats.total} />
|
||||||
|
<StatCard label="Healthy" value={stats.healthy} dotColor="bg-green-500" />
|
||||||
|
<StatCard label="Degraded" value={stats.degraded} dotColor="bg-yellow-500" />
|
||||||
|
<StatCard label="Down" value={stats.down} dotColor="bg-red-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 p-1 bg-muted rounded-lg w-fit">
|
||||||
|
{STATUS_FILTERS.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => setStatusFilter(filter.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
statusFilter === filter.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
{filter.value !== 'all' && (
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||||
|
{filter.value === 'healthy' && stats.healthy}
|
||||||
|
{filter.value === 'degraded' && stats.degraded}
|
||||||
|
{filter.value === 'down' && stats.down}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load health checks: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">Loading health checks...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<>
|
||||||
|
{filteredChecks.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
{statusFilter === 'all' ? 'No health checks found.' : `No servers with status "${statusFilter}" found.`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredChecks.map((check) => (
|
||||||
|
<HealthCheckCard key={check.id} check={check} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,574 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MetricsOverview {
|
||||||
|
totalServers: number;
|
||||||
|
onlinePercent: number;
|
||||||
|
avgCpuPercent: number;
|
||||||
|
avgMemoryPercent: number;
|
||||||
|
totalAlertsToday: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerMetric {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
cpuPercent: number;
|
||||||
|
memoryPercent: number;
|
||||||
|
diskPercent: number;
|
||||||
|
lastCheckedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsOverviewResponse {
|
||||||
|
data: MetricsOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerMetricsResponse {
|
||||||
|
data: ServerMetric[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentFilter = 'all' | 'dev' | 'staging' | 'prod';
|
||||||
|
type StatusFilter = 'all' | 'online' | 'offline';
|
||||||
|
type SortField = 'hostname' | 'status' | 'cpuPercent' | 'memoryPercent' | 'diskPercent' | 'lastCheckedAt';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ENV_FILTERS: { label: string; value: EnvironmentFilter }[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Dev', value: 'dev' },
|
||||||
|
{ label: 'Staging', value: 'staging' },
|
||||||
|
{ label: 'Prod', value: 'prod' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Online', value: 'online' },
|
||||||
|
{ label: 'Offline', value: 'offline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 0) return 'just now';
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPercentColor(value: number): string {
|
||||||
|
if (value < 60) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (value < 85) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPercentBarColor(value: number): string {
|
||||||
|
if (value < 60) return 'bg-green-500';
|
||||||
|
if (value < 85) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overview stat card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function OverviewCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
suffix,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
suffix?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-4">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">{label}</p>
|
||||||
|
<p className={cn('text-2xl font-bold', color)}>
|
||||||
|
{value}
|
||||||
|
{suffix && <span className="text-sm font-normal text-muted-foreground ml-1">{suffix}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: ServerMetric['status'] }) {
|
||||||
|
const styles: Record<ServerMetric['status'], string> = {
|
||||||
|
online: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
offline: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EnvironmentBadge({ env }: { env: ServerMetric['environment'] }) {
|
||||||
|
const styles: Record<ServerMetric['environment'], string> = {
|
||||||
|
dev: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300',
|
||||||
|
staging: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
prod: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
styles[env],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{env}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Percent bar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PercentBar({ value, label }: { value: number; label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[120px]">
|
||||||
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full transition-all', getPercentBarColor(value))}
|
||||||
|
style={{ width: `${Math.min(value, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={cn('text-xs font-medium tabular-nums w-10 text-right', getPercentColor(value))}>
|
||||||
|
{value.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-xs text-muted-foreground">{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sortable column header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
currentSort,
|
||||||
|
currentDirection,
|
||||||
|
onSort,
|
||||||
|
align,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortField;
|
||||||
|
currentSort: SortField;
|
||||||
|
currentDirection: SortDirection;
|
||||||
|
onSort: (field: SortField) => void;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
}) {
|
||||||
|
const isActive = currentSort === field;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 font-medium cursor-pointer select-none hover:text-foreground transition-colors',
|
||||||
|
align === 'right' ? 'text-right' : 'text-left',
|
||||||
|
)}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{label}
|
||||||
|
{isActive && (
|
||||||
|
<span className="text-xs">
|
||||||
|
{currentDirection === 'asc' ? '\u2191' : '\u2193'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function MetricsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [envFilter, setEnvFilter] = useState<EnvironmentFilter>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('hostname');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data: overviewData, isLoading: overviewLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.metrics.overview(),
|
||||||
|
queryFn: () => apiClient<MetricsOverviewResponse>('/api/v1/monitor/metrics/overview'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = overviewData?.data;
|
||||||
|
|
||||||
|
const { data: serversData, isLoading: serversLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.metrics.servers(),
|
||||||
|
queryFn: () => apiClient<ServerMetricsResponse>('/api/v1/monitor/metrics/servers'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allServers = serversData?.data ?? [];
|
||||||
|
|
||||||
|
// Auto-refresh ---------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.metrics.all });
|
||||||
|
}, 30_000);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [autoRefresh, queryClient]);
|
||||||
|
|
||||||
|
// Filter & sort --------------------------------------------------------
|
||||||
|
const filteredServers = useMemo(() => {
|
||||||
|
let result = allServers;
|
||||||
|
|
||||||
|
if (envFilter !== 'all') {
|
||||||
|
result = result.filter((s) => s.environment === envFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
result = result.filter((s) => s.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase().trim();
|
||||||
|
result = result.filter((s) => s.hostname.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch (sortField) {
|
||||||
|
case 'hostname':
|
||||||
|
cmp = a.hostname.localeCompare(b.hostname);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
cmp = a.status.localeCompare(b.status);
|
||||||
|
break;
|
||||||
|
case 'cpuPercent':
|
||||||
|
cmp = a.cpuPercent - b.cpuPercent;
|
||||||
|
break;
|
||||||
|
case 'memoryPercent':
|
||||||
|
cmp = a.memoryPercent - b.memoryPercent;
|
||||||
|
break;
|
||||||
|
case 'diskPercent':
|
||||||
|
cmp = a.diskPercent - b.diskPercent;
|
||||||
|
break;
|
||||||
|
case 'lastCheckedAt':
|
||||||
|
cmp = new Date(a.lastCheckedAt).getTime() - new Date(b.lastCheckedAt).getTime();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortDirection === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [allServers, envFilter, statusFilter, search, sortField, sortDirection]);
|
||||||
|
|
||||||
|
// Handlers -------------------------------------------------------------
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sortField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = overviewLoading || serversLoading;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Metrics Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Monitor server performance and resource utilization
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input text-primary focus:ring-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
Auto-refresh (30s)
|
||||||
|
</label>
|
||||||
|
{autoRefresh && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview cards */}
|
||||||
|
{overview && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<OverviewCard
|
||||||
|
label="Total Servers"
|
||||||
|
value={overview.totalServers}
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
label="Online"
|
||||||
|
value={`${overview.onlinePercent.toFixed(1)}`}
|
||||||
|
suffix="%"
|
||||||
|
color={overview.onlinePercent >= 95 ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'}
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
label="Avg CPU"
|
||||||
|
value={`${overview.avgCpuPercent.toFixed(1)}`}
|
||||||
|
suffix="%"
|
||||||
|
color={getPercentColor(overview.avgCpuPercent)}
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
label="Avg Memory"
|
||||||
|
value={`${overview.avgMemoryPercent.toFixed(1)}`}
|
||||||
|
suffix="%"
|
||||||
|
color={getPercentColor(overview.avgMemoryPercent)}
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
label="Alerts Today"
|
||||||
|
value={overview.totalAlertsToday}
|
||||||
|
color={overview.totalAlertsToday > 0 ? 'text-red-600 dark:text-red-400' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overviewLoading && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-card border rounded-lg p-4 animate-pulse">
|
||||||
|
<div className="h-4 bg-muted rounded w-20 mb-2" />
|
||||||
|
<div className="h-7 bg-muted rounded w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 max-w-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="Search by hostname..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment filter */}
|
||||||
|
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
||||||
|
{ENV_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setEnvFilter(f.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
envFilter === f.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setStatusFilter(f.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
statusFilter === f.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load server metrics: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{serversLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading server metrics...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics table */}
|
||||||
|
{!serversLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<SortableHeader
|
||||||
|
label="Hostname"
|
||||||
|
field="hostname"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Env</th>
|
||||||
|
<SortableHeader
|
||||||
|
label="Status"
|
||||||
|
field="status"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="CPU %"
|
||||||
|
field="cpuPercent"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Memory %"
|
||||||
|
field="memoryPercent"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Disk %"
|
||||||
|
field="diskPercent"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
label="Last Checked"
|
||||||
|
field="lastCheckedAt"
|
||||||
|
currentSort={sortField}
|
||||||
|
currentDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredServers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
{allServers.length === 0
|
||||||
|
? 'No server metrics available.'
|
||||||
|
: 'No servers match the current filters.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredServers.map((server) => (
|
||||||
|
<tr
|
||||||
|
key={server.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-medium">{server.hostname}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<EnvironmentBadge env={server.environment} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={server.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<PercentBar value={server.cpuPercent} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<PercentBar value={server.memoryPercent} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<PercentBar value={server.diskPercent} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-muted-foreground text-xs">
|
||||||
|
{formatRelativeTime(server.lastCheckedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
{!serversLoading && !error && allServers.length > 0 && (
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Showing {filteredServers.length} of {allServers.length} server{allServers.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,818 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Runbook {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
triggerType: 'manual' | 'alert' | 'scheduled';
|
||||||
|
promptTemplate: string;
|
||||||
|
allowedTools: string[];
|
||||||
|
maxRiskLevel: number;
|
||||||
|
autoApprove: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunbookExecution {
|
||||||
|
id: string;
|
||||||
|
runbookId: string;
|
||||||
|
status: 'running' | 'completed' | 'failed';
|
||||||
|
triggeredBy: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const AVAILABLE_TOOLS = [
|
||||||
|
'Bash',
|
||||||
|
'Read',
|
||||||
|
'Write',
|
||||||
|
'Glob',
|
||||||
|
'Grep',
|
||||||
|
'Edit',
|
||||||
|
'ssh',
|
||||||
|
'kubectl',
|
||||||
|
'docker',
|
||||||
|
'systemctl',
|
||||||
|
'curl',
|
||||||
|
'ping',
|
||||||
|
];
|
||||||
|
|
||||||
|
const RISK_LEVEL_LABELS: Record<number, string> = {
|
||||||
|
0: 'L0 - Info',
|
||||||
|
1: 'L1 - Low',
|
||||||
|
2: 'L2 - Medium',
|
||||||
|
3: 'L3 - High',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_LEVEL_STYLES: Record<number, string> = {
|
||||||
|
0: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
1: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
3: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGER_STYLES: Record<string, string> = {
|
||||||
|
manual: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
alert: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||||
|
scheduled: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXECUTION_STATUS_STYLES: Record<string, string> = {
|
||||||
|
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Badge helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RiskBadge({ level }: { level: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
RISK_LEVEL_STYLES[level] ?? RISK_LEVEL_STYLES[0],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{RISK_LEVEL_LABELS[level] ?? `L${level}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriggerBadge({ type }: { type: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
TRIGGER_STYLES[type] ?? TRIGGER_STYLES.manual,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
EXECUTION_STATUS_STYLES[status] ?? EXECUTION_STATUS_STYLES.running,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Toggle switch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
|
checked ? 'bg-primary' : 'bg-muted',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
|
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
name,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
name: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Runbook</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{name}</strong>? This action
|
||||||
|
cannot be undone. All execution history will also be removed.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: format date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms?: number): string {
|
||||||
|
if (ms == null) return '--';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = Math.round(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remaining = seconds % 60;
|
||||||
|
return `${minutes}m ${remaining}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function RunbookDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ------------------------------------------------------------------
|
||||||
|
const [isEditingPrompt, setIsEditingPrompt] = useState(false);
|
||||||
|
const [promptDraft, setPromptDraft] = useState('');
|
||||||
|
const [toolsDraft, setToolsDraft] = useState<string[]>([]);
|
||||||
|
const [isEditingTools, setIsEditingTools] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// Queries ----------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: runbook,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery<Runbook>({
|
||||||
|
queryKey: queryKeys.runbooks.detail(id),
|
||||||
|
queryFn: () => apiClient<Runbook>(`/api/v1/ops/runbooks/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: executionsData } = useQuery<{ data: RunbookExecution[] }>({
|
||||||
|
queryKey: [...queryKeys.runbooks.detail(id), 'executions'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<{ data: RunbookExecution[] }>(
|
||||||
|
`/api/v1/ops/runbooks/${id}/executions`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executions = executionsData?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations --------------------------------------------------------------
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: Partial<Runbook>) =>
|
||||||
|
apiClient<Runbook>(`/api/v1/ops/runbooks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/ops/runbooks/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
router.push('/runbooks');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/ops/runbooks/${id}/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [...queryKeys.runbooks.detail(id), 'executions'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicateMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!runbook) throw new Error('Runbook not loaded');
|
||||||
|
return apiClient<Runbook>('/api/v1/ops/runbooks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: `${runbook.name} (Copy)`,
|
||||||
|
description: runbook.description,
|
||||||
|
triggerType: runbook.triggerType,
|
||||||
|
promptTemplate: runbook.promptTemplate,
|
||||||
|
allowedTools: runbook.allowedTools,
|
||||||
|
maxRiskLevel: runbook.maxRiskLevel,
|
||||||
|
autoApprove: runbook.autoApprove,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (newRunbook) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
router.push(`/runbooks/${newRunbook.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers ---------------------------------------------------------------
|
||||||
|
const handleStartEditPrompt = useCallback(() => {
|
||||||
|
if (!runbook) return;
|
||||||
|
setPromptDraft(runbook.promptTemplate);
|
||||||
|
setIsEditingPrompt(true);
|
||||||
|
}, [runbook]);
|
||||||
|
|
||||||
|
const handleSavePrompt = useCallback(() => {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ promptTemplate: promptDraft },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEditingPrompt(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [promptDraft, updateMutation]);
|
||||||
|
|
||||||
|
const handleCancelEditPrompt = useCallback(() => {
|
||||||
|
setIsEditingPrompt(false);
|
||||||
|
setPromptDraft('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartEditTools = useCallback(() => {
|
||||||
|
if (!runbook) return;
|
||||||
|
setToolsDraft([...runbook.allowedTools]);
|
||||||
|
setIsEditingTools(true);
|
||||||
|
}, [runbook]);
|
||||||
|
|
||||||
|
const handleToggleTool = useCallback((tool: string) => {
|
||||||
|
setToolsDraft((prev) =>
|
||||||
|
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveTools = useCallback(() => {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ allowedTools: toolsDraft },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEditingTools(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [toolsDraft, updateMutation]);
|
||||||
|
|
||||||
|
const handleCancelEditTools = useCallback(() => {
|
||||||
|
setIsEditingTools(false);
|
||||||
|
setToolsDraft([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleAutoApprove = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
updateMutation.mutate({ autoApprove: value });
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleEnabled = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
updateMutation.mutate({ enabled: value });
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render: Loading --------------------------------------------------------
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading runbook...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render: Error ----------------------------------------------------------
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/runbooks')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back to Runbooks
|
||||||
|
</button>
|
||||||
|
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load runbook: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render: Not found ------------------------------------------------------
|
||||||
|
if (!runbook) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/runbooks')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back to Runbooks
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Runbook not found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render: Main -----------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/runbooks')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div className="h-6 w-px bg-border" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{runbook.name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{runbook.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TriggerBadge type={runbook.triggerType} />
|
||||||
|
<RiskBadge level={runbook.maxRiskLevel} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
runbook.enabled
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{runbook.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mutation error banner */}
|
||||||
|
{(updateMutation.error || executeMutation.error || duplicateMutation.error) && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
{(
|
||||||
|
(updateMutation.error ||
|
||||||
|
executeMutation.error ||
|
||||||
|
duplicateMutation.error) as Error
|
||||||
|
).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Overview card */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Overview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1">{runbook.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Trigger Type
|
||||||
|
</label>
|
||||||
|
<p className="mt-1">
|
||||||
|
<TriggerBadge type={runbook.triggerType} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 text-muted-foreground">
|
||||||
|
{runbook.description || 'No description provided.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Created
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 text-muted-foreground">
|
||||||
|
{formatDate(runbook.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Updated
|
||||||
|
</label>
|
||||||
|
<p className="text-sm mt-1 text-muted-foreground">
|
||||||
|
{formatDate(runbook.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Template section */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Prompt Template</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditingPrompt ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditPrompt}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSavePrompt}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditPrompt}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingPrompt ? (
|
||||||
|
<textarea
|
||||||
|
value={promptDraft}
|
||||||
|
onChange={(e) => setPromptDraft(e.target.value)}
|
||||||
|
className="w-full bg-gray-950 text-green-400 font-mono text-sm p-4 rounded-md min-h-[300px] resize-y border-0 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap min-h-[300px]">
|
||||||
|
{runbook.promptTemplate || 'No prompt template defined.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allowed Tools section */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Allowed Tools</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditingTools ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditTools}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveTools}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditTools}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingTools ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{AVAILABLE_TOOLS.map((tool) => (
|
||||||
|
<label
|
||||||
|
key={tool}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolsDraft.includes(tool)}
|
||||||
|
onChange={() => handleToggleTool(tool)}
|
||||||
|
className="accent-primary h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{tool}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{runbook.allowedTools.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No tools configured.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
runbook.allowedTools.map((tool) => (
|
||||||
|
<span
|
||||||
|
key={tool}
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-md bg-muted text-xs font-mono"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution History */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Execution History</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Date</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
|
Duration
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
|
Triggered By
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{executions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={4}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No executions yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
executions.map((exec) => (
|
||||||
|
<tr
|
||||||
|
key={exec.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{formatDate(exec.startedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={exec.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{formatDuration(exec.durationMs)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{exec.triggeredBy}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
{/* Configuration card */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Configuration</h2>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Trigger type */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Trigger Type
|
||||||
|
</label>
|
||||||
|
<p className="mt-1.5">
|
||||||
|
<TriggerBadge type={runbook.triggerType} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Risk Level */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Max Risk Level
|
||||||
|
</label>
|
||||||
|
<p className="mt-1.5">
|
||||||
|
<RiskBadge level={runbook.maxRiskLevel} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-Approve toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5">
|
||||||
|
Auto-Approve
|
||||||
|
</label>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={runbook.autoApprove}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onChange={handleToggleAutoApprove}
|
||||||
|
label={runbook.autoApprove ? 'Enabled' : 'Disabled'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Auto-approve actions within the configured risk level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enabled toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={runbook.enabled}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onChange={handleToggleEnabled}
|
||||||
|
label={runbook.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions card */}
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Execute Now */}
|
||||||
|
<button
|
||||||
|
onClick={() => executeMutation.mutate()}
|
||||||
|
disabled={executeMutation.isPending || !runbook.enabled}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2 text-sm rounded-md font-medium transition-colors disabled:opacity-50',
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{executeMutation.isPending ? 'Executing...' : 'Execute Now'}
|
||||||
|
</button>
|
||||||
|
{!runbook.enabled && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enable the runbook to execute it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duplicate */}
|
||||||
|
<button
|
||||||
|
onClick={() => duplicateMutation.mutate()}
|
||||||
|
disabled={duplicateMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{duplicateMutation.isPending ? 'Duplicating...' : 'Duplicate'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete Runbook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
name={runbook.name}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setShowDeleteDialog(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/* ---------- Types ---------- */
|
||||||
|
interface Runbook {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
triggerType: 'manual' | 'alert' | 'scheduled';
|
||||||
|
promptTemplate: string;
|
||||||
|
allowedTools: string[];
|
||||||
|
maxRiskLevel: number;
|
||||||
|
autoApprove: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunbookFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
triggerType: 'manual' | 'alert' | 'scheduled';
|
||||||
|
promptTemplate: string;
|
||||||
|
allowedTools: string;
|
||||||
|
maxRiskLevel: number;
|
||||||
|
autoApprove: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: RunbookFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
triggerType: 'manual',
|
||||||
|
promptTemplate: '',
|
||||||
|
allowedTools: '',
|
||||||
|
maxRiskLevel: 1,
|
||||||
|
autoApprove: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGER_OPTIONS: { value: RunbookFormData['triggerType']; label: string }[] = [
|
||||||
|
{ value: 'manual', label: 'Manual' },
|
||||||
|
{ value: 'alert', label: 'Alert' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RISK_LEVELS = [
|
||||||
|
{ value: 0, label: '0 - Info' },
|
||||||
|
{ value: 1, label: '1 - Low' },
|
||||||
|
{ value: 2, label: '2 - Medium' },
|
||||||
|
{ value: 3, label: '3 - High' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- Component ---------- */
|
||||||
|
export default function RunbooksPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/* Dialog state */
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<RunbookFormData>(emptyForm);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ---------- Queries ---------- */
|
||||||
|
const { data: runbooks = [], isLoading, error } = useQuery<Runbook[]>({
|
||||||
|
queryKey: queryKeys.runbooks.list(),
|
||||||
|
queryFn: () => apiClient<Runbook[]>('/api/v1/ops/runbooks'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- Mutations ---------- */
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Omit<Runbook, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||||
|
apiClient<Runbook>('/api/v1/ops/runbooks', { method: 'POST', body: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: { id: string } & Partial<Runbook>) =>
|
||||||
|
apiClient<Runbook>(`/api/v1/ops/runbooks/${id}`, { method: 'PUT', body: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/ops/runbooks/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleAutoApproveMutation = useMutation({
|
||||||
|
mutationFn: ({ id, autoApprove }: { id: string; autoApprove: boolean }) =>
|
||||||
|
apiClient<Runbook>(`/api/v1/ops/runbooks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { autoApprove },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runbooks.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- Helpers ---------- */
|
||||||
|
function closeDialog() {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setEditingId(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(rb: Runbook) {
|
||||||
|
setForm({
|
||||||
|
name: rb.name,
|
||||||
|
description: rb.description,
|
||||||
|
triggerType: rb.triggerType,
|
||||||
|
promptTemplate: rb.promptTemplate,
|
||||||
|
allowedTools: rb.allowedTools.join(', '),
|
||||||
|
maxRiskLevel: rb.maxRiskLevel,
|
||||||
|
autoApprove: rb.autoApprove,
|
||||||
|
});
|
||||||
|
setEditingId(rb.id);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
triggerType: form.triggerType,
|
||||||
|
promptTemplate: form.promptTemplate,
|
||||||
|
allowedTools: form.allowedTools
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
maxRiskLevel: form.maxRiskLevel,
|
||||||
|
autoApprove: form.autoApprove,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, ...payload });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
/* ---------- Render ---------- */
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Runbooks</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage operations runbooks and automation scripts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
New Runbook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-muted-foreground text-sm py-12 text-center">Loading runbooks...</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-destructive text-sm py-4">
|
||||||
|
Failed to load runbooks: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Description</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Trigger</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Max Risk</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Auto-Approve</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runbooks.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
|
||||||
|
No runbooks yet. Click "New Runbook" to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
runbooks.map((rb) => (
|
||||||
|
<tr key={rb.id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium">{rb.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground max-w-xs truncate">
|
||||||
|
{rb.description}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block px-2 py-0.5 rounded text-xs font-medium',
|
||||||
|
rb.triggerType === 'manual' && 'bg-secondary text-secondary-foreground',
|
||||||
|
rb.triggerType === 'alert' && 'bg-destructive/20 text-destructive-foreground',
|
||||||
|
rb.triggerType === 'scheduled' && 'bg-primary/20 text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rb.triggerType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">{rb.maxRiskLevel}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
toggleAutoApproveMutation.mutate({
|
||||||
|
id: rb.id,
|
||||||
|
autoApprove: !rb.autoApprove,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
|
rb.autoApprove ? 'bg-primary' : 'bg-muted',
|
||||||
|
)}
|
||||||
|
title={rb.autoApprove ? 'Auto-approve enabled' : 'Auto-approve disabled'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
|
rb.autoApprove ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(rb)}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{deleteConfirmId === rb.id ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(rb.id)}
|
||||||
|
className="text-xs text-destructive-foreground bg-destructive px-2 py-0.5 rounded hover:bg-destructive/80"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(rb.id)}
|
||||||
|
className="text-xs text-destructive-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog Overlay */}
|
||||||
|
{dialogOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={closeDialog} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-card border rounded-lg shadow-xl mx-4">
|
||||||
|
<div className="flex items-center justify-between px-6 pt-6 pb-2">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{editingId ? 'Edit Runbook' : 'New Runbook'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={closeDialog}
|
||||||
|
className="text-muted-foreground hover:text-foreground text-xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="e.g. Disk Cleanup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||||
|
placeholder="Brief description of what this runbook does"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trigger Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Trigger Type</label>
|
||||||
|
<select
|
||||||
|
value={form.triggerType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, triggerType: e.target.value as RunbookFormData['triggerType'] })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{TRIGGER_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Template */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Prompt Template</label>
|
||||||
|
<textarea
|
||||||
|
value={form.promptTemplate}
|
||||||
|
onChange={(e) => setForm({ ...form, promptTemplate: e.target.value })}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||||
|
placeholder="Enter the runbook template instructions..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allowed Tools */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Allowed Tools</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.allowedTools}
|
||||||
|
onChange={(e) => setForm({ ...form, allowedTools: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="ssh, kubectl, docker (comma-separated)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Comma-separated list of tools the agent may use.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Risk Level */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Risk Level</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{RISK_LEVELS.map((rl) => (
|
||||||
|
<label key={rl.value} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="maxRiskLevel"
|
||||||
|
checked={form.maxRiskLevel === rl.value}
|
||||||
|
onChange={() => setForm({ ...form, maxRiskLevel: rl.value })}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
{rl.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-Approve */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoApprove}
|
||||||
|
onChange={(e) => setForm({ ...form, autoApprove: e.target.checked })}
|
||||||
|
className="accent-primary h-4 w-4"
|
||||||
|
/>
|
||||||
|
Auto-approve actions within risk level
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{(createMutation.error || updateMutation.error) && (
|
||||||
|
<p className="text-sm text-destructive-foreground">
|
||||||
|
{((createMutation.error || updateMutation.error) as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeDialog}
|
||||||
|
className="px-4 py-2 rounded-md border text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : editingId ? 'Update Runbook' : 'Create Runbook'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,846 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
authType: 'password' | 'ssh_key' | 'ssh_key_with_passphrase';
|
||||||
|
description: string;
|
||||||
|
associatedServers: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialsResponse {
|
||||||
|
data: Credential[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialFormData {
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
authType: 'password' | 'ssh_key' | 'ssh_key_with_passphrase';
|
||||||
|
password: string;
|
||||||
|
privateKey: string;
|
||||||
|
passphrase: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const AUTH_TYPE_LABELS: Record<Credential['authType'], string> = {
|
||||||
|
password: 'Password',
|
||||||
|
ssh_key: 'SSH Key',
|
||||||
|
ssh_key_with_passphrase: 'SSH Key + Passphrase',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: CredentialFormData = {
|
||||||
|
name: '',
|
||||||
|
username: '',
|
||||||
|
authType: 'password',
|
||||||
|
password: '',
|
||||||
|
privateKey: '',
|
||||||
|
passphrase: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_EDIT_FORM: EditFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth type badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AuthTypeBadge({ authType }: { authType: Credential['authType'] }) {
|
||||||
|
const styles: Record<Credential['authType'], string> = {
|
||||||
|
password: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
ssh_key: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
ssh_key_with_passphrase: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
styles[authType],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{AUTH_TYPE_LABELS[authType]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add credential dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AddCredentialDialog({
|
||||||
|
open,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
form: CredentialFormData;
|
||||||
|
errors: Partial<Record<keyof CredentialFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof CredentialFormData, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Add Credential</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="Production SSH Key"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* username */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Username <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => onChange('username', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.username ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="root"
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.username}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* auth type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Auth Type</label>
|
||||||
|
<select
|
||||||
|
value={form.authType}
|
||||||
|
onChange={(e) => onChange('authType', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="ssh_key">SSH Key</option>
|
||||||
|
<option value="ssh_key_with_passphrase">SSH Key with Passphrase</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* password field */}
|
||||||
|
{form.authType === 'password' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Password <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => onChange('password', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.password ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* private key field */}
|
||||||
|
{(form.authType === 'ssh_key' || form.authType === 'ssh_key_with_passphrase') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Private Key <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.privateKey}
|
||||||
|
onChange={(e) => onChange('privateKey', e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm font-mono resize-none',
|
||||||
|
errors.privateKey ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="-----BEGIN RSA PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
{errors.privateKey && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.privateKey}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* passphrase field */}
|
||||||
|
{form.authType === 'ssh_key_with_passphrase' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Passphrase <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.passphrase}
|
||||||
|
onChange={(e) => onChange('passphrase', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.passphrase ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="Enter passphrase"
|
||||||
|
/>
|
||||||
|
{errors.passphrase && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.passphrase}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit credential dialog (name/description only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EditCredentialDialog({
|
||||||
|
open,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
form: EditFormData;
|
||||||
|
errors: Partial<Record<keyof EditFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof EditFormData, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Edit Credential</h2>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mb-4 p-3 bg-muted rounded-md">
|
||||||
|
For security reasons, credential values (passwords and private keys) cannot be modified.
|
||||||
|
To change authentication details, delete and recreate the credential.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="Credential name"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
credentialName,
|
||||||
|
associatedServers,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
credentialName: string;
|
||||||
|
associatedServers: number;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Credential</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Are you sure you want to delete <strong>{credentialName}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{associatedServers > 0 && (
|
||||||
|
<div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-sm text-destructive">
|
||||||
|
<strong>Warning:</strong> This credential is currently associated with{' '}
|
||||||
|
<strong>{associatedServers}</strong> server{associatedServers !== 1 ? 's' : ''}.
|
||||||
|
Deleting it will remove the credential from those servers and may prevent SSH connections.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
|
||||||
|
Encrypted credential data will be permanently destroyed and cannot be recovered.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function CredentialsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [editingCredential, setEditingCredential] = useState<Credential | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Credential | null>(null);
|
||||||
|
const [form, setForm] = useState<CredentialFormData>({ ...EMPTY_FORM });
|
||||||
|
const [editForm, setEditForm] = useState<EditFormData>({ ...EMPTY_EDIT_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof CredentialFormData, string>>>({});
|
||||||
|
const [editErrors, setEditErrors] = useState<Partial<Record<keyof EditFormData, string>>>({});
|
||||||
|
const [testingId, setTestingId] = useState<string | null>(null);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.credentials.list(),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<CredentialsResponse>('/api/v1/inventory/credentials'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const credentials = data?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<Credential>('/api/v1/inventory/credentials', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.credentials.all });
|
||||||
|
closeAddDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<Credential>(`/api/v1/inventory/credentials/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.credentials.all });
|
||||||
|
closeEditDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/inventory/credentials/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.credentials.all });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<{ success: boolean; message: string }>(
|
||||||
|
`/api/v1/inventory/credentials/${id}/test`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
|
onSuccess: (result, id) => {
|
||||||
|
setTestResults((prev) => ({ ...prev, [id]: result }));
|
||||||
|
setTestingId(null);
|
||||||
|
},
|
||||||
|
onError: (err: Error, id) => {
|
||||||
|
setTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: { success: false, message: err.message },
|
||||||
|
}));
|
||||||
|
setTestingId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validateAdd = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof CredentialFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
if (!form.username.trim()) next.username = 'Username is required';
|
||||||
|
|
||||||
|
if (form.authType === 'password') {
|
||||||
|
if (!form.password.trim()) next.password = 'Password is required';
|
||||||
|
}
|
||||||
|
if (form.authType === 'ssh_key' || form.authType === 'ssh_key_with_passphrase') {
|
||||||
|
if (!form.privateKey.trim()) next.privateKey = 'Private key is required';
|
||||||
|
}
|
||||||
|
if (form.authType === 'ssh_key_with_passphrase') {
|
||||||
|
if (!form.passphrase.trim()) next.passphrase = 'Passphrase is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const validateEdit = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof EditFormData, string>> = {};
|
||||||
|
if (!editForm.name.trim()) next.name = 'Name is required';
|
||||||
|
setEditErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [editForm]);
|
||||||
|
|
||||||
|
const closeAddDialog = useCallback(() => {
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeEditDialog = useCallback(() => {
|
||||||
|
setEditingCredential(null);
|
||||||
|
setEditForm({ ...EMPTY_EDIT_FORM });
|
||||||
|
setEditErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setAddDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((credential: Credential) => {
|
||||||
|
setEditingCredential(credential);
|
||||||
|
setEditForm({
|
||||||
|
name: credential.name,
|
||||||
|
description: credential.description ?? '',
|
||||||
|
});
|
||||||
|
setEditErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddChange = useCallback(
|
||||||
|
(field: keyof CredentialFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditChange = useCallback(
|
||||||
|
(field: keyof EditFormData, value: string) => {
|
||||||
|
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setEditErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddSubmit = useCallback(() => {
|
||||||
|
if (!validateAdd()) return;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
authType: form.authType,
|
||||||
|
description: form.description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.authType === 'password') {
|
||||||
|
body.password = form.password;
|
||||||
|
}
|
||||||
|
if (form.authType === 'ssh_key' || form.authType === 'ssh_key_with_passphrase') {
|
||||||
|
body.privateKey = form.privateKey;
|
||||||
|
}
|
||||||
|
if (form.authType === 'ssh_key_with_passphrase') {
|
||||||
|
body.passphrase = form.passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}, [form, validateAdd, createMutation]);
|
||||||
|
|
||||||
|
const handleEditSubmit = useCallback(() => {
|
||||||
|
if (!validateEdit()) return;
|
||||||
|
if (!editingCredential) return;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
description: editForm.description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMutation.mutate({ id: editingCredential.id, body });
|
||||||
|
}, [editForm, editingCredential, validateEdit, updateMutation]);
|
||||||
|
|
||||||
|
const handleTestConnection = useCallback(
|
||||||
|
(credential: Credential) => {
|
||||||
|
setTestingId(credential.id);
|
||||||
|
setTestResults((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[credential.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
testMutation.mutate(credential.id);
|
||||||
|
},
|
||||||
|
[testMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = useCallback((dateString: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAddSaving = createMutation.isPending;
|
||||||
|
const isEditSaving = updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Credentials</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage SSH credentials for server connections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Credential
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security notice banner */}
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
Credentials are encrypted with AES-256-GCM. Private keys and passwords are never exposed after creation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load credentials: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading credentials...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Type</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Username</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Associated Servers</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{credentials.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No credentials found. Add one to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
credentials.map((credential) => (
|
||||||
|
<tr
|
||||||
|
key={credential.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{credential.name}</span>
|
||||||
|
{credential.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[200px]">
|
||||||
|
{credential.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<AuthTypeBadge authType={credential.authType} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">
|
||||||
|
{credential.username}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{credential.associatedServers} server{credential.associatedServers !== 1 ? 's' : ''}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{formatDate(credential.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Test connection result */}
|
||||||
|
{testResults[credential.id] && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full',
|
||||||
|
testResults[credential.id].success
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
)}
|
||||||
|
title={testResults[credential.id].message}
|
||||||
|
>
|
||||||
|
{testResults[credential.id].success ? 'OK' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestConnection(credential)}
|
||||||
|
disabled={testingId === credential.id}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testingId === credential.id ? 'Testing...' : 'Test'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(credential)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(credential)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add credential dialog */}
|
||||||
|
<AddCredentialDialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isAddSaving}
|
||||||
|
onClose={closeAddDialog}
|
||||||
|
onChange={handleAddChange}
|
||||||
|
onSubmit={handleAddSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit credential dialog */}
|
||||||
|
<EditCredentialDialog
|
||||||
|
open={!!editingCredential}
|
||||||
|
form={editForm}
|
||||||
|
errors={editErrors}
|
||||||
|
saving={isEditSaving}
|
||||||
|
onClose={closeEditDialog}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
onSubmit={handleEditSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
credentialName={deleteTarget?.name ?? ''}
|
||||||
|
associatedServers={deleteTarget?.associatedServers ?? 0}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
resource: string;
|
||||||
|
action: 'create' | 'read' | 'update' | 'delete' | 'execute';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionMatrixEntry {
|
||||||
|
roleId: string;
|
||||||
|
permissionId: string;
|
||||||
|
granted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionsResponse {
|
||||||
|
data: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixResponse {
|
||||||
|
roles: Role[];
|
||||||
|
permissions: Permission[];
|
||||||
|
matrix: PermissionMatrixEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<Permission['action'], string> = {
|
||||||
|
create: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
read: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
update: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
delete: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
execute: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ActionBadge({ action }: { action: Permission['action'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
ACTION_COLORS[action],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permission info dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PermissionInfoDialog({
|
||||||
|
open,
|
||||||
|
permission,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
permission: Permission | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open || !permission) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Permission Details</h2>
|
||||||
|
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Key</dt>
|
||||||
|
<dd className="text-sm font-medium font-mono mt-0.5">{permission.key}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Resource</dt>
|
||||||
|
<dd className="text-sm font-medium capitalize mt-0.5">{permission.resource}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Action</dt>
|
||||||
|
<dd className="mt-0.5">
|
||||||
|
<ActionBadge action={permission.action} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Description</dt>
|
||||||
|
<dd className="text-sm mt-0.5">{permission.description || '--'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function PermissionsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [selectedPermission, setSelectedPermission] = useState<Permission | null>(null);
|
||||||
|
const [resourceFilter, setResourceFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data: matrixData, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.permissions.matrix(),
|
||||||
|
queryFn: () => apiClient<MatrixResponse>('/api/v1/auth/permissions/matrix'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = matrixData?.roles ?? [];
|
||||||
|
const permissions = matrixData?.permissions ?? [];
|
||||||
|
const matrixEntries = matrixData?.matrix ?? [];
|
||||||
|
|
||||||
|
// Build lookup map: `${roleId}:${permissionId}` -> granted
|
||||||
|
const grantedMap = useMemo(() => {
|
||||||
|
const map = new Map<string, boolean>();
|
||||||
|
for (const entry of matrixEntries) {
|
||||||
|
map.set(`${entry.roleId}:${entry.permissionId}`, entry.granted);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [matrixEntries]);
|
||||||
|
|
||||||
|
// Extract unique resources
|
||||||
|
const resources = useMemo(() => {
|
||||||
|
const set = new Set(permissions.map((p) => p.resource));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
// Filter permissions by resource
|
||||||
|
const filteredPermissions = useMemo(() => {
|
||||||
|
if (resourceFilter === 'all') return permissions;
|
||||||
|
return permissions.filter((p) => p.resource === resourceFilter);
|
||||||
|
}, [permissions, resourceFilter]);
|
||||||
|
|
||||||
|
// Group filtered permissions by resource for display
|
||||||
|
const groupedPermissions = useMemo(() => {
|
||||||
|
const groups: Record<string, Permission[]> = {};
|
||||||
|
for (const perm of filteredPermissions) {
|
||||||
|
const resource = perm.resource || 'general';
|
||||||
|
if (!groups[resource]) groups[resource] = [];
|
||||||
|
groups[resource].push(perm);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [filteredPermissions]);
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
roleId,
|
||||||
|
permissionId,
|
||||||
|
grant,
|
||||||
|
}: {
|
||||||
|
roleId: string;
|
||||||
|
permissionId: string;
|
||||||
|
grant: boolean;
|
||||||
|
}) =>
|
||||||
|
apiClient<void>('/api/v1/auth/permissions/matrix', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { roleId, permissionId, grant },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const isGranted = useCallback(
|
||||||
|
(roleId: string, permissionId: string): boolean => {
|
||||||
|
return grantedMap.get(`${roleId}:${permissionId}`) ?? false;
|
||||||
|
},
|
||||||
|
[grantedMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(roleId: string, permissionId: string) => {
|
||||||
|
const currentlyGranted = isGranted(roleId, permissionId);
|
||||||
|
toggleMutation.mutate({
|
||||||
|
roleId,
|
||||||
|
permissionId,
|
||||||
|
grant: !currentlyGranted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isGranted, toggleMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Permissions</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
View and manage the permission matrix across roles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resource filter */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||||
|
<span className="text-sm text-muted-foreground">Resource:</span>
|
||||||
|
<div className="flex gap-1 p-1 bg-muted rounded-lg flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setResourceFilter('all')}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
resourceFilter === 'all'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<button
|
||||||
|
key={resource}
|
||||||
|
onClick={() => setResourceFilter(resource)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors capitalize',
|
||||||
|
resourceFilter === resource
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resource}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load permissions: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading permissions matrix...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permission matrix table - grouped by resource */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.keys(groupedPermissions).length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
No permissions found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(groupedPermissions).map(([resource, perms]) => (
|
||||||
|
<div key={resource} className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||||
|
<h3 className="text-sm font-semibold capitalize">{resource}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-3 font-medium min-w-[200px]">
|
||||||
|
Permission
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Action</th>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<th
|
||||||
|
key={role.id}
|
||||||
|
className="text-center px-3 py-3 font-medium whitespace-nowrap min-w-[100px]"
|
||||||
|
>
|
||||||
|
<span className="capitalize">{role.name}</span>
|
||||||
|
{role.isSystem && (
|
||||||
|
<span className="block text-[10px] text-muted-foreground font-normal">
|
||||||
|
built-in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{perms.map((perm) => (
|
||||||
|
<tr
|
||||||
|
key={perm.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPermission(perm)}
|
||||||
|
className="text-left hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-sm">{perm.key}</span>
|
||||||
|
{perm.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[250px]">
|
||||||
|
{perm.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ActionBadge action={perm.action} />
|
||||||
|
</td>
|
||||||
|
{roles.map((role) => {
|
||||||
|
const granted = isGranted(role.id, perm.id);
|
||||||
|
return (
|
||||||
|
<td key={role.id} className="text-center px-3 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={granted}
|
||||||
|
onChange={() => handleToggle(role.id, perm.id)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
className="h-4 w-4 rounded border-input text-primary focus:ring-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{!isLoading && !error && permissions.length > 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-muted/30 rounded-lg border">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Summary</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Permissions</p>
|
||||||
|
<p className="text-lg font-bold">{permissions.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Roles</p>
|
||||||
|
<p className="text-lg font-bold">{roles.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Resources</p>
|
||||||
|
<p className="text-lg font-bold">{resources.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Granted</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{matrixEntries.filter((e) => e.granted).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Permission info dialog */}
|
||||||
|
<PermissionInfoDialog
|
||||||
|
open={!!selectedPermission}
|
||||||
|
permission={selectedPermission}
|
||||||
|
onClose={() => setSelectedPermission(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,681 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RiskRule {
|
||||||
|
id: string;
|
||||||
|
pattern: string;
|
||||||
|
riskLevel: 0 | 1 | 2 | 3;
|
||||||
|
action: 'allow' | 'block' | 'require_approval';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiskRuleFormData {
|
||||||
|
pattern: string;
|
||||||
|
riskLevel: 0 | 1 | 2 | 3;
|
||||||
|
action: 'allow' | 'block' | 'require_approval';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiskRulesResponse {
|
||||||
|
data: RiskRule[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Role = 'admin' | 'operator' | 'viewer' | 'readonly';
|
||||||
|
type Permission =
|
||||||
|
| 'manage_servers'
|
||||||
|
| 'execute_commands'
|
||||||
|
| 'view_audit'
|
||||||
|
| 'manage_users'
|
||||||
|
| 'approve_commands';
|
||||||
|
|
||||||
|
type PermissionMatrix = Record<Role, Permission[]>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RISK_LEVELS: { value: RiskRule['riskLevel']; label: string; color: string }[] = [
|
||||||
|
{ value: 0, label: '0 - None', color: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300' },
|
||||||
|
{ value: 1, label: '1 - Low', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||||
|
{ value: 2, label: '2 - Medium', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||||
|
{ value: 3, label: '3 - High', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIONS: { value: RiskRule['action']; label: string }[] = [
|
||||||
|
{ value: 'allow', label: 'Allow' },
|
||||||
|
{ value: 'block', label: 'Block' },
|
||||||
|
{ value: 'require_approval', label: 'Require Approval' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLES: { value: Role; label: string }[] = [
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'operator', label: 'Operator' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
{ value: 'readonly', label: 'Read-only' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERMISSIONS: { value: Permission; label: string }[] = [
|
||||||
|
{ value: 'manage_servers', label: 'Manage Servers' },
|
||||||
|
{ value: 'execute_commands', label: 'Execute Commands' },
|
||||||
|
{ value: 'view_audit', label: 'View Audit' },
|
||||||
|
{ value: 'manage_users', label: 'Manage Users' },
|
||||||
|
{ value: 'approve_commands', label: 'Approve Commands' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_MATRIX: PermissionMatrix = {
|
||||||
|
admin: ['manage_servers', 'execute_commands', 'view_audit', 'manage_users', 'approve_commands'],
|
||||||
|
operator: ['execute_commands', 'view_audit'],
|
||||||
|
viewer: ['view_audit'],
|
||||||
|
readonly: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RULE_FORM: RiskRuleFormData = {
|
||||||
|
pattern: '',
|
||||||
|
riskLevel: 0,
|
||||||
|
action: 'allow',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Risk level badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RiskBadge({ level }: { level: RiskRule['riskLevel'] }) {
|
||||||
|
const meta = RISK_LEVELS.find((r) => r.value === level) ?? RISK_LEVELS[0];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
meta.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Level {level}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ActionBadge({ action }: { action: RiskRule['action'] }) {
|
||||||
|
const styles: Record<RiskRule['action'], string> = {
|
||||||
|
allow: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
block: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
require_approval:
|
||||||
|
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<RiskRule['action'], string> = {
|
||||||
|
allow: 'Allow',
|
||||||
|
block: 'Block',
|
||||||
|
require_approval: 'Require Approval',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
styles[action],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{labels[action]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rule form dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RuleDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: RiskRuleFormData;
|
||||||
|
errors: Partial<Record<keyof RiskRuleFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof RiskRuleFormData, value: string | number) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* pattern */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Pattern (regex) <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.pattern}
|
||||||
|
onChange={(e) => onChange('pattern', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm font-mono',
|
||||||
|
errors.pattern ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="^rm\s+-rf\s+/"
|
||||||
|
/>
|
||||||
|
{errors.pattern && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.pattern}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* riskLevel */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Risk Level</label>
|
||||||
|
<select
|
||||||
|
value={form.riskLevel}
|
||||||
|
onChange={(e) => onChange('riskLevel', parseInt(e.target.value, 10))}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{RISK_LEVELS.map((rl) => (
|
||||||
|
<option key={rl.value} value={rl.value}>
|
||||||
|
{rl.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* action */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Action</label>
|
||||||
|
<select
|
||||||
|
value={form.action}
|
||||||
|
onChange={(e) => onChange('action', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<option key={a.value} value={a.value}>
|
||||||
|
{a.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="Describe this rule..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteRuleDialog({
|
||||||
|
open,
|
||||||
|
pattern,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
pattern: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Rule</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete the rule with pattern{' '}
|
||||||
|
<code className="px-1 py-0.5 rounded bg-muted font-mono text-xs">{pattern}</code>?
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permission matrix section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PermissionMatrixSection({
|
||||||
|
matrix,
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
matrix: PermissionMatrix;
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: (role: Role, perm: Permission) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Permission Matrix</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Configure which roles have access to each permission.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Permissions'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||||
|
{PERMISSIONS.map((perm) => (
|
||||||
|
<th key={perm.value} className="text-center px-3 py-3 font-medium whitespace-nowrap">
|
||||||
|
{perm.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ROLES.map((role) => (
|
||||||
|
<tr
|
||||||
|
key={role.value}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium capitalize">{role.label}</td>
|
||||||
|
{PERMISSIONS.map((perm) => {
|
||||||
|
const checked = (matrix[role.value] ?? []).includes(perm.value);
|
||||||
|
return (
|
||||||
|
<td key={perm.value} className="text-center px-3 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggle(role.value, perm.value)}
|
||||||
|
className="h-4 w-4 rounded border-input text-primary focus:ring-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function RiskRulesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Risk rules state -----------------------------------------------------
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState<RiskRule | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<RiskRule | null>(null);
|
||||||
|
const [form, setForm] = useState<RiskRuleFormData>({ ...EMPTY_RULE_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof RiskRuleFormData, string>>>({});
|
||||||
|
|
||||||
|
// Permission matrix state ----------------------------------------------
|
||||||
|
const [matrix, setMatrix] = useState<PermissionMatrix>({ ...DEFAULT_MATRIX });
|
||||||
|
|
||||||
|
// Queries - risk rules -------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: rulesData,
|
||||||
|
isLoading: rulesLoading,
|
||||||
|
error: rulesError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.riskRules.list(),
|
||||||
|
queryFn: () => apiClient<RiskRulesResponse>('/api/v1/agent/risk-rules'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = rulesData?.data ?? [];
|
||||||
|
|
||||||
|
// Queries - permissions matrix -----------------------------------------
|
||||||
|
const { isLoading: matrixLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.permissions.matrix(),
|
||||||
|
queryFn: () => apiClient<PermissionMatrix>('/api/v1/agent/permissions'),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
select: (data: any) => data?.data ?? data,
|
||||||
|
placeholderData: DEFAULT_MATRIX,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations - risk rules -----------------------------------------------
|
||||||
|
const createRuleMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<RiskRule>('/api/v1/agent/risk-rules', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.riskRules.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRuleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<RiskRule>(`/api/v1/agent/risk-rules/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.riskRules.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRuleMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/agent/risk-rules/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.riskRules.all });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations - permissions matrix ----------------------------------------
|
||||||
|
const saveMatrixMutation = useMutation({
|
||||||
|
mutationFn: (body: PermissionMatrix) =>
|
||||||
|
apiClient<PermissionMatrix>('/api/v1/agent/permissions', { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers - rules -------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof RiskRuleFormData, string>> = {};
|
||||||
|
if (!form.pattern.trim()) next.pattern = 'Pattern is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
setForm({ ...EMPTY_RULE_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setForm({ ...EMPTY_RULE_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((rule: RiskRule) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
setForm({
|
||||||
|
pattern: rule.pattern,
|
||||||
|
riskLevel: rule.riskLevel,
|
||||||
|
action: rule.action,
|
||||||
|
description: rule.description ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof RiskRuleFormData, value: string | number) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
pattern: form.pattern.trim(),
|
||||||
|
riskLevel: form.riskLevel,
|
||||||
|
action: form.action,
|
||||||
|
description: form.description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRule) {
|
||||||
|
updateRuleMutation.mutate({ id: editingRule.id, body });
|
||||||
|
} else {
|
||||||
|
createRuleMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingRule, validate, createRuleMutation, updateRuleMutation]);
|
||||||
|
|
||||||
|
// Helpers - permissions -------------------------------------------------
|
||||||
|
const handleToggle = useCallback((role: Role, perm: Permission) => {
|
||||||
|
setMatrix((prev) => {
|
||||||
|
const current = prev[role] ?? [];
|
||||||
|
const has = current.includes(perm);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[role]: has ? current.filter((p) => p !== perm) : [...current, perm],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveMatrix = useCallback(() => {
|
||||||
|
saveMatrixMutation.mutate(matrix);
|
||||||
|
}, [matrix, saveMatrixMutation]);
|
||||||
|
|
||||||
|
const isRuleSaving = createRuleMutation.isPending || updateRuleMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Security & Risk Rules</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Configure command risk classification rules and security policies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Risk Rules Section ---- */}
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Command Risk Rules</h2>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{rulesError && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load risk rules: {(rulesError as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{rulesLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading risk rules...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rules table */}
|
||||||
|
{!rulesLoading && !rulesError && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Pattern</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Risk Level</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Action</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Description</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No risk rules configured.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rules.map((rule) => (
|
||||||
|
<tr
|
||||||
|
key={rule.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<code className="px-1.5 py-0.5 rounded bg-muted font-mono text-xs">
|
||||||
|
{rule.pattern}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<RiskBadge level={rule.riskLevel} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ActionBadge action={rule.action} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground max-w-xs truncate">
|
||||||
|
{rule.description || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(rule)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(rule)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Permission Matrix Section ---- */}
|
||||||
|
{matrixLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading permissions...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PermissionMatrixSection
|
||||||
|
matrix={matrix}
|
||||||
|
saving={saveMatrixMutation.isPending}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onSave={handleSaveMatrix}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit rule dialog */}
|
||||||
|
<RuleDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingRule ? 'Edit Rule' : 'Add Rule'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isRuleSaving}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteRuleDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
pattern={deleteTarget?.pattern ?? ''}
|
||||||
|
deleting={deleteRuleMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteRuleMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,658 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissionCount: number;
|
||||||
|
userCount: number;
|
||||||
|
isSystem: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RolesResponse {
|
||||||
|
data: Role[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RolePermissionsResponse {
|
||||||
|
data: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionsListResponse {
|
||||||
|
data: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMPTY_FORM: RoleFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SystemBadge({ isSystem }: { isSystem: boolean }) {
|
||||||
|
if (!isSystem) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Built-in
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add / Edit Role dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RoleDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: RoleFormData;
|
||||||
|
errors: Partial<Record<keyof RoleFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof RoleFormData, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="e.g. Operator"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Describe the role's purpose..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete role confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteRoleDialog({
|
||||||
|
open,
|
||||||
|
roleName,
|
||||||
|
userCount,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
roleName: string;
|
||||||
|
userCount: number;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Role</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Are you sure you want to delete <strong>{roleName}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{userCount > 0 && (
|
||||||
|
<div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-sm text-destructive">
|
||||||
|
<strong>Warning:</strong> This role is currently assigned to{' '}
|
||||||
|
<strong>{userCount}</strong> user{userCount !== 1 ? 's' : ''}.
|
||||||
|
Those users will lose the permissions granted by this role.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permissions panel (expandable per role)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PermissionsPanel({
|
||||||
|
roleId,
|
||||||
|
allPermissions,
|
||||||
|
rolePermissions,
|
||||||
|
saving,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
roleId: string;
|
||||||
|
allPermissions: Permission[];
|
||||||
|
rolePermissions: Permission[];
|
||||||
|
saving: boolean;
|
||||||
|
onToggle: (roleId: string, permissionId: string, checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const assignedIds = useMemo(
|
||||||
|
() => new Set(rolePermissions.map((p) => p.id)),
|
||||||
|
[rolePermissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const groups: Record<string, Permission[]> = {};
|
||||||
|
for (const perm of allPermissions) {
|
||||||
|
const resource = perm.resource || 'general';
|
||||||
|
if (!groups[resource]) groups[resource] = [];
|
||||||
|
groups[resource].push(perm);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [allPermissions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-4 bg-muted/20">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">Assigned Permissions</h3>
|
||||||
|
{Object.keys(grouped).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No permissions available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(grouped).map(([resource, perms]) => (
|
||||||
|
<div key={resource}>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
{resource}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
{perms.map((perm) => {
|
||||||
|
const checked = assignedIds.has(perm.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={perm.id}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md hover:bg-muted/40 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggle(roleId, perm.id, !checked)}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-4 w-4 mt-0.5 rounded border-input text-primary focus:ring-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{perm.key}</p>
|
||||||
|
{perm.description && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{perm.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saving && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Saving permissions...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
|
||||||
|
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<RoleFormData>({ ...EMPTY_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof RoleFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data: rolesData, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.roles.list(),
|
||||||
|
queryFn: () => apiClient<RolesResponse>('/api/v1/auth/roles'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = rolesData?.data ?? [];
|
||||||
|
|
||||||
|
// Fetch all permissions (for checkbox lists)
|
||||||
|
const { data: allPermsData } = useQuery({
|
||||||
|
queryKey: queryKeys.permissions.list(),
|
||||||
|
queryFn: () => apiClient<PermissionsListResponse>('/api/v1/auth/permissions'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPermissions = allPermsData?.data ?? [];
|
||||||
|
|
||||||
|
// Fetch permissions for expanded role
|
||||||
|
const { data: rolePermsData, isLoading: rolePermsLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.roles.permissions(expandedRoleId ?? ''),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<RolePermissionsResponse>(
|
||||||
|
`/api/v1/auth/roles/${expandedRoleId}/permissions`,
|
||||||
|
),
|
||||||
|
enabled: !!expandedRoleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rolePermissions = rolePermsData?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<Role>('/api/v1/auth/roles', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.roles.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<Role>(`/api/v1/auth/roles/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.roles.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/auth/roles/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.roles.all });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePermissionMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
roleId,
|
||||||
|
permissionId,
|
||||||
|
assign,
|
||||||
|
}: {
|
||||||
|
roleId: string;
|
||||||
|
permissionId: string;
|
||||||
|
assign: boolean;
|
||||||
|
}) =>
|
||||||
|
apiClient<void>(`/api/v1/auth/roles/${roleId}/permissions`, {
|
||||||
|
method: assign ? 'POST' : 'DELETE',
|
||||||
|
body: { permissionId },
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.roles.permissions(variables.roleId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.roles.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof RoleFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingRole(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((role: Role) => {
|
||||||
|
setEditingRole(role);
|
||||||
|
setForm({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof RoleFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRole) {
|
||||||
|
updateMutation.mutate({ id: editingRole.id, body });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingRole, validate, createMutation, updateMutation]);
|
||||||
|
|
||||||
|
const handleTogglePermission = useCallback(
|
||||||
|
(roleId: string, permissionId: string, assign: boolean) => {
|
||||||
|
togglePermissionMutation.mutate({ roleId, permissionId, assign });
|
||||||
|
},
|
||||||
|
[togglePermissionMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleExpand = useCallback(
|
||||||
|
(roleId: string) => {
|
||||||
|
setExpandedRoleId((prev) => (prev === roleId ? null : roleId));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Roles</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage roles and their associated permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load roles: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading roles...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Description</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Type</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Permissions</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Users</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No roles found. Add one to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
roles.map((role) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={role.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-medium">{role.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground max-w-xs truncate">
|
||||||
|
{role.description || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<SystemBadge isSystem={role.isSystem} />
|
||||||
|
{!role.isSystem && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{role.permissionCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{role.userCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{formatDate(role.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(role.id)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
{expandedRoleId === role.id ? 'Hide Perms' : 'Permissions'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(role)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{!role.isSystem && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(role)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded permissions row */}
|
||||||
|
{expandedRoleId === role.id && (
|
||||||
|
<tr key={`${role.id}-perms`}>
|
||||||
|
<td colSpan={7}>
|
||||||
|
{rolePermsLoading ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||||
|
Loading permissions...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PermissionsPanel
|
||||||
|
roleId={role.id}
|
||||||
|
allPermissions={allPermissions}
|
||||||
|
rolePermissions={rolePermissions}
|
||||||
|
saving={togglePermissionMutation.isPending}
|
||||||
|
onToggle={handleTogglePermission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit role dialog */}
|
||||||
|
<RoleDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingRole ? 'Edit Role' : 'Add Role'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isSaving}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteRoleDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
roleName={deleteTarget?.name ?? ''}
|
||||||
|
userCount={deleteTarget?.userCount ?? 0}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,974 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ServerDetail {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
host: string;
|
||||||
|
sshPort: number;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
role: string;
|
||||||
|
status: 'online' | 'offline' | 'maintenance';
|
||||||
|
tags: string[];
|
||||||
|
description: string;
|
||||||
|
jumpServerId?: string;
|
||||||
|
jumpServerName?: string;
|
||||||
|
credentialId?: string;
|
||||||
|
credentialName?: string;
|
||||||
|
os?: string;
|
||||||
|
cpuCores?: number;
|
||||||
|
memoryGb?: number;
|
||||||
|
region?: string;
|
||||||
|
cloudProvider?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerFormData {
|
||||||
|
hostname: string;
|
||||||
|
host: string;
|
||||||
|
sshPort: number;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
role: string;
|
||||||
|
tags: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthCheck {
|
||||||
|
id: string;
|
||||||
|
serverId: string;
|
||||||
|
status: 'healthy' | 'unhealthy' | 'degraded' | 'timeout';
|
||||||
|
latencyMs: number;
|
||||||
|
message?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentCommand {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
riskLevel: 'L0' | 'L1' | 'L2' | 'L3';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthChecksResponse {
|
||||||
|
data: HealthCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentCommandsResponse {
|
||||||
|
data: RecentCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: ServerDetail['status'] }) {
|
||||||
|
const styles: Record<ServerDetail['status'], string> = {
|
||||||
|
online: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
offline: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
maintenance: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health check status badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HealthStatusBadge({ status }: { status: HealthCheck['status'] }) {
|
||||||
|
const styles: Record<HealthCheck['status'], string> = {
|
||||||
|
healthy: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
unhealthy: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
degraded: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
timeout: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Risk level badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RiskBadge({ level }: { level: RecentCommand['riskLevel'] }) {
|
||||||
|
const styles: Record<RecentCommand['riskLevel'], string> = {
|
||||||
|
L0: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
L1: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
L2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
L3: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
styles[level],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
hostname,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
hostname: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Server</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{hostname}</strong>? This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: format date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Info row component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 py-2">
|
||||||
|
<dt className="text-sm text-muted-foreground sm:w-36 shrink-0">{label}</dt>
|
||||||
|
<dd className="text-sm font-medium">{value || '--'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function ServerDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<ServerFormData>({
|
||||||
|
hostname: '',
|
||||||
|
host: '',
|
||||||
|
sshPort: 22,
|
||||||
|
environment: 'dev',
|
||||||
|
role: '',
|
||||||
|
tags: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof ServerFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: server,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.servers.detail(id),
|
||||||
|
queryFn: () => apiClient<ServerDetail>(`/api/v1/inventory/servers/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: healthData } = useQuery({
|
||||||
|
queryKey: queryKeys.healthChecks.list({ serverId: id, limit: '5' }),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<HealthChecksResponse>(
|
||||||
|
`/api/v1/monitor/health-checks?serverId=${id}&limit=5`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: commandsData } = useQuery({
|
||||||
|
queryKey: ['commands', 'server', id],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<RecentCommandsResponse>(
|
||||||
|
`/api/v1/agent/commands?serverId=${id}&limit=10`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const healthChecks = healthData?.data ?? [];
|
||||||
|
const recentCommands = commandsData?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<ServerDetail>(`/api/v1/inventory/servers/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||||
|
setIsEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/inventory/servers/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||||
|
router.push('/servers');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runHealthCheckMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/monitor/health-checks/${id}/run`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.healthChecks.list({ serverId: id, limit: '5' }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
if (!server) return;
|
||||||
|
setForm({
|
||||||
|
hostname: server.hostname,
|
||||||
|
host: server.host,
|
||||||
|
sshPort: server.sshPort,
|
||||||
|
environment: server.environment,
|
||||||
|
role: server.role ?? '',
|
||||||
|
tags: (server.tags ?? []).join(', '),
|
||||||
|
description: server.description ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setIsEditing(true);
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const cancelEditing = useCallback(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof ServerFormData, value: string | number) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof ServerFormData, string>> = {};
|
||||||
|
if (!form.hostname.trim()) next.hostname = 'Hostname is required';
|
||||||
|
if (!form.host.trim()) next.host = 'Host is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
hostname: form.hostname.trim(),
|
||||||
|
host: form.host.trim(),
|
||||||
|
sshPort: form.sshPort,
|
||||||
|
environment: form.environment,
|
||||||
|
role: form.role.trim(),
|
||||||
|
tags: form.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
description: form.description.trim(),
|
||||||
|
};
|
||||||
|
updateMutation.mutate(body);
|
||||||
|
}, [form, validate, updateMutation]);
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/servers')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Servers
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading server details...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/servers')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Servers
|
||||||
|
</button>
|
||||||
|
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load server: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
const sshConnectionString = `ssh ${server.host} -p ${server.sshPort}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/servers')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Servers
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{server.hostname}</h1>
|
||||||
|
<StatusBadge status={server.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Server Information Card */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Server Information</h2>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
/* ---------- Edit form ---------- */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* hostname */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Hostname <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.hostname}
|
||||||
|
onChange={(e) => handleChange('hostname', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.hostname ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="web-server-01"
|
||||||
|
/>
|
||||||
|
{errors.hostname && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.hostname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* host */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Host (IP) <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => handleChange('host', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.host ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
/>
|
||||||
|
{errors.host && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.host}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* sshPort */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">SSH Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.sshPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('sshPort', parseInt(e.target.value, 10) || 22)
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* environment */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Environment</label>
|
||||||
|
<select
|
||||||
|
value={form.environment}
|
||||||
|
onChange={(e) => handleChange('environment', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="dev">dev</option>
|
||||||
|
<option value="staging">staging</option>
|
||||||
|
<option value="prod">prod</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => handleChange('role', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="web, db, cache..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tags</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(e) => handleChange('tags', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="linux, ubuntu, docker (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update error */}
|
||||||
|
{updateMutation.isError && (
|
||||||
|
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to update server: {(updateMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ---------- Read-only info display ---------- */
|
||||||
|
<dl className="divide-y">
|
||||||
|
<InfoRow label="Hostname" value={server.hostname} />
|
||||||
|
<InfoRow
|
||||||
|
label="Host IP"
|
||||||
|
value={
|
||||||
|
<span className="font-mono text-xs">{server.host}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow label="SSH Port" value={String(server.sshPort)} />
|
||||||
|
<InfoRow
|
||||||
|
label="Environment"
|
||||||
|
value={
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">
|
||||||
|
{server.environment}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Role" value={server.role} />
|
||||||
|
<InfoRow label="OS" value={server.os} />
|
||||||
|
{server.cpuCores != null && (
|
||||||
|
<InfoRow label="CPU Cores" value={String(server.cpuCores)} />
|
||||||
|
)}
|
||||||
|
{server.memoryGb != null && (
|
||||||
|
<InfoRow label="Memory" value={`${server.memoryGb} GB`} />
|
||||||
|
)}
|
||||||
|
{server.region && <InfoRow label="Region" value={server.region} />}
|
||||||
|
{server.cloudProvider && (
|
||||||
|
<InfoRow label="Cloud Provider" value={server.cloudProvider} />
|
||||||
|
)}
|
||||||
|
<InfoRow
|
||||||
|
label="Tags"
|
||||||
|
value={
|
||||||
|
server.tags && server.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{server.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Description" value={server.description} />
|
||||||
|
<InfoRow label="Created" value={formatDate(server.createdAt)} />
|
||||||
|
<InfoRow label="Updated" value={formatDate(server.updatedAt)} />
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Health Checks */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Recent Health Checks</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => runHealthCheckMutation.mutate()}
|
||||||
|
disabled={runHealthCheckMutation.isPending}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{runHealthCheckMutation.isPending ? 'Running...' : 'Run Now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runHealthCheckMutation.isError && (
|
||||||
|
<div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Health check failed: {(runHealthCheckMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runHealthCheckMutation.isSuccess && (
|
||||||
|
<div className="p-3 mb-4 rounded-md border border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400 text-sm">
|
||||||
|
Health check initiated successfully.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{healthChecks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No health checks recorded yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Status</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Latency</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Message</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{healthChecks.map((hc) => (
|
||||||
|
<tr
|
||||||
|
key={hc.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<HealthStatusBadge status={hc.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{hc.latencyMs}ms
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{hc.message || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
|
||||||
|
{formatRelative(hc.createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Commands */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Recent Commands</h2>
|
||||||
|
|
||||||
|
{recentCommands.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No commands executed on this server yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Command</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Exit Code</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Risk</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentCommands.map((cmd) => (
|
||||||
|
<tr
|
||||||
|
key={cmd.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded max-w-[300px] truncate inline-block">
|
||||||
|
{cmd.command}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-xs',
|
||||||
|
cmd.exitCode === 0
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: cmd.exitCode !== null
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cmd.exitCode !== null ? cmd.exitCode : '--'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<RiskBadge level={cmd.riskLevel} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
|
||||||
|
{formatRelative(cmd.createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/terminal?serverId=${server.id}`)}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="4 17 10 11 4 5" />
|
||||||
|
<line x1="12" x2="20" y1="19" y2="19" />
|
||||||
|
</svg>
|
||||||
|
Open Terminal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => runHealthCheckMutation.mutate()}
|
||||||
|
disabled={runHealthCheckMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
{runHealthCheckMutation.isPending ? 'Running...' : 'Run Health Check'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
Edit Server
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
<line x1="10" x2="10" y1="11" y2="17" />
|
||||||
|
<line x1="14" x2="14" y1="11" y2="17" />
|
||||||
|
</svg>
|
||||||
|
Delete Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Info */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Connection Info</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">SSH Connection</p>
|
||||||
|
<div className="font-mono text-xs bg-muted p-2 rounded select-all">
|
||||||
|
{sshConnectionString}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{server.jumpServerName && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Jump Server</p>
|
||||||
|
<p className="text-sm font-medium">{server.jumpServerName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{server.credentialName && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Credential</p>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/credentials?highlight=${server.credentialId}`)
|
||||||
|
}
|
||||||
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{server.credentialName}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!server.jumpServerName && !server.credentialName && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No jump server or credential configured.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Tags</h2>
|
||||||
|
{server.tags && server.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{server.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-muted"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No tags assigned.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
hostname={server.hostname}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,626 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Cluster {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
tags: string[];
|
||||||
|
serverIds: string[];
|
||||||
|
serverCount: number;
|
||||||
|
healthySummary: { online: number; offline: number; maintenance: number };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
host: string;
|
||||||
|
status: 'online' | 'offline' | 'maintenance';
|
||||||
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClustersResponse {
|
||||||
|
data: Cluster[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServersResponse {
|
||||||
|
data: Server[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClusterFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
tags: string;
|
||||||
|
serverIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMPTY_FORM: ClusterFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
environment: 'dev',
|
||||||
|
tags: '',
|
||||||
|
serverIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLUSTER_QUERY_KEY = ['clusters'] as const;
|
||||||
|
|
||||||
|
const ENV_COLORS: Record<string, string> = {
|
||||||
|
dev: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
staging: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
prod: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EnvironmentBadge({ environment }: { environment: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
ENV_COLORS[environment] ?? 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{environment}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health summary dots
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HealthSummary({
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
summary: Cluster['healthySummary'];
|
||||||
|
}) {
|
||||||
|
const parts: { count: number; label: string; color: string }[] = [];
|
||||||
|
if (summary.online > 0) {
|
||||||
|
parts.push({ count: summary.online, label: 'online', color: 'bg-green-500' });
|
||||||
|
}
|
||||||
|
if (summary.offline > 0) {
|
||||||
|
parts.push({ count: summary.offline, label: 'offline', color: 'bg-red-500' });
|
||||||
|
}
|
||||||
|
if (summary.maintenance > 0) {
|
||||||
|
parts.push({ count: summary.maintenance, label: 'maintenance', color: 'bg-yellow-500' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">No servers</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<span key={part.label} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<span className={cn('w-2 h-2 rounded-full', part.color)} />
|
||||||
|
{part.count} {part.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cluster form dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ClusterDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
servers,
|
||||||
|
serversLoading,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onToggleServer,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: ClusterFormData;
|
||||||
|
errors: Partial<Record<keyof ClusterFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
servers: Server[];
|
||||||
|
serversLoading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof ClusterFormData, value: string) => void;
|
||||||
|
onToggleServer: (serverId: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="Production Web Tier"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description for this cluster..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* environment */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Environment</label>
|
||||||
|
<select
|
||||||
|
value={form.environment}
|
||||||
|
onChange={(e) => onChange('environment', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="dev">dev</option>
|
||||||
|
<option value="staging">staging</option>
|
||||||
|
<option value="prod">prod</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tags</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(e) => onChange('tags', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="web, nginx, load-balanced (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* server selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Servers</label>
|
||||||
|
{serversLoading ? (
|
||||||
|
<div className="text-xs text-muted-foreground py-4 text-center">
|
||||||
|
Loading servers...
|
||||||
|
</div>
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground py-4 text-center">
|
||||||
|
No servers available.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[200px] overflow-y-auto border border-input rounded-md">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<label
|
||||||
|
key={server.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 hover:bg-muted/30 cursor-pointer border-b last:border-b-0 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.serverIds.includes(server.id)}
|
||||||
|
onChange={() => onToggleServer(server.id)}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium">{server.hostname}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 font-mono">
|
||||||
|
{server.host}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full flex-shrink-0',
|
||||||
|
server.status === 'online'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: server.status === 'offline'
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-yellow-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.serverIds.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{form.serverIds.length} server{form.serverIds.length !== 1 ? 's' : ''} selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
clusterName,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
clusterName: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Cluster</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{clusterName}</strong>? This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function ClustersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingCluster, setEditingCluster] = useState<Cluster | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Cluster | null>(null);
|
||||||
|
const [form, setForm] = useState<ClusterFormData>({ ...EMPTY_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof ClusterFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: CLUSTER_QUERY_KEY,
|
||||||
|
queryFn: () => apiClient<ClustersResponse>('/api/v1/inventory/clusters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusters = data?.data ?? [];
|
||||||
|
|
||||||
|
// Fetch servers for the dialog server selection
|
||||||
|
const { data: serversData, isLoading: serversLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.servers.list(),
|
||||||
|
queryFn: () => apiClient<ServersResponse>('/api/v1/inventory/servers'),
|
||||||
|
enabled: dialogOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const servers = serversData?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<Cluster>('/api/v1/inventory/clusters', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: CLUSTER_QUERY_KEY });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<Cluster>(`/api/v1/inventory/clusters/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: CLUSTER_QUERY_KEY });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/inventory/clusters/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: CLUSTER_QUERY_KEY });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof ClusterFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingCluster(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingCluster(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((cluster: Cluster) => {
|
||||||
|
setEditingCluster(cluster);
|
||||||
|
setForm({
|
||||||
|
name: cluster.name,
|
||||||
|
description: cluster.description ?? '',
|
||||||
|
environment: cluster.environment,
|
||||||
|
tags: (cluster.tags ?? []).join(', '),
|
||||||
|
serverIds: cluster.serverIds ?? [],
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof ClusterFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleServer = useCallback((serverId: string) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const exists = prev.serverIds.includes(serverId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
serverIds: exists
|
||||||
|
? prev.serverIds.filter((id) => id !== serverId)
|
||||||
|
: [...prev.serverIds, serverId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
environment: form.environment,
|
||||||
|
tags: form.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
serverIds: form.serverIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCluster) {
|
||||||
|
updateMutation.mutate({ id: editingCluster.id, body });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingCluster, validate, createMutation, updateMutation]);
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Clusters</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Organize servers into logical groups
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Cluster
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load clusters: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading clusters...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && !error && clusters.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">No clusters found.</p>
|
||||||
|
<p className="text-xs mt-1">Create your first cluster to organize servers into logical groups.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cluster cards grid */}
|
||||||
|
{!isLoading && !error && clusters.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{clusters.map((cluster) => (
|
||||||
|
<div
|
||||||
|
key={cluster.id}
|
||||||
|
className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Card header: name + environment */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h3 className="font-semibold text-base truncate">{cluster.name}</h3>
|
||||||
|
<EnvironmentBadge environment={cluster.environment} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{cluster.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
|
||||||
|
{cluster.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server count badge */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="inline-flex items-center bg-muted px-2 py-0.5 rounded text-xs font-medium">
|
||||||
|
{cluster.serverCount} server{cluster.serverCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{cluster.tags && cluster.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{cluster.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/60 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Health summary */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<HealthSummary summary={cluster.healthySummary} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-3 border-t">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(cluster)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(cluster)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<ClusterDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingCluster ? 'Edit Cluster' : 'Add Cluster'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isSaving}
|
||||||
|
servers={servers}
|
||||||
|
serversLoading={serversLoading}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onToggleServer={handleToggleServer}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
clusterName={deleteTarget?.name ?? ''}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,571 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
host: string;
|
||||||
|
sshPort: number;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
role: string;
|
||||||
|
status: 'online' | 'offline' | 'maintenance';
|
||||||
|
tags: string[];
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerFormData {
|
||||||
|
hostname: string;
|
||||||
|
host: string;
|
||||||
|
sshPort: number;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
role: string;
|
||||||
|
tags: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServersResponse {
|
||||||
|
data: Server[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnvironmentFilter = 'all' | 'dev' | 'staging' | 'prod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ENVIRONMENTS: { label: string; value: EnvironmentFilter }[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Dev', value: 'dev' },
|
||||||
|
{ label: 'Staging', value: 'staging' },
|
||||||
|
{ label: 'Prod', value: 'prod' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FORM: ServerFormData = {
|
||||||
|
hostname: '',
|
||||||
|
host: '',
|
||||||
|
sshPort: 22,
|
||||||
|
environment: 'dev',
|
||||||
|
role: '',
|
||||||
|
tags: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: Server['status'] }) {
|
||||||
|
const styles: Record<Server['status'], string> = {
|
||||||
|
online: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
offline: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
maintenance: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server form dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ServerDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
form: ServerFormData;
|
||||||
|
errors: Partial<Record<keyof ServerFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof ServerFormData, value: string | number) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* hostname */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Hostname <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.hostname}
|
||||||
|
onChange={(e) => onChange('hostname', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.hostname ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="web-server-01"
|
||||||
|
/>
|
||||||
|
{errors.hostname && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.hostname}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* host */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Host (IP) <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => onChange('host', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.host ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
/>
|
||||||
|
{errors.host && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.host}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* sshPort */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">SSH Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.sshPort}
|
||||||
|
onChange={(e) => onChange('sshPort', parseInt(e.target.value, 10) || 22)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* environment */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Environment</label>
|
||||||
|
<select
|
||||||
|
value={form.environment}
|
||||||
|
onChange={(e) => onChange('environment', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="dev">dev</option>
|
||||||
|
<option value="staging">staging</option>
|
||||||
|
<option value="prod">prod</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => onChange('role', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="web, db, cache..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tags</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(e) => onChange('tags', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="linux, ubuntu, docker (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
hostname,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
hostname: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Server</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{hostname}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function ServersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [envFilter, setEnvFilter] = useState<EnvironmentFilter>('all');
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null);
|
||||||
|
const [form, setForm] = useState<ServerFormData>({ ...EMPTY_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof ServerFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const queryParams = envFilter !== 'all' ? { environment: envFilter } : undefined;
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.servers.list(queryParams),
|
||||||
|
queryFn: () => {
|
||||||
|
const qs = envFilter !== 'all' ? `?environment=${envFilter}` : '';
|
||||||
|
return apiClient<ServersResponse>(`/api/v1/inventory/servers${qs}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const servers = data?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<Server>('/api/v1/inventory/servers', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<Server>(`/api/v1/inventory/servers/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/inventory/servers/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof ServerFormData, string>> = {};
|
||||||
|
if (!form.hostname.trim()) next.hostname = 'Hostname is required';
|
||||||
|
if (!form.host.trim()) next.host = 'Host is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingServer(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setEditingServer(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((server: Server) => {
|
||||||
|
setEditingServer(server);
|
||||||
|
setForm({
|
||||||
|
hostname: server.hostname,
|
||||||
|
host: server.host,
|
||||||
|
sshPort: server.sshPort,
|
||||||
|
environment: server.environment,
|
||||||
|
role: server.role,
|
||||||
|
tags: (server.tags ?? []).join(', '),
|
||||||
|
description: server.description ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof ServerFormData, value: string | number) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
hostname: form.hostname.trim(),
|
||||||
|
host: form.host.trim(),
|
||||||
|
sshPort: form.sshPort,
|
||||||
|
environment: form.environment,
|
||||||
|
role: form.role.trim(),
|
||||||
|
tags: form.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
description: form.description.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingServer) {
|
||||||
|
updateMutation.mutate({ id: editingServer.id, body });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
}, [form, editingServer, validate, createMutation, updateMutation]);
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Servers</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage server inventory, clusters, and SSH configurations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment filter tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 p-1 bg-muted rounded-lg w-fit">
|
||||||
|
{ENVIRONMENTS.map((env) => (
|
||||||
|
<button
|
||||||
|
key={env.value}
|
||||||
|
onClick={() => setEnvFilter(env.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
envFilter === env.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{env.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load servers: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading servers...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Hostname</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Host</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Environment</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
No servers found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
servers.map((server) => (
|
||||||
|
<tr
|
||||||
|
key={server.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">{server.hostname}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{server.host}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">
|
||||||
|
{server.environment}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{server.role || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={server.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(server)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(server)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<ServerDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
title={editingServer ? 'Edit Server' : 'Add Server'}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={isSaving}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
hostname={deleteTarget?.hostname ?? ''}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,644 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SessionDetail {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'completed' | 'failed';
|
||||||
|
engineType: 'claude_code_cli' | 'claude_api';
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
tokenCount: number;
|
||||||
|
totalCostUsd: number;
|
||||||
|
taskDescription?: string;
|
||||||
|
serverTargets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
type: 'assistant' | 'tool_use' | 'tool_result' | 'result' | 'error';
|
||||||
|
timestamp: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionEventsResponse {
|
||||||
|
data: SessionEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionTask {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionTasksResponse {
|
||||||
|
data: SessionTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<SessionDetail['status'], string> = {
|
||||||
|
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENGINE_STYLES: Record<SessionDetail['engineType'], string> = {
|
||||||
|
claude_code_cli: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
claude_api: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENGINE_LABELS: Record<SessionDetail['engineType'], string> = {
|
||||||
|
claude_code_cli: 'Claude Code CLI',
|
||||||
|
claude_api: 'Claude API',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPE_STYLES: Record<SessionEvent['type'], string> = {
|
||||||
|
assistant: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
tool_use: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
tool_result: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
result: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||||
|
error: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_STATUS_STYLES: Record<SessionTask['status'], string> = {
|
||||||
|
pending: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
completed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Badge components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: SessionDetail['status'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
STATUS_STYLES[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EngineBadge({ engine }: { engine: SessionDetail['engineType'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
ENGINE_STYLES[engine],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ENGINE_LABELS[engine]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventTypeBadge({ type }: { type: SessionEvent['type'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
EVENT_TYPE_STYLES[type],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskStatusBadge({ status }: { status: SessionTask['status'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
TASK_STATUS_STYLES[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Toggle switch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
|
checked ? 'bg-primary' : 'bg-muted',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
|
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: format date / duration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleTimeString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startStr: string, endStr?: string): string {
|
||||||
|
const start = new Date(startStr).getTime();
|
||||||
|
const end = endStr ? new Date(endStr).getTime() : Date.now();
|
||||||
|
const ms = end - start;
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return `${hours}h ${remainingMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value: number): string {
|
||||||
|
return `$${value.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Info row component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 py-2">
|
||||||
|
<dt className="text-sm text-muted-foreground sm:w-36 shrink-0">{label}</dt>
|
||||||
|
<dd className="text-sm font-medium">{value || '--'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function SessionDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const [expandedEventId, setExpandedEventId] = useState<string | null>(null);
|
||||||
|
const eventsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: session,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.sessions.detail(id),
|
||||||
|
queryFn: () => apiClient<SessionDetail>(`/api/v1/agent/sessions/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: eventsData } = useQuery({
|
||||||
|
queryKey: queryKeys.sessions.events(id),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<SessionEventsResponse>(
|
||||||
|
`/api/v1/agent/sessions/${id}/events`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: session?.status === 'active' ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tasksData } = useQuery({
|
||||||
|
queryKey: [...queryKeys.sessions.detail(id), 'tasks'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<SessionTasksResponse>(
|
||||||
|
`/api/v1/agent/sessions/${id}/tasks`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = eventsData?.data ?? [];
|
||||||
|
const tasks = tasksData?.data ?? [];
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new events arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && eventsEndRef.current) {
|
||||||
|
eventsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [events.length, autoScroll]);
|
||||||
|
|
||||||
|
// Auto-refresh session if still active
|
||||||
|
useQuery({
|
||||||
|
queryKey: [...queryKeys.sessions.detail(id), 'poll'],
|
||||||
|
queryFn: () => apiClient<SessionDetail>(`/api/v1/agent/sessions/${id}`),
|
||||||
|
enabled: !!id && session?.status === 'active',
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/audit/replay')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Sessions
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading session details...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/audit/replay')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Sessions
|
||||||
|
</button>
|
||||||
|
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load session: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/audit/replay')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Sessions
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Session</h1>
|
||||||
|
<StatusBadge status={session.status} />
|
||||||
|
<EngineBadge engine={session.engineType} />
|
||||||
|
</div>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||||
|
{session.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Session Information Card */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Session Information</h2>
|
||||||
|
<dl className="divide-y">
|
||||||
|
<InfoRow
|
||||||
|
label="Session ID"
|
||||||
|
value={
|
||||||
|
<code className="font-mono text-xs">{session.id}</code>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Status"
|
||||||
|
value={<StatusBadge status={session.status} />}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Engine"
|
||||||
|
value={<EngineBadge engine={session.engineType} />}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Started At" value={formatDate(session.startedAt)} />
|
||||||
|
<InfoRow
|
||||||
|
label="Ended At"
|
||||||
|
value={session.endedAt ? formatDate(session.endedAt) : 'In progress...'}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Duration"
|
||||||
|
value={formatDuration(session.startedAt, session.endedAt)}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Token Count"
|
||||||
|
value={
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{session.tokenCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Total Cost"
|
||||||
|
value={
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatCurrency(session.totalCostUsd)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{session.taskDescription && (
|
||||||
|
<InfoRow label="Task" value={session.taskDescription} />
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Stream (Timeline) */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
Event Stream
|
||||||
|
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||||
|
({events.length} events)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={autoScroll}
|
||||||
|
onChange={setAutoScroll}
|
||||||
|
label="Auto-scroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||||
|
No events recorded yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto space-y-1">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className={cn(
|
||||||
|
'border rounded-md transition-colors',
|
||||||
|
expandedEventId === event.id
|
||||||
|
? 'bg-muted/30 border-border'
|
||||||
|
: 'border-transparent hover:bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Event header row */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedEventId(
|
||||||
|
expandedEventId === event.id ? null : event.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block transition-transform text-xs text-muted-foreground',
|
||||||
|
expandedEventId === event.id && 'rotate-90',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||||
|
{formatTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
<EventTypeBadge type={event.type} />
|
||||||
|
<span className="text-sm truncate text-muted-foreground flex-1">
|
||||||
|
{event.content.substring(0, 120)}
|
||||||
|
{event.content.length > 120 ? '...' : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expandedEventId === event.id && (
|
||||||
|
<div className="px-3 pb-3 pl-10">
|
||||||
|
<div className="bg-gray-950 text-gray-200 font-mono text-xs p-3 rounded-md whitespace-pre-wrap max-h-[400px] overflow-y-auto">
|
||||||
|
{event.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={eventsEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.status === 'active' && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
|
</span>
|
||||||
|
Live -- streaming events...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Session Stats */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Statistics</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Status</span>
|
||||||
|
<StatusBadge status={session.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Engine</span>
|
||||||
|
<EngineBadge engine={session.engineType} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Duration</span>
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{formatDuration(session.startedAt, session.endedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tokens</span>
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{session.tokenCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Cost</span>
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{formatCurrency(session.totalCostUsd)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Events</span>
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{events.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Tasks</h2>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No tasks in this session.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md border bg-muted/20"
|
||||||
|
>
|
||||||
|
<TaskStatusBadge status={task.status} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm truncate">{task.description}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatDate(task.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Targets */}
|
||||||
|
{session.serverTargets && session.serverTargets.length > 0 && (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Server Targets</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{session.serverTargets.map((server) => (
|
||||||
|
<span
|
||||||
|
key={server}
|
||||||
|
className="inline-flex items-center px-2.5 py-1 rounded-md bg-muted text-xs font-mono"
|
||||||
|
>
|
||||||
|
{server}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Timestamps</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Started</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(session.startedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Ended</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{session.endedAt ? formatDate(session.endedAt) : 'In progress'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,764 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface PlatformSettings {
|
||||||
|
platformName: string;
|
||||||
|
defaultTimezone: string;
|
||||||
|
defaultLanguage: string;
|
||||||
|
autoApproveThreshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationSettings {
|
||||||
|
emailEnabled: boolean;
|
||||||
|
smsEnabled: boolean;
|
||||||
|
pushEnabled: boolean;
|
||||||
|
defaultEscalationPolicy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeSettings {
|
||||||
|
mode: 'light' | 'dark';
|
||||||
|
primaryColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountInfo {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SectionId = 'general' | 'notifications' | 'apikeys' | 'theme' | 'account';
|
||||||
|
|
||||||
|
const SECTIONS: { id: SectionId; label: string }[] = [
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'notifications', label: 'Notifications' },
|
||||||
|
{ id: 'apikeys', label: 'API Keys' },
|
||||||
|
{ id: 'theme', label: 'Theme' },
|
||||||
|
{ id: 'account', label: 'Account' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIMEZONES = [
|
||||||
|
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
|
||||||
|
'America/Los_Angeles', 'Europe/London', 'Europe/Berlin', 'Europe/Paris',
|
||||||
|
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Seoul', 'Asia/Singapore',
|
||||||
|
'Australia/Sydney',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'ko', label: 'Korean' },
|
||||||
|
{ value: 'ja', label: 'Japanese' },
|
||||||
|
{ value: 'zh', label: 'Chinese' },
|
||||||
|
{ value: 'de', label: 'German' },
|
||||||
|
{ value: 'fr', label: 'French' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ESCALATION_POLICIES = ['immediate', 'after-5-min', 'after-15-min', 'after-30-min', 'manual'];
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#3b82f6', '#6366f1', '#8b5cf6', '#ec4899',
|
||||||
|
'#ef4444', '#f97316', '#10b981', '#14b8a6',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [activeSection, setActiveSection] = useState<SectionId>('general');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-1">Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Application preferences and configurations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* ---- Section Nav ---- */}
|
||||||
|
<nav className="w-48 shrink-0">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSection(s.id)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeSection === s.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'hover:bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ---- Section Content ---- */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{activeSection === 'general' && <GeneralSection />}
|
||||||
|
{activeSection === 'notifications' && <NotificationsSection />}
|
||||||
|
{activeSection === 'apikeys' && <ApiKeysSection />}
|
||||||
|
{activeSection === 'theme' && <ThemeSection />}
|
||||||
|
{activeSection === 'account' && <AccountSection />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* General Section */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function GeneralSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<PlatformSettings>({
|
||||||
|
queryKey: queryKeys.settings.general(),
|
||||||
|
queryFn: () => apiClient<PlatformSettings>('/api/v1/admin/settings/general'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [form, setForm] = useState<PlatformSettings>({
|
||||||
|
platformName: '',
|
||||||
|
defaultTimezone: 'UTC',
|
||||||
|
defaultLanguage: 'en',
|
||||||
|
autoApproveThreshold: 1,
|
||||||
|
});
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
if (data && !initialized) {
|
||||||
|
setForm(data);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: PlatformSettings) =>
|
||||||
|
apiClient('/api/v1/admin/settings/general', { method: 'PUT', body }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.settings.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">General Settings</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Platform Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={form.platformName}
|
||||||
|
onChange={(e) => setForm({ ...form, platformName: e.target.value })}
|
||||||
|
placeholder="IT0 Platform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Default Timezone</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={form.defaultTimezone}
|
||||||
|
onChange={(e) => setForm({ ...form, defaultTimezone: e.target.value })}
|
||||||
|
>
|
||||||
|
{TIMEZONES.map((tz) => (
|
||||||
|
<option key={tz} value={tz}>{tz}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Default Language</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={form.defaultLanguage}
|
||||||
|
onChange={(e) => setForm({ ...form, defaultLanguage: e.target.value })}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((l) => (
|
||||||
|
<option key={l.value} value={l.value}>{l.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Auto-Approve Threshold (Risk Level 0-3)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={form.autoApproveThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, autoApproveThreshold: Math.min(3, Math.max(0, Number(e.target.value))) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Operations at or below this risk level will be auto-approved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate(form)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<p className="text-sm text-red-500">{(mutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
{mutation.isSuccess && (
|
||||||
|
<p className="text-sm text-green-600">Settings saved successfully.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Notifications Section */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function NotificationsSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<NotificationSettings>({
|
||||||
|
queryKey: queryKeys.settings.notifications(),
|
||||||
|
queryFn: () => apiClient<NotificationSettings>('/api/v1/admin/settings/notifications'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [form, setForm] = useState<NotificationSettings>({
|
||||||
|
emailEnabled: true,
|
||||||
|
smsEnabled: false,
|
||||||
|
pushEnabled: false,
|
||||||
|
defaultEscalationPolicy: 'immediate',
|
||||||
|
});
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
if (data && !initialized) {
|
||||||
|
setForm(data);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: NotificationSettings) =>
|
||||||
|
apiClient('/api/v1/admin/settings/notifications', { method: 'PUT', body }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.settings.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
checked ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Notification Settings</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-w-lg">
|
||||||
|
<Toggle
|
||||||
|
label="Email Notifications"
|
||||||
|
checked={form.emailEnabled}
|
||||||
|
onChange={(v) => setForm({ ...form, emailEnabled: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="SMS Notifications"
|
||||||
|
checked={form.smsEnabled}
|
||||||
|
onChange={(v) => setForm({ ...form, smsEnabled: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Push Notifications"
|
||||||
|
checked={form.pushEnabled}
|
||||||
|
onChange={(v) => setForm({ ...form, pushEnabled: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<label className="block text-sm font-medium mb-1">Default Escalation Policy</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={form.defaultEscalationPolicy}
|
||||||
|
onChange={(e) => setForm({ ...form, defaultEscalationPolicy: e.target.value })}
|
||||||
|
>
|
||||||
|
{ESCALATION_POLICIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate(form)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<p className="text-sm text-red-500">{(mutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
{mutation.isSuccess && (
|
||||||
|
<p className="text-sm text-green-600">Notification settings saved.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* API Keys Section */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ApiKeysSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
|
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: apiKeys = [], isLoading } = useQuery<ApiKey[]>({
|
||||||
|
queryKey: queryKeys.settings.apiKeys(),
|
||||||
|
queryFn: () => apiClient<ApiKey[]>('/api/v1/admin/settings/api-keys'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.apiKeys() });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (name: string) =>
|
||||||
|
apiClient<{ key: string }>('/api/v1/admin/settings/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name },
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setGeneratedKey((data as { key: string }).key);
|
||||||
|
setNewKeyName('');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient(`/api/v1/admin/settings/api-keys/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
function maskKey(key: string) {
|
||||||
|
if (key.length <= 8) return key;
|
||||||
|
return key.slice(0, 4) + '****' + key.slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">API Keys</h2>
|
||||||
|
|
||||||
|
{/* Generate new key */}
|
||||||
|
<div className="flex gap-2 mb-4 max-w-lg">
|
||||||
|
<input
|
||||||
|
className="flex-1 border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="Key name (e.g. CI/CD Pipeline)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={!newKeyName.trim() || createMutation.isPending}
|
||||||
|
onClick={() => createMutation.mutate(newKeyName.trim())}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Generate New Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show generated key once */}
|
||||||
|
{generatedKey && (
|
||||||
|
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-md">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-1">
|
||||||
|
New API key generated. Copy it now -- it will not be shown again.
|
||||||
|
</p>
|
||||||
|
<code className="block text-sm bg-white dark:bg-gray-900 p-2 rounded border font-mono break-all">
|
||||||
|
{generatedKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(generatedKey);
|
||||||
|
}}
|
||||||
|
className="mt-2 px-3 py-1 text-xs border rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
Copy to Clipboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGeneratedKey(null)}
|
||||||
|
className="mt-2 ml-2 px-3 py-1 text-xs border rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keys table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Last Used</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{apiKeys.map((k) => (
|
||||||
|
<tr key={k.id} className="hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-3 font-medium">{k.name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-muted-foreground">{maskKey(k.key)}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{format(new Date(k.createdAt), 'MMM d, yyyy')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{k.lastUsedAt ? format(new Date(k.lastUsedAt), 'MMM d, yyyy') : 'Never'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => revokeMutation.mutate(k.id)}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900 dark:text-red-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{apiKeys.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
No API keys. Generate one above.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createMutation.isError && (
|
||||||
|
<p className="text-sm text-red-500 mt-2">{(createMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Theme Section */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function ThemeSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ThemeSettings>({
|
||||||
|
queryKey: queryKeys.settings.theme(),
|
||||||
|
queryFn: () => apiClient<ThemeSettings>('/api/v1/admin/settings/theme'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<'light' | 'dark'>('light');
|
||||||
|
const [primaryColor, setPrimaryColor] = useState('#3b82f6');
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
if (data && !initialized) {
|
||||||
|
setMode(data.mode);
|
||||||
|
setPrimaryColor(data.primaryColor);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (body: ThemeSettings) =>
|
||||||
|
apiClient('/api/v1/admin/settings/theme', { method: 'PUT', body }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.settings.all }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Theme</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 max-w-lg">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Appearance</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['light', 'dark'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`px-4 py-2 text-sm rounded-md border font-medium transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'light' ? 'Light' : 'Dark'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color presets */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Primary Color</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{COLOR_PRESETS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setPrimaryColor(c)}
|
||||||
|
className={`w-8 h-8 rounded-full border-2 transition-transform ${
|
||||||
|
primaryColor === c ? 'border-foreground scale-110' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
title={c}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||||
|
className="w-8 h-8 rounded-full cursor-pointer border-0 p-0"
|
||||||
|
title="Custom color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Selected: {primaryColor}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate({ mode, primaryColor })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Theme'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<p className="text-sm text-red-500">{(mutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
{mutation.isSuccess && (
|
||||||
|
<p className="text-sm text-green-600">Theme settings saved.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Account Section */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function AccountSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<AccountInfo>({
|
||||||
|
queryKey: queryKeys.settings.account(),
|
||||||
|
queryFn: () => apiClient<AccountInfo>('/api/v1/admin/settings/account'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
if (data && !initialized) {
|
||||||
|
setDisplayName(data.displayName);
|
||||||
|
setEmail(data.email);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileMutation = useMutation({
|
||||||
|
mutationFn: (body: { displayName: string }) =>
|
||||||
|
apiClient('/api/v1/admin/settings/account', { method: 'PUT', body }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.settings.account() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordMutation = useMutation({
|
||||||
|
mutationFn: (body: { currentPassword: string; newPassword: string }) =>
|
||||||
|
apiClient('/api/v1/admin/settings/account/password', { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordMismatch = confirmPassword !== '' && newPassword !== confirmPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Profile card */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Account Profile</h2>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Display Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-muted text-sm cursor-not-allowed"
|
||||||
|
value={email}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Email cannot be changed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => profileMutation.mutate({ displayName })}
|
||||||
|
disabled={profileMutation.isPending || !displayName.trim()}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{profileMutation.isPending ? 'Saving...' : 'Save Profile'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{profileMutation.isError && (
|
||||||
|
<p className="text-sm text-red-500">{(profileMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
{profileMutation.isSuccess && (
|
||||||
|
<p className="text-sm text-green-600">Profile updated.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password card */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Change Password</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={`w-full border rounded-md px-3 py-2 bg-background text-sm ${
|
||||||
|
passwordMismatch ? 'border-red-500' : ''
|
||||||
|
}`}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{passwordMismatch && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">Passwords do not match.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
passwordMutation.mutate({ currentPassword, newPassword })
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
passwordMutation.isPending ||
|
||||||
|
!currentPassword ||
|
||||||
|
!newPassword ||
|
||||||
|
newPassword !== confirmPassword
|
||||||
|
}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{passwordMutation.isPending ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{passwordMutation.isError && (
|
||||||
|
<p className="text-sm text-red-500">{(passwordMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
{passwordMutation.isSuccess && (
|
||||||
|
<p className="text-sm text-green-600">Password changed successfully.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,728 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
/* ---------- Types ---------- */
|
||||||
|
type TriggerType = 'cron' | 'event' | 'threshold';
|
||||||
|
|
||||||
|
interface TriggerConfig {
|
||||||
|
triggerType: TriggerType;
|
||||||
|
cronExpression?: string;
|
||||||
|
eventType?: string;
|
||||||
|
metric?: string;
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StandingOrder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
triggerConfig: TriggerConfig;
|
||||||
|
targetServers: string[];
|
||||||
|
agentInstruction: string;
|
||||||
|
maxBudget: number;
|
||||||
|
escalateOnFailure: boolean;
|
||||||
|
status: 'active' | 'paused';
|
||||||
|
lastExecutionAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Execution {
|
||||||
|
id: string;
|
||||||
|
standingOrderId: string;
|
||||||
|
status: 'success' | 'failure' | 'running' | 'cancelled';
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
summary: string;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StandingOrderFormData {
|
||||||
|
name: string;
|
||||||
|
triggerType: TriggerType;
|
||||||
|
cronExpression: string;
|
||||||
|
eventType: string;
|
||||||
|
metric: string;
|
||||||
|
threshold: string;
|
||||||
|
targetServers: string;
|
||||||
|
agentInstruction: string;
|
||||||
|
maxBudget: string;
|
||||||
|
escalateOnFailure: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: StandingOrderFormData = {
|
||||||
|
name: '',
|
||||||
|
triggerType: 'cron',
|
||||||
|
cronExpression: '',
|
||||||
|
eventType: '',
|
||||||
|
metric: '',
|
||||||
|
threshold: '',
|
||||||
|
targetServers: '',
|
||||||
|
agentInstruction: '',
|
||||||
|
maxBudget: '',
|
||||||
|
escalateOnFailure: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGER_TYPE_OPTIONS: { value: TriggerType; label: string }[] = [
|
||||||
|
{ value: 'cron', label: 'Cron Schedule' },
|
||||||
|
{ value: 'event', label: 'Event' },
|
||||||
|
{ value: 'threshold', label: 'Threshold' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_TYPE_OPTIONS = [
|
||||||
|
'server.cpu_high',
|
||||||
|
'server.disk_full',
|
||||||
|
'server.unreachable',
|
||||||
|
'deployment.failed',
|
||||||
|
'alert.critical',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-500/20 text-green-400',
|
||||||
|
paused: 'bg-muted text-muted-foreground',
|
||||||
|
success: 'bg-green-500/20 text-green-400',
|
||||||
|
failure: 'bg-destructive/20 text-destructive-foreground',
|
||||||
|
running: 'bg-primary/20 text-primary',
|
||||||
|
cancelled: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Execution History Sub-component ---------- */
|
||||||
|
function ExecutionHistory({ orderId }: { orderId: string }) {
|
||||||
|
const { data: executions = [], isLoading, error } = useQuery<Execution[]>({
|
||||||
|
queryKey: queryKeys.standingOrders.executions(orderId),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<Execution[]>(`/api/v1/ops/standing-orders/${orderId}/executions`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-sm text-muted-foreground">Loading executions...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-sm text-destructive-foreground">
|
||||||
|
Failed to load executions: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (executions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 text-sm text-muted-foreground">No executions recorded yet.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">
|
||||||
|
Execution History
|
||||||
|
</h4>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left py-1.5 pr-4 font-medium">Status</th>
|
||||||
|
<th className="text-left py-1.5 pr-4 font-medium">Started</th>
|
||||||
|
<th className="text-left py-1.5 pr-4 font-medium">Completed</th>
|
||||||
|
<th className="text-left py-1.5 pr-4 font-medium">Summary</th>
|
||||||
|
<th className="text-right py-1.5 font-medium">Cost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{executions.map((exec) => (
|
||||||
|
<tr key={exec.id} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5 pr-4">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block px-1.5 py-0.5 rounded text-xs font-medium',
|
||||||
|
STATUS_COLORS[exec.status] ?? 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exec.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-4 text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(exec.startedAt), { addSuffix: true })}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-4 text-muted-foreground">
|
||||||
|
{exec.completedAt
|
||||||
|
? formatDistanceToNow(new Date(exec.completedAt), { addSuffix: true })
|
||||||
|
: '--'}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-4 max-w-xs truncate">{exec.summary}</td>
|
||||||
|
<td className="py-1.5 text-right">${exec.cost.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Main Component ---------- */
|
||||||
|
export default function StandingOrdersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/* Dialog state */
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<StandingOrderFormData>(emptyForm);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ---------- Queries ---------- */
|
||||||
|
const {
|
||||||
|
data: orders = [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery<StandingOrder[]>({
|
||||||
|
queryKey: queryKeys.standingOrders.list(),
|
||||||
|
queryFn: () => apiClient<StandingOrder[]>('/api/v1/ops/standing-orders'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- Mutations ---------- */
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
apiClient<StandingOrder>('/api/v1/ops/standing-orders', { method: 'POST', body: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.standingOrders.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) =>
|
||||||
|
apiClient<StandingOrder>(`/api/v1/ops/standing-orders/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.standingOrders.all });
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/ops/standing-orders/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.standingOrders.all });
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleStatusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: 'active' | 'paused' }) =>
|
||||||
|
apiClient<StandingOrder>(`/api/v1/ops/standing-orders/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { status },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.standingOrders.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- Helpers ---------- */
|
||||||
|
function closeDialog() {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setEditingId(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(order: StandingOrder) {
|
||||||
|
setForm({
|
||||||
|
name: order.name,
|
||||||
|
triggerType: order.triggerConfig.triggerType,
|
||||||
|
cronExpression: order.triggerConfig.cronExpression ?? '',
|
||||||
|
eventType: order.triggerConfig.eventType ?? '',
|
||||||
|
metric: order.triggerConfig.metric ?? '',
|
||||||
|
threshold: order.triggerConfig.threshold?.toString() ?? '',
|
||||||
|
targetServers: order.targetServers.join(', '),
|
||||||
|
agentInstruction: order.agentInstruction,
|
||||||
|
maxBudget: order.maxBudget.toString(),
|
||||||
|
escalateOnFailure: order.escalateOnFailure,
|
||||||
|
});
|
||||||
|
setEditingId(order.id);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTriggerConfig(): TriggerConfig {
|
||||||
|
const config: TriggerConfig = { triggerType: form.triggerType };
|
||||||
|
if (form.triggerType === 'cron') {
|
||||||
|
config.cronExpression = form.cronExpression;
|
||||||
|
} else if (form.triggerType === 'event') {
|
||||||
|
config.eventType = form.eventType;
|
||||||
|
} else if (form.triggerType === 'threshold') {
|
||||||
|
config.metric = form.metric;
|
||||||
|
config.threshold = parseFloat(form.threshold) || 0;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
triggerConfig: buildTriggerConfig(),
|
||||||
|
targetServers: form.targetServers
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
agentInstruction: form.agentInstruction,
|
||||||
|
maxBudget: parseFloat(form.maxBudget) || 0,
|
||||||
|
escalateOnFailure: form.escalateOnFailure,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, ...payload });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerLabel(config: TriggerConfig): string {
|
||||||
|
switch (config.triggerType) {
|
||||||
|
case 'cron':
|
||||||
|
return `Cron: ${config.cronExpression || '(not set)'}`;
|
||||||
|
case 'event':
|
||||||
|
return `Event: ${config.eventType || '(not set)'}`;
|
||||||
|
case 'threshold':
|
||||||
|
return `${config.metric || 'metric'} >= ${config.threshold ?? '?'}`;
|
||||||
|
default:
|
||||||
|
return config.triggerType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
/* ---------- Render ---------- */
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Standing Orders</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage autonomous operation tasks and execution schedules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
New Standing Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-muted-foreground text-sm py-12 text-center">
|
||||||
|
Loading standing orders...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-destructive text-sm py-4">
|
||||||
|
Failed to load standing orders: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="w-8 px-4 py-3" />
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Trigger</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Last Execution</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
|
||||||
|
No standing orders yet. Click "New Standing Order" to create one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
) : (
|
||||||
|
orders.map((order) => (
|
||||||
|
<tbody key={order.id}>
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
'border-b hover:bg-muted/30 transition-colors cursor-pointer',
|
||||||
|
expandedId === order.id && 'bg-muted/20',
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedId(expandedId === order.id ? null : order.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Expand chevron */}
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block transition-transform text-xs',
|
||||||
|
expandedId === order.id && 'rotate-90',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3 font-medium">{order.name}</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block px-2 py-0.5 rounded font-medium',
|
||||||
|
order.triggerConfig.triggerType === 'cron' &&
|
||||||
|
'bg-primary/20 text-primary',
|
||||||
|
order.triggerConfig.triggerType === 'event' &&
|
||||||
|
'bg-destructive/20 text-destructive-foreground',
|
||||||
|
order.triggerConfig.triggerType === 'threshold' &&
|
||||||
|
'bg-secondary text-secondary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{triggerLabel(order.triggerConfig)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status toggle */}
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleStatusMutation.mutate({
|
||||||
|
id: order.id,
|
||||||
|
status: order.status === 'active' ? 'paused' : 'active',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
|
||||||
|
order.status === 'active' ? 'bg-green-500' : 'bg-muted',
|
||||||
|
)}
|
||||||
|
title={order.status === 'active' ? 'Active (click to pause)' : 'Paused (click to activate)'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
|
||||||
|
order.status === 'active'
|
||||||
|
? 'translate-x-[18px]'
|
||||||
|
: 'translate-x-[3px]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="block text-[10px] mt-0.5 text-muted-foreground">
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{order.lastExecutionAt
|
||||||
|
? formatDistanceToNow(new Date(order.lastExecutionAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})
|
||||||
|
: 'Never'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3 text-right space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEdit(order);
|
||||||
|
}}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{deleteConfirmId === order.id ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteMutation.mutate(order.id);
|
||||||
|
}}
|
||||||
|
className="text-xs text-destructive-foreground bg-destructive px-2 py-0.5 rounded hover:bg-destructive/80"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteConfirmId(order.id);
|
||||||
|
}}
|
||||||
|
className="text-xs text-destructive-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded execution history */}
|
||||||
|
{expandedId === order.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="bg-muted/10 border-b">
|
||||||
|
<ExecutionHistory orderId={order.id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog Overlay */}
|
||||||
|
{dialogOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={closeDialog} />
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-card border rounded-lg shadow-xl mx-4">
|
||||||
|
<div className="flex items-center justify-between px-6 pt-6 pb-2">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{editingId ? 'Edit Standing Order' : 'New Standing Order'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={closeDialog}
|
||||||
|
className="text-muted-foreground hover:text-foreground text-xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="e.g. Nightly Log Rotation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trigger Config Section */}
|
||||||
|
<fieldset className="border rounded-md p-4 space-y-3">
|
||||||
|
<legend className="text-sm font-medium px-1">Trigger Configuration</legend>
|
||||||
|
|
||||||
|
{/* Trigger Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1 text-muted-foreground">
|
||||||
|
Trigger Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.triggerType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, triggerType: e.target.value as TriggerType })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{TRIGGER_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Expression */}
|
||||||
|
{form.triggerType === 'cron' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1 text-muted-foreground">
|
||||||
|
Cron Expression
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.cronExpression}
|
||||||
|
onChange={(e) => setForm({ ...form, cronExpression: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="0 2 * * *"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Standard cron syntax (minute hour day month weekday).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Type */}
|
||||||
|
{form.triggerType === 'event' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1 text-muted-foreground">
|
||||||
|
Event Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.eventType}
|
||||||
|
onChange={(e) => setForm({ ...form, eventType: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Select an event type...</option>
|
||||||
|
{EVENT_TYPE_OPTIONS.map((evt) => (
|
||||||
|
<option key={evt} value={evt}>
|
||||||
|
{evt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Threshold */}
|
||||||
|
{form.triggerType === 'threshold' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1 text-muted-foreground">
|
||||||
|
Metric
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.metric}
|
||||||
|
onChange={(e) => setForm({ ...form, metric: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="e.g. cpu_percent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1 text-muted-foreground">
|
||||||
|
Threshold
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
step="any"
|
||||||
|
value={form.threshold}
|
||||||
|
onChange={(e) => setForm({ ...form, threshold: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* Target Servers */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Target Servers</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.targetServers}
|
||||||
|
onChange={(e) => setForm({ ...form, targetServers: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="server-1, server-2, server-3 (comma-separated IDs)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Comma-separated server IDs this order applies to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Instruction */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Agent Instruction</label>
|
||||||
|
<textarea
|
||||||
|
value={form.agentInstruction}
|
||||||
|
onChange={(e) => setForm({ ...form, agentInstruction: e.target.value })}
|
||||||
|
rows={5}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||||
|
placeholder="Describe what the agent should do when this order triggers..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Budget */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Budget ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.maxBudget}
|
||||||
|
onChange={(e) => setForm({ ...form, maxBudget: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="10.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Escalate On Failure */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.escalateOnFailure}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, escalateOnFailure: e.target.checked })
|
||||||
|
}
|
||||||
|
className="accent-primary h-4 w-4"
|
||||||
|
/>
|
||||||
|
Escalate on failure
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||||
|
Notify administrators when an execution fails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{(createMutation.error || updateMutation.error) && (
|
||||||
|
<p className="text-sm text-destructive-foreground">
|
||||||
|
{((createMutation.error || updateMutation.error) as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeDialog}
|
||||||
|
className="px-4 py-2 rounded-md border text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? 'Saving...'
|
||||||
|
: editingId
|
||||||
|
? 'Update Standing Order'
|
||||||
|
: 'Create Standing Order'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,764 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TenantDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
schemaName: string;
|
||||||
|
plan: 'free' | 'pro' | 'enterprise';
|
||||||
|
status: 'active' | 'suspended';
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantMember {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
joinedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantMembersResponse {
|
||||||
|
data: TenantMember[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantFormData {
|
||||||
|
name: string;
|
||||||
|
plan: TenantDetail['plan'];
|
||||||
|
status: TenantDetail['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PLAN_STYLES: Record<TenantDetail['plan'], string> = {
|
||||||
|
free: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
pro: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
enterprise: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<TenantDetail['status'], string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
suspended: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_STYLES: Record<TenantMember['role'], string> = {
|
||||||
|
owner: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
admin: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
member: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
viewer: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Badge components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PlanBadge({ plan }: { plan: TenantDetail['plan'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
PLAN_STYLES[plan],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: TenantDetail['status'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
STATUS_STYLES[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: TenantMember['role'] }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
ROLE_STYLES[role],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
name,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
name: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete Tenant</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Are you sure you want to delete <strong>{name}</strong>? This will permanently remove the
|
||||||
|
tenant, all its data, and all member associations. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: format date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Info row component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 py-2">
|
||||||
|
<dt className="text-sm text-muted-foreground sm:w-36 shrink-0">{label}</dt>
|
||||||
|
<dd className="text-sm font-medium">{value || '--'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function TenantDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<TenantFormData>({
|
||||||
|
name: '',
|
||||||
|
plan: 'free',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof TenantFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: tenant,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.tenants.detail(id),
|
||||||
|
queryFn: () => apiClient<TenantDetail>(`/api/v1/admin/tenants/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: membersData } = useQuery({
|
||||||
|
queryKey: [...queryKeys.tenants.detail(id), 'members'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<TenantMembersResponse>(
|
||||||
|
`/api/v1/admin/tenants/${id}/members`,
|
||||||
|
),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = membersData?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<TenantDetail>(`/api/v1/admin/tenants/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenants.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||||
|
setIsEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/admin/tenants/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||||
|
router.push('/tenants');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const suspendMutation = useMutation({
|
||||||
|
mutationFn: (status: TenantDetail['status']) =>
|
||||||
|
apiClient<TenantDetail>(`/api/v1/admin/tenants/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { status },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenants.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
if (!tenant) return;
|
||||||
|
setForm({
|
||||||
|
name: tenant.name,
|
||||||
|
plan: tenant.plan,
|
||||||
|
status: tenant.status,
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setIsEditing(true);
|
||||||
|
}, [tenant]);
|
||||||
|
|
||||||
|
const cancelEditing = useCallback(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof TenantFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof TenantFormData, string>> = {};
|
||||||
|
if (!form.name.trim()) next.name = 'Name is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
plan: form.plan,
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
updateMutation.mutate(body);
|
||||||
|
}, [form, validate, updateMutation]);
|
||||||
|
|
||||||
|
const handleToggleStatus = useCallback(() => {
|
||||||
|
if (!tenant) return;
|
||||||
|
suspendMutation.mutate(tenant.status === 'active' ? 'suspended' : 'active');
|
||||||
|
}, [tenant, suspendMutation]);
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/tenants')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Tenants
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading tenant details...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/tenants')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Tenants
|
||||||
|
</button>
|
||||||
|
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load tenant: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/tenants')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Tenants
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{tenant.name}</h1>
|
||||||
|
<StatusBadge status={tenant.status} />
|
||||||
|
<PlanBadge plan={tenant.plan} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Tenant Information Card */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Tenant Information</h2>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
/* ---------- Edit form ---------- */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.name ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="My Organization"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* plan */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Plan</label>
|
||||||
|
<select
|
||||||
|
value={form.plan}
|
||||||
|
onChange={(e) => handleChange('plan', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="free">Free</option>
|
||||||
|
<option value="pro">Pro</option>
|
||||||
|
<option value="enterprise">Enterprise</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => handleChange('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update error */}
|
||||||
|
{updateMutation.isError && (
|
||||||
|
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to update tenant: {(updateMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ---------- Read-only info display ---------- */
|
||||||
|
<dl className="divide-y">
|
||||||
|
<InfoRow label="Name" value={tenant.name} />
|
||||||
|
<InfoRow
|
||||||
|
label="Slug"
|
||||||
|
value={
|
||||||
|
<span className="font-mono text-xs">{tenant.slug}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Schema Name"
|
||||||
|
value={
|
||||||
|
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{tenant.schemaName}
|
||||||
|
</code>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Plan"
|
||||||
|
value={<PlanBadge plan={tenant.plan} />}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Status"
|
||||||
|
value={<StatusBadge status={tenant.status} />}
|
||||||
|
/>
|
||||||
|
<InfoRow label="Members" value={String(tenant.memberCount)} />
|
||||||
|
<InfoRow label="Created" value={formatDate(tenant.createdAt)} />
|
||||||
|
<InfoRow label="Updated" value={formatDate(tenant.updatedAt)} />
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Member List */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Members</h2>
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No members found.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Name</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Email</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Role</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Joined</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr
|
||||||
|
key={member.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-medium">{member.name}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{member.email}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<RoleBadge role={member.role} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
|
||||||
|
{formatRelative(member.joinedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleStatus}
|
||||||
|
disabled={suspendMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2 text-sm rounded-md font-medium transition-colors text-left flex items-center gap-2 disabled:opacity-50',
|
||||||
|
tenant.status === 'active'
|
||||||
|
? 'border border-yellow-500/50 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-500/10'
|
||||||
|
: 'border border-green-500/50 text-green-700 dark:text-green-400 hover:bg-green-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{tenant.status === 'active' ? (
|
||||||
|
<>
|
||||||
|
<rect x="6" y="4" width="4" height="16" />
|
||||||
|
<rect x="14" y="4" width="4" height="16" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{suspendMutation.isPending
|
||||||
|
? 'Updating...'
|
||||||
|
: tenant.status === 'active'
|
||||||
|
? 'Suspend Tenant'
|
||||||
|
: 'Activate Tenant'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{suspendMutation.isError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{(suspendMutation.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/audit/logs?tenantId=${tenant.id}`)}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" x2="8" y1="13" y2="13" />
|
||||||
|
<line x1="16" x2="8" y1="17" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
View Audit Log
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
Edit Tenant
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
<line x1="10" x2="10" y1="11" y2="17" />
|
||||||
|
<line x1="14" x2="14" y1="11" y2="17" />
|
||||||
|
</svg>
|
||||||
|
Delete Tenant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tenant Metadata */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Metadata</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tenant ID</span>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{tenant.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Slug</span>
|
||||||
|
<span className="text-sm font-mono">{tenant.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Schema</span>
|
||||||
|
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{tenant.schemaName}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Created</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(tenant.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Updated</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(tenant.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
name={tenant.name}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface TenantQuota {
|
||||||
|
maxServers: number;
|
||||||
|
maxUsers: number;
|
||||||
|
maxStandingOrders: number;
|
||||||
|
maxAgentTokensPerMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
plan: 'free' | 'pro' | 'enterprise';
|
||||||
|
status: 'active' | 'suspended';
|
||||||
|
userCount: number;
|
||||||
|
quota: TenantQuota;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTenantPayload {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
plan: 'free' | 'pro' | 'enterprise';
|
||||||
|
adminEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const PLANS: Tenant['plan'][] = ['free', 'pro', 'enterprise'];
|
||||||
|
|
||||||
|
const planBadge: Record<Tenant['plan'], string> = {
|
||||||
|
free: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
pro: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
enterprise: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge: Record<Tenant['status'], string> = {
|
||||||
|
active: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
suspended: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugify(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function TenantsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/* ---- local state ---- */
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editPlan, setEditPlan] = useState<Tenant['plan']>('free');
|
||||||
|
const [editQuota, setEditQuota] = useState<TenantQuota>({
|
||||||
|
maxServers: 0,
|
||||||
|
maxUsers: 0,
|
||||||
|
maxStandingOrders: 0,
|
||||||
|
maxAgentTokensPerMonth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- create form state ---- */
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formSlug, setFormSlug] = useState('');
|
||||||
|
const [formPlan, setFormPlan] = useState<Tenant['plan']>('free');
|
||||||
|
const [formEmail, setFormEmail] = useState('');
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
|
||||||
|
/* ---- queries ---- */
|
||||||
|
const { data: tenants = [], isLoading, error } = useQuery<Tenant[]>({
|
||||||
|
queryKey: queryKeys.tenants.list(),
|
||||||
|
queryFn: () => apiClient<Tenant[]>('/api/v1/admin/tenants'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- mutations ---- */
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (payload: CreateTenantPayload) =>
|
||||||
|
apiClient('/api/v1/admin/tenants', { method: 'POST', body: payload }),
|
||||||
|
onSuccess: () => { invalidate(); resetCreateForm(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Partial<Tenant> }) =>
|
||||||
|
apiClient(`/api/v1/admin/tenants/${id}`, { method: 'PATCH', body }),
|
||||||
|
onSuccess: () => { invalidate(); setEditingId(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleStatusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: Tenant['status'] }) =>
|
||||||
|
apiClient(`/api/v1/admin/tenants/${id}`, { method: 'PATCH', body: { status } }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- helpers ---- */
|
||||||
|
function resetCreateForm() {
|
||||||
|
setShowCreate(false);
|
||||||
|
setFormName('');
|
||||||
|
setFormSlug('');
|
||||||
|
setFormPlan('free');
|
||||||
|
setFormEmail('');
|
||||||
|
setSlugTouched(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNameChange(value: string) {
|
||||||
|
setFormName(value);
|
||||||
|
if (!slugTouched) setFormSlug(slugify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(t: Tenant) {
|
||||||
|
setEditingId(t.id);
|
||||||
|
setEditPlan(t.plan);
|
||||||
|
setEditQuota({ ...t.quota });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit(id: string) {
|
||||||
|
updateMutation.mutate({ id, body: { plan: editPlan, quota: editQuota } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Render */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Tenant Management</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage tenants, plans, and resource quotas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
+ New Tenant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Create Dialog ---- */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border rounded-lg shadow-lg w-full max-w-md p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Create New Tenant</h2>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm"
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">Slug</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm"
|
||||||
|
value={formSlug}
|
||||||
|
onChange={(e) => { setSlugTouched(true); setFormSlug(e.target.value); }}
|
||||||
|
placeholder="acme-corp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">Plan</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm"
|
||||||
|
value={formPlan}
|
||||||
|
onChange={(e) => setFormPlan(e.target.value as Tenant['plan'])}
|
||||||
|
>
|
||||||
|
{PLANS.map((p) => (
|
||||||
|
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">Admin Email *</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded-md px-3 py-2 mb-4 bg-background text-sm"
|
||||||
|
type="email"
|
||||||
|
value={formEmail}
|
||||||
|
onChange={(e) => setFormEmail(e.target.value)}
|
||||||
|
placeholder="admin@acme.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={resetCreateForm}
|
||||||
|
className="px-4 py-2 text-sm border rounded-md hover:bg-muted"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!formName || !formEmail || createMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
createMutation.mutate({
|
||||||
|
name: formName,
|
||||||
|
slug: formSlug || slugify(formName),
|
||||||
|
plan: formPlan,
|
||||||
|
adminEmail: formEmail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create Tenant'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createMutation.isError && (
|
||||||
|
<p className="text-sm text-red-500 mt-2">
|
||||||
|
{(createMutation.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Loading / Error ---- */}
|
||||||
|
{isLoading && <p className="text-muted-foreground">Loading tenants...</p>}
|
||||||
|
{error && <p className="text-red-500">Failed to load tenants: {(error as Error).message}</p>}
|
||||||
|
|
||||||
|
{/* ---- Tenant Table ---- */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Slug</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Plan</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Users</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<>
|
||||||
|
{/* Main row */}
|
||||||
|
<tr key={t.id} className="hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-3 font-medium">{t.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{t.slug}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{editingId === t.id ? (
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-xs bg-background"
|
||||||
|
value={editPlan}
|
||||||
|
onChange={(e) => setEditPlan(e.target.value as Tenant['plan'])}
|
||||||
|
>
|
||||||
|
{PLANS.map((p) => (
|
||||||
|
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${planBadge[t.plan]}`}>
|
||||||
|
{t.plan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge[t.status]}`}>
|
||||||
|
{t.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">{t.userCount}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{format(new Date(t.createdAt), 'MMM d, yyyy')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{editingId === t.id ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => saveEdit(t.id)}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-2 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className="px-2 py-1 text-xs border rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(t)}
|
||||||
|
className="px-2 py-1 text-xs border rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
toggleStatusMutation.mutate({
|
||||||
|
id: t.id,
|
||||||
|
status: t.status === 'active' ? 'suspended' : 'active',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
t.status === 'active'
|
||||||
|
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-300'
|
||||||
|
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.status === 'active' ? 'Suspend' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(expandedId === t.id ? null : t.id)}
|
||||||
|
className="px-2 py-1 text-xs border rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
{expandedId === t.id ? 'Hide Quotas' : 'Quotas'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded quota row */}
|
||||||
|
{expandedId === t.id && (
|
||||||
|
<tr key={`${t.id}-quota`} className="bg-muted/20">
|
||||||
|
<td colSpan={7} className="px-4 py-4">
|
||||||
|
<QuotaEditor
|
||||||
|
quota={editingId === t.id ? editQuota : t.quota}
|
||||||
|
editable={editingId === t.id}
|
||||||
|
onChange={setEditQuota}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tenants.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
|
No tenants found. Create your first tenant to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Quota Editor (inline sub-component) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function QuotaEditor({
|
||||||
|
quota,
|
||||||
|
editable,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
quota: TenantQuota;
|
||||||
|
editable: boolean;
|
||||||
|
onChange: (q: TenantQuota) => void;
|
||||||
|
}) {
|
||||||
|
const fields: { key: keyof TenantQuota; label: string }[] = [
|
||||||
|
{ key: 'maxServers', label: 'Max Servers' },
|
||||||
|
{ key: 'maxUsers', label: 'Max Users' },
|
||||||
|
{ key: 'maxStandingOrders', label: 'Max Standing Orders' },
|
||||||
|
{ key: 'maxAgentTokensPerMonth', label: 'Max Agent Tokens / Month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-3">Resource Quotas</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{fields.map(({ key, label }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="block text-xs text-muted-foreground mb-1">{label}</label>
|
||||||
|
{editable ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-full border rounded px-2 py-1 text-sm bg-background"
|
||||||
|
value={quota[key]}
|
||||||
|
onChange={(e) => onChange({ ...quota, [key]: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">{quota[key].toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,450 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
host: string;
|
||||||
|
status: 'online' | 'offline' | 'maintenance';
|
||||||
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServersResponse {
|
||||||
|
data: Server[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalLine {
|
||||||
|
id: number;
|
||||||
|
type: 'input' | 'output' | 'system' | 'error';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let lineIdCounter = 0;
|
||||||
|
function nextLineId(): number {
|
||||||
|
return ++lineIdCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip common ANSI escape sequences for simplified rendering. */
|
||||||
|
function stripAnsi(text: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a WebSocket URL from the current page origin. */
|
||||||
|
function getWsBaseUrl(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
if (apiBase) {
|
||||||
|
try {
|
||||||
|
const url = new URL(apiBase);
|
||||||
|
const wsProto = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${wsProto}//${url.host}`;
|
||||||
|
} catch {
|
||||||
|
// Relative path -- fall through to use page origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${proto}//${window.location.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connection status indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: ConnectionStatus }) {
|
||||||
|
const colorMap: Record<ConnectionStatus, string> = {
|
||||||
|
connected: 'bg-green-500',
|
||||||
|
connecting: 'bg-yellow-500 animate-pulse',
|
||||||
|
disconnected: 'bg-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelMap: Record<ConnectionStatus, string> = {
|
||||||
|
connected: 'Connected',
|
||||||
|
connecting: 'Connecting...',
|
||||||
|
disconnected: 'Disconnected',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className={cn('w-2 h-2 rounded-full inline-block', colorMap[status])} />
|
||||||
|
{labelMap[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Terminal line component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TerminalLineRow({ line }: { line: TerminalLine }) {
|
||||||
|
const colorMap: Record<TerminalLine['type'], string> = {
|
||||||
|
output: 'text-green-400',
|
||||||
|
input: 'text-white',
|
||||||
|
system: 'text-blue-400',
|
||||||
|
error: 'text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('whitespace-pre-wrap break-all', colorMap[line.type])}>
|
||||||
|
{line.type === 'input' && <span className="text-gray-500 select-none">$ </span>}
|
||||||
|
{line.content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function TerminalPage() {
|
||||||
|
// ---- State ----
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState<string>('');
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
|
||||||
|
const [lines, setLines] = useState<TerminalLine[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
|
||||||
|
// ---- Refs ----
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const terminalEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// ---- Query: fetch servers ----
|
||||||
|
const { data: serversData, isLoading: serversLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.servers.list(),
|
||||||
|
queryFn: () => apiClient<ServersResponse>('/api/v1/inventory/servers'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const servers = serversData?.data ?? [];
|
||||||
|
|
||||||
|
// ---- Auto-scroll ----
|
||||||
|
useEffect(() => {
|
||||||
|
terminalEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [lines]);
|
||||||
|
|
||||||
|
// ---- Clean up WebSocket on unmount ----
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
wsRef.current?.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Helpers: add lines ----
|
||||||
|
const addLine = useCallback((type: TerminalLine['type'], content: string) => {
|
||||||
|
setLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: nextLineId(), type, content, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Connect ----
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!selectedServerId) return;
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
addLine('error', 'No authentication token found. Please log in first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedServer = servers.find((s) => s.id === selectedServerId);
|
||||||
|
const hostname = selectedServer?.hostname ?? selectedServerId;
|
||||||
|
|
||||||
|
setConnectionStatus('connecting');
|
||||||
|
addLine('system', `Connecting to ${hostname} (${selectedServer?.host ?? '...'})...`);
|
||||||
|
|
||||||
|
const wsBase = getWsBaseUrl();
|
||||||
|
const wsUrl = `${wsBase}/ws/terminal?serverId=${encodeURIComponent(selectedServerId)}&token=${encodeURIComponent(token)}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
addLine('system', `Connected to ${hostname}. Type commands below.`);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
if (parsed.type === 'output') {
|
||||||
|
text = parsed.data ?? '';
|
||||||
|
} else if (parsed.type === 'error') {
|
||||||
|
addLine('error', parsed.data ?? parsed.message ?? 'Unknown error');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
text = parsed.data ?? event.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Raw text message
|
||||||
|
text = event.data;
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
addLine('output', stripAnsi(text));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
addLine('error', 'WebSocket error occurred.');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
const reason = event.reason ? ` Reason: ${event.reason}` : '';
|
||||||
|
addLine('system', `Disconnected from ${hostname} (code ${event.code}).${reason}`);
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [selectedServerId, servers, addLine]);
|
||||||
|
|
||||||
|
// ---- Disconnect ----
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Send command ----
|
||||||
|
const sendCommand = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
|
addLine('error', 'Not connected. Please connect to a server first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLine('input', command);
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'input', data: command + '\n' }));
|
||||||
|
|
||||||
|
// Update command history
|
||||||
|
setCommandHistory((prev) => {
|
||||||
|
const updated = [...prev.filter((c) => c !== command), command];
|
||||||
|
if (updated.length > 100) updated.shift();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
},
|
||||||
|
[addLine],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Handle input key events ----
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const cmd = inputValue.trim();
|
||||||
|
if (!cmd) return;
|
||||||
|
sendCommand(cmd);
|
||||||
|
setInputValue('');
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (commandHistory.length === 0) return;
|
||||||
|
const newIndex =
|
||||||
|
historyIndex === -1
|
||||||
|
? commandHistory.length - 1
|
||||||
|
: Math.max(0, historyIndex - 1);
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
setInputValue(commandHistory[newIndex]);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIndex === -1) return;
|
||||||
|
const newIndex = historyIndex + 1;
|
||||||
|
if (newIndex >= commandHistory.length) {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
setInputValue('');
|
||||||
|
} else {
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
setInputValue(commandHistory[newIndex]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'l' && e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLines([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputValue, commandHistory, historyIndex, sendCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Focus terminal on click ----
|
||||||
|
const focusInput = useCallback(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Selected server info ----
|
||||||
|
const selectedServer = servers.find((s) => s.id === selectedServerId);
|
||||||
|
|
||||||
|
// ---- Render ----
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Terminal</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Remote shell access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusDot status={connectionStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
|
||||||
|
{/* Server selector */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<label htmlFor="server-select" className="text-sm font-medium whitespace-nowrap">
|
||||||
|
Server:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="server-select"
|
||||||
|
value={selectedServerId}
|
||||||
|
onChange={(e) => setSelectedServerId(e.target.value)}
|
||||||
|
disabled={connectionStatus === 'connected' || connectionStatus === 'connecting'}
|
||||||
|
className={cn(
|
||||||
|
'w-full max-w-xs px-3 py-2 rounded-md border border-input bg-background text-sm',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{serversLoading ? 'Loading servers...' : 'Select a server'}
|
||||||
|
</option>
|
||||||
|
{servers.map((server) => (
|
||||||
|
<option key={server.id} value={server.id}>
|
||||||
|
{server.hostname} ({server.host}) - {server.environment}
|
||||||
|
{server.status !== 'online' ? ` [${server.status}]` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connect / Disconnect buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{connectionStatus === 'disconnected' && (
|
||||||
|
<button
|
||||||
|
onClick={connect}
|
||||||
|
disabled={!selectedServerId}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm rounded-md font-medium transition-colors',
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(connectionStatus === 'connected' || connectionStatus === 'connecting') && (
|
||||||
|
<button
|
||||||
|
onClick={disconnect}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm rounded-md font-medium transition-colors',
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setLines([])}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
title="Clear terminal output"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal container */}
|
||||||
|
<div className="bg-gray-950 border rounded-lg overflow-hidden">
|
||||||
|
{/* Terminal header bar */}
|
||||||
|
<div className="bg-gray-900 px-4 py-2 flex items-center gap-2 border-b border-gray-800">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-green-500/80" />
|
||||||
|
<span className="ml-3 text-xs text-gray-400 font-mono select-none">
|
||||||
|
{selectedServer
|
||||||
|
? `${selectedServer.hostname} (${selectedServer.host})`
|
||||||
|
: 'No server selected'}
|
||||||
|
{connectionStatus === 'connected' && ' - connected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal body */}
|
||||||
|
<div
|
||||||
|
className="h-[calc(100vh-320px)] overflow-y-auto p-4 font-mono text-sm leading-relaxed cursor-text"
|
||||||
|
onClick={focusInput}
|
||||||
|
>
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<div className="text-gray-600 select-none">
|
||||||
|
{connectionStatus === 'connected'
|
||||||
|
? 'Terminal ready. Type a command and press Enter.'
|
||||||
|
: 'Select a server and click Connect to start a terminal session.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.map((line) => (
|
||||||
|
<TerminalLineRow key={line.id} line={line} />
|
||||||
|
))}
|
||||||
|
<div ref={terminalEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input line */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-gray-900 border-t border-gray-800">
|
||||||
|
<span className="text-green-500 font-mono text-sm select-none">$</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={connectionStatus !== 'connected'}
|
||||||
|
placeholder={
|
||||||
|
connectionStatus === 'connected'
|
||||||
|
? 'Type a command...'
|
||||||
|
: 'Connect to a server to start typing'
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'bg-transparent text-green-400 font-mono text-sm flex-1 outline-none',
|
||||||
|
'placeholder:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard shortcuts hint */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Enter</kbd> Send command
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Up/Down</kbd> Command history
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Ctrl+L</kbd> Clear terminal
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,777 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface UserDetail {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'operator' | 'viewer';
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
resource: string;
|
||||||
|
details: string;
|
||||||
|
ipAddress: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityResponse {
|
||||||
|
data: ActivityEntry[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditFormData {
|
||||||
|
displayName: string;
|
||||||
|
role: 'admin' | 'operator' | 'viewer';
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROLES: { value: UserDetail['role']; label: string }[] = [
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'operator', label: 'Operator' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUSES: { value: UserDetail['status']; label: string }[] = [
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'disabled', label: 'Disabled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 0) return 'just now';
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: UserDetail['role'] }) {
|
||||||
|
const styles: Record<UserDetail['role'], string> = {
|
||||||
|
admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
operator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
viewer: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[role],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: UserDetail['status'] }) {
|
||||||
|
const styles: Record<UserDetail['status'], string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
disabled: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Activity action badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ActionBadge({ action }: { action: string }) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
login: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
logout: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300',
|
||||||
|
create: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
update: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||||
|
delete: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
execute: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = map[action.toLowerCase()] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
style,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Info row component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 py-2">
|
||||||
|
<dt className="text-sm text-muted-foreground sm:w-36 shrink-0">{label}</dt>
|
||||||
|
<dd className="text-sm font-medium">{value || '--'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteDialog({
|
||||||
|
open,
|
||||||
|
userName,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
userName: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete User</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Are you sure you want to delete <strong>{userName}</strong>? This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
|
||||||
|
The user will lose all access and their sessions will be terminated immediately.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reset password confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ResetPasswordDialog({
|
||||||
|
open,
|
||||||
|
userName,
|
||||||
|
resetting,
|
||||||
|
success,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
userName: string;
|
||||||
|
resetting: boolean;
|
||||||
|
success: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Reset Password</h2>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<>
|
||||||
|
<div className="p-3 mb-4 rounded-md border border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400 text-sm">
|
||||||
|
A password reset link has been sent to <strong>{userName}</strong>'s email address.
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
This will send a password reset link to <strong>{userName}</strong>'s email
|
||||||
|
address. The user will need to set a new password.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={resetting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={resetting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resetting ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function UserDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [resetPasswordOpen, setResetPasswordOpen] = useState(false);
|
||||||
|
const [resetPasswordSuccess, setResetPasswordSuccess] = useState(false);
|
||||||
|
const [form, setForm] = useState<EditFormData>({
|
||||||
|
displayName: '',
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof EditFormData, string>>>({});
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
data: user,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.users.detail(id),
|
||||||
|
queryFn: () => apiClient<UserDetail>(`/api/v1/auth/users/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: activityData } = useQuery({
|
||||||
|
queryKey: queryKeys.users.activity(id),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient<ActivityResponse>(`/api/v1/auth/users/${id}/activity`),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activityLog = activityData?.data ?? [];
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<UserDetail>(`/api/v1/auth/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||||
|
setIsEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/auth/users/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||||
|
router.push('/users');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPasswordMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<void>(`/api/v1/auth/users/${id}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setResetPasswordSuccess(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
if (!user) return;
|
||||||
|
setForm({
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setIsEditing(true);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const cancelEditing = useCallback(() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(field: keyof EditFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof EditFormData, string>> = {};
|
||||||
|
if (!form.displayName.trim()) next.displayName = 'Display name is required';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!validate()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
displayName: form.displayName.trim(),
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
updateMutation.mutate(body);
|
||||||
|
}, [form, validate, updateMutation]);
|
||||||
|
|
||||||
|
const handleResetPasswordClose = useCallback(() => {
|
||||||
|
setResetPasswordOpen(false);
|
||||||
|
setResetPasswordSuccess(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/users')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Users
|
||||||
|
</button>
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading user details...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/users')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Users
|
||||||
|
</button>
|
||||||
|
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load user: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/users')}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Users
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{user.displayName}</h1>
|
||||||
|
<StatusBadge status={user.status} />
|
||||||
|
<RoleBadge role={user.role} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* User Information Card */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">User Information</h2>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
/* ---------- Edit form ---------- */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* displayName */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Display Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={(e) => handleChange('displayName', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.displayName ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
{errors.displayName && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.displayName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => handleChange('role', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => handleChange('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update error */}
|
||||||
|
{updateMutation.isError && (
|
||||||
|
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to update user: {(updateMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ---------- Read-only info display ---------- */
|
||||||
|
<dl className="divide-y">
|
||||||
|
<InfoRow label="Display Name" value={user.displayName} />
|
||||||
|
<InfoRow label="Email" value={user.email} />
|
||||||
|
<InfoRow label="Role" value={<RoleBadge role={user.role} />} />
|
||||||
|
<InfoRow label="Status" value={<StatusBadge status={user.status} />} />
|
||||||
|
<InfoRow label="Tenant" value={user.tenantName || '--'} />
|
||||||
|
<InfoRow label="Last Login" value={formatDateTime(user.lastLoginAt)} />
|
||||||
|
<InfoRow label="Created" value={formatDateTime(user.createdAt)} />
|
||||||
|
<InfoRow label="Updated" value={formatDateTime(user.updatedAt)} />
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Activity Log</h2>
|
||||||
|
|
||||||
|
{activityLog.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No activity recorded yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Action</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Resource</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Details</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">IP Address</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activityLog.map((entry) => (
|
||||||
|
<tr
|
||||||
|
key={entry.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<ActionBadge action={entry.action} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{entry.resource || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground truncate max-w-[250px]">
|
||||||
|
{entry.details || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||||
|
{entry.ipAddress || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
|
||||||
|
{formatRelativeTime(entry.createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={startEditing}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
Edit User
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordSuccess(false);
|
||||||
|
setResetPasswordOpen(true);
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors text-left flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
<line x1="10" x2="10" y1="11" y2="17" />
|
||||||
|
<line x1="14" x2="14" y1="11" y2="17" />
|
||||||
|
</svg>
|
||||||
|
Delete User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Summary */}
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Account Summary</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Status</p>
|
||||||
|
<StatusBadge status={user.status} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Role</p>
|
||||||
|
<RoleBadge role={user.role} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Last Login</p>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'Never'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Member Since</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(user.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
{user.tenantName && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Tenant</p>
|
||||||
|
<p className="text-sm font-medium">{user.tenantName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
userName={user.displayName}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Reset password dialog */}
|
||||||
|
<ResetPasswordDialog
|
||||||
|
open={resetPasswordOpen}
|
||||||
|
userName={user.displayName}
|
||||||
|
resetting={resetPasswordMutation.isPending}
|
||||||
|
success={resetPasswordSuccess}
|
||||||
|
onClose={handleResetPasswordClose}
|
||||||
|
onConfirm={() => resetPasswordMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,833 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
role: 'admin' | 'operator' | 'viewer';
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersResponse {
|
||||||
|
data: User[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFormData {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: 'admin' | 'operator' | 'viewer';
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditUserFormData {
|
||||||
|
role: 'admin' | 'operator' | 'viewer';
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROLES: { value: User['role']; label: string }[] = [
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'operator', label: 'Operator' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUSES: { value: User['status']; label: string }[] = [
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'disabled', label: 'Disabled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FORM: UserFormData = {
|
||||||
|
displayName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'viewer',
|
||||||
|
tenantId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_EDIT_FORM: EditUserFormData = {
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: User['role'] }) {
|
||||||
|
const styles: Record<User['role'], string> = {
|
||||||
|
admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||||
|
operator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
viewer: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[role],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: User['status'] }) {
|
||||||
|
const styles: Record<User['status'], string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||||
|
disabled: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
||||||
|
styles[status],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add user dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AddUserDialog({
|
||||||
|
open,
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
form: UserFormData;
|
||||||
|
errors: Partial<Record<keyof UserFormData, string>>;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof UserFormData, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Add User</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
{/* displayName */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Display Name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={(e) => onChange('displayName', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.displayName ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
{errors.displayName && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.displayName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Email <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => onChange('email', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.email ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Password <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => onChange('password', e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
||||||
|
errors.password ? 'border-destructive' : 'border-input',
|
||||||
|
)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => onChange('role', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tenantId */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tenant ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tenantId}
|
||||||
|
onChange={(e) => onChange('tenantId', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="Optional tenant ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Creating...' : 'Create User'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit user dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EditUserDialog({
|
||||||
|
open,
|
||||||
|
userName,
|
||||||
|
form,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
userName: string;
|
||||||
|
form: EditUserFormData;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: (field: keyof EditUserFormData, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
Edit User: <span className="text-muted-foreground">{userName}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* role */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => onChange('role', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => onChange('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete user confirmation dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DeleteUserDialog({
|
||||||
|
open,
|
||||||
|
userName,
|
||||||
|
deleting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
userName: string;
|
||||||
|
deleting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Delete User</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Are you sure you want to delete <strong>{userName}</strong>? This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
|
||||||
|
The user will lose all access and their sessions will be terminated immediately.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// State ----------------------------------------------------------------
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||||
|
const [form, setForm] = useState<UserFormData>({ ...EMPTY_FORM });
|
||||||
|
const [editForm, setEditForm] = useState<EditUserFormData>({ ...EMPTY_EDIT_FORM });
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof UserFormData, string>>>({});
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
// Queries --------------------------------------------------------------
|
||||||
|
const { data: usersData, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.users.list(),
|
||||||
|
queryFn: () => apiClient<UsersResponse>('/api/v1/auth/users'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allUsers = usersData?.data ?? [];
|
||||||
|
|
||||||
|
// Filter ---------------------------------------------------------------
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
let result = allUsers;
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase().trim();
|
||||||
|
result = result.filter(
|
||||||
|
(u) =>
|
||||||
|
u.displayName.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleFilter !== 'all') {
|
||||||
|
result = result.filter((u) => u.role === roleFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
result = result.filter((u) => u.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [allUsers, search, roleFilter, statusFilter]);
|
||||||
|
|
||||||
|
// Mutations ------------------------------------------------------------
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
apiClient<User>('/api/v1/auth/users', { method: 'POST', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||||
|
closeAddDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||||
|
apiClient<User>(`/api/v1/auth/users/${id}`, { method: 'PUT', body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||||
|
closeEditDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiClient<void>(`/api/v1/auth/users/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers --------------------------------------------------------------
|
||||||
|
const validateAdd = useCallback((): boolean => {
|
||||||
|
const next: Partial<Record<keyof UserFormData, string>> = {};
|
||||||
|
if (!form.displayName.trim()) next.displayName = 'Display name is required';
|
||||||
|
if (!form.email.trim()) next.email = 'Email is required';
|
||||||
|
if (!form.password.trim()) next.password = 'Password is required';
|
||||||
|
if (form.password.trim() && form.password.trim().length < 8)
|
||||||
|
next.password = 'Password must be at least 8 characters';
|
||||||
|
setErrors(next);
|
||||||
|
return Object.keys(next).length === 0;
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const closeAddDialog = useCallback(() => {
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeEditDialog = useCallback(() => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setEditForm({ ...EMPTY_EDIT_FORM });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAdd = useCallback(() => {
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setErrors({});
|
||||||
|
setAddDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openEdit = useCallback((user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditForm({
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddChange = useCallback(
|
||||||
|
(field: keyof UserFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditChange = useCallback(
|
||||||
|
(field: keyof EditUserFormData, value: string) => {
|
||||||
|
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddSubmit = useCallback(() => {
|
||||||
|
if (!validateAdd()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
displayName: form.displayName.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
};
|
||||||
|
if (form.tenantId.trim()) {
|
||||||
|
body.tenantId = form.tenantId.trim();
|
||||||
|
}
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}, [form, validateAdd, createMutation]);
|
||||||
|
|
||||||
|
const handleEditSubmit = useCallback(() => {
|
||||||
|
if (!editingUser) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
role: editForm.role,
|
||||||
|
status: editForm.status,
|
||||||
|
};
|
||||||
|
updateMutation.mutate({ id: editingUser.id, body });
|
||||||
|
}, [editForm, editingUser, updateMutation]);
|
||||||
|
|
||||||
|
// Render ---------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Users</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage user accounts and access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAdd}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4 mb-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 max-w-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role filter */}
|
||||||
|
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleFilter('all')}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
roleFilter === 'all'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All Roles
|
||||||
|
</button>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
onClick={() => setRoleFilter(r.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
roleFilter === r.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
statusFilter === 'all'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => setStatusFilter(s.value)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md font-medium transition-colors',
|
||||||
|
statusFilter === s.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
||||||
|
Failed to load users: {(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||||
|
Loading users...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users table */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Email</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Tenant</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Last Login</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={8}
|
||||||
|
className="text-center text-muted-foreground py-12"
|
||||||
|
>
|
||||||
|
{allUsers.length === 0
|
||||||
|
? 'No users found. Add one to get started.'
|
||||||
|
: 'No users match the current filters.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/users/${user.id}`)}
|
||||||
|
className="font-medium hover:text-primary transition-colors text-left"
|
||||||
|
>
|
||||||
|
{user.displayName}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<RoleBadge role={user.role} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{user.tenantName || '--'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={user.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{formatDateTime(user.lastLoginAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||||
|
{formatDate(user.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/users/${user.id}`)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(user)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(user)}
|
||||||
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
{!isLoading && !error && allUsers.length > 0 && (
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Showing {filteredUsers.length} of {allUsers.length} user{allUsers.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add user dialog */}
|
||||||
|
<AddUserDialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
form={form}
|
||||||
|
errors={errors}
|
||||||
|
saving={createMutation.isPending}
|
||||||
|
onClose={closeAddDialog}
|
||||||
|
onChange={handleAddChange}
|
||||||
|
onSubmit={handleAddSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit user dialog */}
|
||||||
|
<EditUserDialog
|
||||||
|
open={!!editingUser}
|
||||||
|
userName={editingUser?.displayName ?? ''}
|
||||||
|
form={editForm}
|
||||||
|
saving={updateMutation.isPending}
|
||||||
|
onClose={closeEditDialog}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
onSubmit={handleEditSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete user dialog */}
|
||||||
|
<DeleteUserDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
userName={deleteTarget?.displayName ?? ''}
|
||||||
|
deleting={deleteMutation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: { id: string; email: string; name: string; roles: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient<LoginResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', data.accessToken);
|
||||||
|
localStorage.setItem('refresh_token', data.refreshToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold">IT0</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">Admin Console</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-input border rounded-md"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-input border rounded-md"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return proxyRequest(request, params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return proxyRequest(request, params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return proxyRequest(request, params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return proxyRequest(request, params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyRequest(request: NextRequest, path: string[]) {
|
||||||
|
const url = `${API_BASE_URL}/${path.join('/')}${request.nextUrl.search}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
request.headers.forEach((value, key) => {
|
||||||
|
if (!['host', 'connection'].includes(key.toLowerCase())) {
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = request.method !== 'GET' ? await request.text() : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.text();
|
||||||
|
return new NextResponse(data, {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
import { Providers } from './providers';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'IT0 Admin Console',
|
||||||
|
description: 'IT Operations Intelligent Agent - Administration Console',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { createStore } from '@/stores/redux/store';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(() => new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [store] = useState(() => createStore());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReduxProvider store={store}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ReduxProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export interface AgentConfigDto {
|
||||||
|
activeEngine: string;
|
||||||
|
engines: Record<string, Record<string, unknown>>;
|
||||||
|
systemPrompt: string;
|
||||||
|
hookScripts: HookScriptDto[];
|
||||||
|
allowedTools: string[];
|
||||||
|
maxTurns: number;
|
||||||
|
maxBudgetUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookScriptDto {
|
||||||
|
id: string;
|
||||||
|
event: string;
|
||||||
|
toolPattern: string;
|
||||||
|
scriptPath: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type { AgentConfigDto, HookScriptDto } from './agent-config.dto';
|
||||||
|
export type { CreateRunbookDto, UpdateRunbookDto, RunbookExecutionDto } from './runbook.dto';
|
||||||
|
export type { CreateServerDto, UpdateServerDto } from './server.dto';
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
export interface CreateRunbookDto {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
triggerType: 'manual' | 'alert' | 'scheduled';
|
||||||
|
promptTemplate: string;
|
||||||
|
allowedTools: string[];
|
||||||
|
maxRiskLevel: number;
|
||||||
|
autoApprove: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRunbookDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
triggerType?: 'manual' | 'alert' | 'scheduled';
|
||||||
|
promptTemplate?: string;
|
||||||
|
allowedTools?: string[];
|
||||||
|
maxRiskLevel?: number;
|
||||||
|
autoApprove?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunbookExecutionDto {
|
||||||
|
id: string;
|
||||||
|
runbookId: string;
|
||||||
|
status: 'running' | 'completed' | 'failed';
|
||||||
|
triggeredBy: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
export interface CreateServerDto {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
role: string;
|
||||||
|
clusterId?: string;
|
||||||
|
networkType: 'public' | 'private';
|
||||||
|
jumpServerId?: string;
|
||||||
|
tags: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateServerDto {
|
||||||
|
name?: string;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
environment?: 'dev' | 'staging' | 'prod';
|
||||||
|
role?: string;
|
||||||
|
clusterId?: string;
|
||||||
|
networkType?: 'public' | 'private';
|
||||||
|
jumpServerId?: string;
|
||||||
|
status?: 'active' | 'inactive' | 'maintenance';
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { switchEngine } from './switch-engine';
|
||||||
|
export { updateSystemPrompt } from './update-system-prompt';
|
||||||
|
export { getHooks, updateHook, createHook, deleteHook } from './manage-hooks';
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { HookScriptDto } from '@/application/dto/agent-config.dto';
|
||||||
|
|
||||||
|
export async function getHooks(): Promise<HookScriptDto[]> {
|
||||||
|
return apiClient<HookScriptDto[]>('/api/v1/agent/config/hooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHook(hook: HookScriptDto): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/agent/config/hooks/${hook.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: hook,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHook(hook: Omit<HookScriptDto, 'id'>): Promise<HookScriptDto> {
|
||||||
|
return apiClient<HookScriptDto>('/api/v1/agent/config/hooks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: hook,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHook(hookId: string): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/agent/config/hooks/${hookId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
|
export async function switchEngine(engineType: string): Promise<void> {
|
||||||
|
await apiClient('/api/v1/agent/config/engine', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { engineType },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
|
export async function updateSystemPrompt(prompt: string): Promise<void> {
|
||||||
|
await apiClient('/api/v1/agent/config/system-prompt', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { prompt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { HealthCheckResult } from '@/domain/entities/health-check';
|
||||||
|
|
||||||
|
export interface HealthCheckConfig {
|
||||||
|
serverId: string;
|
||||||
|
checkType: 'ping' | 'tcp' | 'http';
|
||||||
|
intervalSeconds: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHealthChecks(params?: Record<string, string>): Promise<HealthCheckResult[]> {
|
||||||
|
const query = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return apiClient<HealthCheckResult[]>(`/api/v1/monitoring/health-checks${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configureHealthCheck(config: HealthCheckConfig): Promise<void> {
|
||||||
|
await apiClient('/api/v1/monitoring/health-checks/configure', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerHealthCheck(serverId: string): Promise<HealthCheckResult> {
|
||||||
|
return apiClient<HealthCheckResult>(`/api/v1/monitoring/health-checks/${serverId}/trigger`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { AlertRule } from '@/domain/entities/alert-rule';
|
||||||
|
|
||||||
|
export async function createAlertRule(
|
||||||
|
rule: Omit<AlertRule, 'id' | 'createdAt' | 'updatedAt'>,
|
||||||
|
): Promise<AlertRule> {
|
||||||
|
return apiClient<AlertRule>('/api/v1/monitoring/alert-rules', {
|
||||||
|
method: 'POST',
|
||||||
|
body: rule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAlertRule(id: string, data: Partial<AlertRule>): Promise<AlertRule> {
|
||||||
|
return apiClient<AlertRule>(`/api/v1/monitoring/alert-rules/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAlertRule(id: string): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/monitoring/alert-rules/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleAlertRule(id: string, enabled: boolean): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/monitoring/alert-rules/${id}/toggle`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { createAlertRule, updateAlertRule, deleteAlertRule, toggleAlertRule } from './create-alert-rule';
|
||||||
|
export { listHealthChecks, configureHealthCheck, triggerHealthCheck } from './configure-health-check';
|
||||||
|
export type { HealthCheckConfig } from './configure-health-check';
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { CreateRunbookDto } from '@/application/dto/runbook.dto';
|
||||||
|
import type { Runbook } from '@/domain/entities/runbook';
|
||||||
|
|
||||||
|
export async function createRunbook(data: CreateRunbookDto): Promise<Runbook> {
|
||||||
|
return apiClient<Runbook>('/api/v1/runbooks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
|
export async function deleteRunbook(id: string): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/runbooks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { createRunbook } from './create-runbook';
|
||||||
|
export { updateRunbook } from './update-runbook';
|
||||||
|
export { deleteRunbook } from './delete-runbook';
|
||||||
|
export { testRunbook } from './test-runbook';
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { RunbookExecution } from '@/domain/entities/runbook';
|
||||||
|
|
||||||
|
export async function testRunbook(id: string): Promise<RunbookExecution> {
|
||||||
|
return apiClient<RunbookExecution>(`/api/v1/runbooks/${id}/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { dryRun: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { UpdateRunbookDto } from '@/application/dto/runbook.dto';
|
||||||
|
import type { Runbook } from '@/domain/entities/runbook';
|
||||||
|
|
||||||
|
export async function updateRunbook(id: string, data: UpdateRunbookDto): Promise<Runbook> {
|
||||||
|
return apiClient<Runbook>(`/api/v1/runbooks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { listRiskRules, createRiskRule, updateRiskRule, deleteRiskRule } from './manage-risk-rules';
|
||||||
|
export { listCredentials, createCredential, updateCredential, deleteCredential } from './manage-credentials';
|
||||||
|
export { getPermissionMatrix, updateRolePermissions, listRoles } from './manage-permissions';
|
||||||
|
export type { PermissionMatrix } from './manage-permissions';
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { Credential } from '@/domain/entities/credential';
|
||||||
|
|
||||||
|
export async function listCredentials(params?: Record<string, string>): Promise<Credential[]> {
|
||||||
|
const query = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return apiClient<Credential[]>(`/api/v1/security/credentials${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCredential(
|
||||||
|
credential: Omit<Credential, 'id' | 'createdAt' | 'updatedAt' | 'associatedServers'>,
|
||||||
|
): Promise<Credential> {
|
||||||
|
return apiClient<Credential>('/api/v1/security/credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
body: credential,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCredential(id: string, data: Partial<Credential>): Promise<Credential> {
|
||||||
|
return apiClient<Credential>(`/api/v1/security/credentials/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCredential(id: string): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/security/credentials/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { Role } from '@/domain/entities/role';
|
||||||
|
|
||||||
|
export interface PermissionMatrix {
|
||||||
|
roles: Role[];
|
||||||
|
allPermissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermissionMatrix(): Promise<PermissionMatrix> {
|
||||||
|
return apiClient<PermissionMatrix>('/api/v1/security/permissions/matrix');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRolePermissions(roleId: string, permissions: string[]): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/security/permissions/roles/${roleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { permissions },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRoles(): Promise<Role[]> {
|
||||||
|
return apiClient<Role[]>('/api/v1/security/roles');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { RiskRule } from '@/domain/entities/risk-rule';
|
||||||
|
|
||||||
|
export async function listRiskRules(params?: Record<string, string>): Promise<RiskRule[]> {
|
||||||
|
const query = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||||
|
return apiClient<RiskRule[]>(`/api/v1/security/risk-rules${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRiskRule(
|
||||||
|
rule: Omit<RiskRule, 'id' | 'createdAt' | 'updatedAt'>,
|
||||||
|
): Promise<RiskRule> {
|
||||||
|
return apiClient<RiskRule>('/api/v1/security/risk-rules', {
|
||||||
|
method: 'POST',
|
||||||
|
body: rule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRiskRule(id: string, data: Partial<RiskRule>): Promise<RiskRule> {
|
||||||
|
return apiClient<RiskRule>(`/api/v1/security/risk-rules/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRiskRule(id: string): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/security/risk-rules/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { CreateServerDto } from '@/application/dto/server.dto';
|
||||||
|
import type { Server } from '@/domain/entities/server';
|
||||||
|
|
||||||
|
export async function addServer(data: CreateServerDto): Promise<Server> {
|
||||||
|
return apiClient<Server>('/api/v1/servers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { addServer } from './add-server';
|
||||||
|
export { updateServer } from './update-server';
|
||||||
|
export { removeServer } from './remove-server';
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
|
||||||
|
export async function removeServer(id: string): Promise<void> {
|
||||||
|
await apiClient(`/api/v1/servers/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import type { UpdateServerDto } from '@/application/dto/server.dto';
|
||||||
|
import type { Server } from '@/domain/entities/server';
|
||||||
|
|
||||||
|
export async function updateServer(id: string, data: UpdateServerDto): Promise<Server> {
|
||||||
|
return apiClient<Server>(`/api/v1/servers/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export type EngineType = 'claude_code_cli' | 'claude_api' | 'custom';
|
||||||
|
|
||||||
|
export interface AgentEngine {
|
||||||
|
type: EngineType;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
isActive: boolean;
|
||||||
|
healthStatus: 'healthy' | 'degraded' | 'unavailable';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
export interface AgentSession {
|
||||||
|
id: string;
|
||||||
|
taskDescription: string;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
commandCount: number;
|
||||||
|
serverTargets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
type:
|
||||||
|
| 'command_executed'
|
||||||
|
| 'output_received'
|
||||||
|
| 'approval_requested'
|
||||||
|
| 'approval_granted'
|
||||||
|
| 'approval_denied'
|
||||||
|
| 'error'
|
||||||
|
| 'session_started'
|
||||||
|
| 'session_completed';
|
||||||
|
timestamp: string;
|
||||||
|
data: {
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
riskLevel?: string;
|
||||||
|
error?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
export interface AlertRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
metric: string;
|
||||||
|
condition: 'gt' | 'lt' | 'gte' | 'lte' | 'eq';
|
||||||
|
threshold: number;
|
||||||
|
duration: string;
|
||||||
|
severity: 'info' | 'warning' | 'critical';
|
||||||
|
enabled: boolean;
|
||||||
|
targetServers: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertEvent {
|
||||||
|
id: string;
|
||||||
|
ruleId: string;
|
||||||
|
ruleName: string;
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
severity: 'info' | 'warning' | 'critical';
|
||||||
|
status: 'firing' | 'resolved' | 'acknowledged';
|
||||||
|
message: string;
|
||||||
|
value: number;
|
||||||
|
firedAt: string;
|
||||||
|
resolvedAt?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface AuditLog {
|
||||||
|
id: number;
|
||||||
|
actionType: string;
|
||||||
|
actorType: 'agent' | 'user' | 'system';
|
||||||
|
actorId?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface Cluster {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
tags: string[];
|
||||||
|
serverIds: string[];
|
||||||
|
serverCount: number;
|
||||||
|
healthySummary: { online: number; offline: number; maintenance: number };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface Contact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
channels: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export interface Credential {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
authType: 'password' | 'ssh_key' | 'ssh_key_with_passphrase';
|
||||||
|
description: string;
|
||||||
|
associatedServers: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface EscalationPolicy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
steps: EscalationStep[];
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EscalationStep {
|
||||||
|
severity: 'info' | 'warning' | 'critical';
|
||||||
|
channels: string[];
|
||||||
|
delayMinutes: number;
|
||||||
|
contactIds: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface HealthCheckResult {
|
||||||
|
id: string;
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
serverHost: string;
|
||||||
|
checkType: 'ping' | 'tcp' | 'http';
|
||||||
|
status: 'healthy' | 'degraded' | 'down';
|
||||||
|
latencyMs: number;
|
||||||
|
uptimePercent: number;
|
||||||
|
lastCheckedAt: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface HookScript {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
event: 'PreToolUse' | 'PostToolUse' | 'PreNotification' | 'PostNotification';
|
||||||
|
toolPattern: string;
|
||||||
|
script: string;
|
||||||
|
timeout: number;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export type { Server } from './server';
|
||||||
|
export type { StandingOrder } from './standing-order';
|
||||||
|
export type { AuditLog } from './audit-log';
|
||||||
|
export type { Skill } from './skill';
|
||||||
|
export type { HookScript } from './hook-script';
|
||||||
|
export type { Runbook, RunbookExecution } from './runbook';
|
||||||
|
export type { AlertRule, AlertEvent } from './alert-rule';
|
||||||
|
export type { Credential } from './credential';
|
||||||
|
export type { Cluster } from './cluster';
|
||||||
|
export type { HealthCheckResult } from './health-check';
|
||||||
|
export type { AgentSession, SessionEvent } from './agent-session';
|
||||||
|
export type { Tenant } from './tenant';
|
||||||
|
export type { User } from './user';
|
||||||
|
export type { EscalationPolicy, EscalationStep } from './escalation-policy';
|
||||||
|
export type { RiskRule } from './risk-rule';
|
||||||
|
export type { AgentEngine, EngineType } from './agent-engine';
|
||||||
|
export type { Role } from './role';
|
||||||
|
export type { Session } from './session';
|
||||||
|
export type { Contact } from './contact';
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface RiskRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pattern: string;
|
||||||
|
riskLevel: 0 | 1 | 2 | 3;
|
||||||
|
action: 'allow' | 'block' | 'require_approval';
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
description?: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
export interface Runbook {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
triggerType: 'manual' | 'alert' | 'scheduled';
|
||||||
|
promptTemplate: string;
|
||||||
|
allowedTools: string[];
|
||||||
|
maxRiskLevel: number;
|
||||||
|
autoApprove: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunbookExecution {
|
||||||
|
id: string;
|
||||||
|
runbookId: string;
|
||||||
|
status: 'running' | 'completed' | 'failed';
|
||||||
|
triggeredBy: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
export interface Server {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
|
role: string;
|
||||||
|
clusterId?: string;
|
||||||
|
networkType: 'public' | 'private';
|
||||||
|
jumpServerId?: string;
|
||||||
|
status: 'active' | 'inactive' | 'maintenance';
|
||||||
|
tags: Record<string, string>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
status: 'active' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
engineType: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
commandCount: number;
|
||||||
|
tokensUsed?: number;
|
||||||
|
summary?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: 'inspection' | 'deployment' | 'maintenance' | 'security' | 'monitoring' | 'custom';
|
||||||
|
script: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export interface StandingOrder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
trigger: {
|
||||||
|
type: 'cron' | 'event' | 'threshold' | 'manual';
|
||||||
|
cronExpression?: string;
|
||||||
|
eventType?: string;
|
||||||
|
};
|
||||||
|
targets: {
|
||||||
|
serverIds?: string[];
|
||||||
|
clusterIds?: string[];
|
||||||
|
allServers?: boolean;
|
||||||
|
environmentFilter?: string[];
|
||||||
|
};
|
||||||
|
status: 'active' | 'paused' | 'archived';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
plan: 'free' | 'pro' | 'enterprise';
|
||||||
|
ownerId: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
quotas: {
|
||||||
|
maxServers: number;
|
||||||
|
maxUsers: number;
|
||||||
|
maxSkills: number;
|
||||||
|
maxRunbooks: number;
|
||||||
|
maxStandingOrders: number;
|
||||||
|
};
|
||||||
|
usage: {
|
||||||
|
servers: number;
|
||||||
|
users: number;
|
||||||
|
skills: number;
|
||||||
|
runbooks: number;
|
||||||
|
standingOrders: number;
|
||||||
|
};
|
||||||
|
enabledEngines: string[];
|
||||||
|
status: 'active' | 'suspended' | 'trial';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'operator' | 'viewer' | 'readonly';
|
||||||
|
tenantId: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
lastLoginAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { AgentConfigDto } from '@/application/dto/agent-config.dto';
|
||||||
|
|
||||||
|
export interface AgentConfigRepository {
|
||||||
|
getConfig(): Promise<AgentConfigDto>;
|
||||||
|
saveConfig(config: Partial<AgentConfigDto>): Promise<void>;
|
||||||
|
switchEngine(engineType: string): Promise<void>;
|
||||||
|
getSystemPrompt(): Promise<string>;
|
||||||
|
updateSystemPrompt(prompt: string): Promise<void>;
|
||||||
|
getHooks(): Promise<AgentConfigDto['hookScripts']>;
|
||||||
|
updateHook(hook: AgentConfigDto['hookScripts'][number]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { AlertRule, AlertEvent } from '@/domain/entities/alert-rule';
|
||||||
|
|
||||||
|
export interface AlertRuleRepository {
|
||||||
|
listRules(): Promise<AlertRule[]>;
|
||||||
|
getRuleById(id: string): Promise<AlertRule>;
|
||||||
|
createRule(rule: Omit<AlertRule, 'id' | 'createdAt' | 'updatedAt'>): Promise<AlertRule>;
|
||||||
|
updateRule(id: string, data: Partial<AlertRule>): Promise<AlertRule>;
|
||||||
|
removeRule(id: string): Promise<void>;
|
||||||
|
toggleRule(id: string, enabled: boolean): Promise<void>;
|
||||||
|
listEvents(params?: Record<string, string>): Promise<AlertEvent[]>;
|
||||||
|
acknowledgeEvent(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { AuditLog } from '@/domain/entities/audit-log';
|
||||||
|
|
||||||
|
export interface AuditRepository {
|
||||||
|
list(params?: Record<string, string>): Promise<AuditLog[]>;
|
||||||
|
getById(id: number): Promise<AuditLog>;
|
||||||
|
exportLogs(params?: Record<string, string>): Promise<Blob>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue