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