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:
hailin 2026-02-08 22:54:37 -08:00
commit 00f8801d51
552 changed files with 71949 additions and 0 deletions

38
.env.example Normal file
View File

@ -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

90
.gitignore vendored Normal file
View File

@ -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

45
README.md Normal file
View File

@ -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

View File

@ -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=

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: it0
labels:
app: it0

View File

@ -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

View File

@ -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

3095
docs/backend-guide.md Normal file

File diff suppressed because it is too large Load Diff

2105
docs/flutter-guide.md Normal file

File diff suppressed because it is too large Load Diff

1984
docs/web-admin-guide.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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;

9512
it0-web-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -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 &quot;Add Hook&quot; 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>
);
}

View File

@ -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>
);
}

View File

@ -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"
>
&larr; 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"
>
&larr; 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"
>
&larr; 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"
>
&larr; 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>
);
}

View File

@ -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>
);
}

View File

@ -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')}>
&#9654;
</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>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -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 &quot;Add Contact&quot; 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 &quot;Add Policy&quot; 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>
);
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"
>
&larr; 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"
>
&larr; 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"
>
&larr; 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>
);
}

View File

@ -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 &quot;New Runbook&quot; 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"
>
&times;
</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 &amp; 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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',
)}
>
&#9654;
</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>
);
}

View File

@ -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

View File

@ -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 &quot;New Standing Order&quot; 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',
)}
>
&#9654;
</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"
>
&times;
</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>&apos;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>&apos;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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 });
}
}

View File

@ -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>
);
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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>;
}

View File

@ -0,0 +1,3 @@
export { switchEngine } from './switch-engine';
export { updateSystemPrompt } from './update-system-prompt';
export { getHooks, updateHook, createHook, deleteHook } from './manage-hooks';

View File

@ -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',
});
}

View File

@ -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 },
});
}

View File

@ -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 },
});
}

View File

@ -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',
});
}

View File

@ -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 },
});
}

View File

@ -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';

View File

@ -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,
});
}

View File

@ -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',
});
}

View File

@ -0,0 +1,4 @@
export { createRunbook } from './create-runbook';
export { updateRunbook } from './update-runbook';
export { deleteRunbook } from './delete-runbook';
export { testRunbook } from './test-runbook';

View File

@ -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 },
});
}

View File

@ -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,
});
}

View File

@ -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';

View File

@ -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',
});
}

View File

@ -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');
}

View File

@ -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',
});
}

View File

@ -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,
});
}

View File

@ -0,0 +1,3 @@
export { addServer } from './add-server';
export { updateServer } from './update-server';
export { removeServer } from './remove-server';

View File

@ -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',
});
}

View File

@ -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,
});
}

View File

@ -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';
}

View File

@ -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;
};
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
export interface Contact {
id: string;
name: string;
email?: string;
phone?: string;
role?: string;
channels: string[];
isActive: boolean;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
export interface Role {
id: string;
name: string;
permissions: string[];
description?: string;
isSystem: boolean;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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