Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

Submodule letsbe-hub deleted from 17f4cf765c

44
letsbe-hub/.dockerignore Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
.next
out
build
dist
# Testing
coverage
# Environment files (secrets!)
.env
.env.*
!.env.example
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
*.md
!README.md
LICENSE
deploy/

34
letsbe-hub/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# LetsBe Hub Configuration
# Database
DATABASE_URL=postgresql://hub:hub@db:5432/hub
# Admin API Key (CHANGE IN PRODUCTION!)
ADMIN_API_KEY=change-me-in-production
# Debug mode
DEBUG=false
# Telemetry retention (days)
TELEMETRY_RETENTION_DAYS=90
# =============================================================================
# Email (Resend)
# =============================================================================
# API key from https://resend.com
# RESEND_API_KEY=re_xxxxxxxxxx
# Sender email address (must be verified in Resend)
# RESEND_FROM_EMAIL=noreply@yourdomain.com
# =============================================================================
# Cron / Scheduled Tasks
# =============================================================================
# Secret used to authenticate cron job requests
# Generate with: openssl rand -hex 32
# CRON_SECRET=
# =============================================================================
# Public API
# =============================================================================
# API key exposed to client-side code (non-sensitive, for rate limiting etc.)
# PUBLIC_API_KEY=

View File

@@ -0,0 +1,24 @@
# Database
DATABASE_URL="postgresql://letsbe:letsbe@localhost:5432/letsbe_hub"
# NextAuth.js
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here-change-in-production"
# Stripe (Phase 5)
# STRIPE_SECRET_KEY="sk_test_..."
# STRIPE_WEBHOOK_SECRET="whsec_..."
# Entri DNS API (Phase 3)
# ENTRI_APP_ID="..."
# ENTRI_SECRET="..."
# Runner Authentication
RUNNER_TOKEN="change-me-in-production"
# Admin Setup
ADMIN_EMAIL="admin@letsbe.solutions"
ADMIN_PASSWORD="change-me-in-production"
# Hub Internal URL (for runners)
HUB_INTERNAL_URL="http://localhost:3000"

View File

@@ -0,0 +1,79 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
tags:
- 'v*'
pull_request:
branches:
- main
- master
env:
REGISTRY: code.letsbe.solutions
IMAGE_NAME: letsbe/hub
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Generate Prisma client
run: npx prisma generate
- name: Run TypeScript check
run: npm run typecheck
- name: Run linter
run: npm run lint --if-present
build:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

57
letsbe-hub/.gitignore vendored Normal file
View File

@@ -0,0 +1,57 @@
# Dependencies
node_modules/
.pnp/
.pnp.js
# Build outputs
.next/
out/
dist/
build/
# Testing
coverage/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
deploy/.env
!.env.example
!.env.local.example
!deploy/.env.example
# IDE
.idea/
.vscode/
*.swp
*.swo
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Typescript
*.tsbuildinfo
next-env.d.ts
# Misc
.DS_Store
*.pem
Thumbs.db
# Serena
.serena/
# Vercel
.vercel
# Prisma
prisma/*.db
prisma/*.db-journal
# Job runtime data
jobs/

292
letsbe-hub/CLAUDE.md Normal file
View File

@@ -0,0 +1,292 @@
# CLAUDE.md — LetsBe Hub
## Purpose
You are the engineering assistant for the LetsBe Hub Dashboard.
This is the admin dashboard and API for managing the LetsBe Cloud platform.
The Hub provides:
- **Admin Dashboard**: Next.js admin UI for platform management
- **Customer Management**: Create/manage customers and subscriptions
- **Order Management**: Process and track provisioning orders
- **Server Monitoring**: View and manage tenant servers
- **Netcup Integration**: Full server management via Netcup SCP API
- **Token Usage Tracking**: Monitor AI token consumption
## Tech Stack
- **Next.js 15** (App Router)
- **TypeScript** (strict mode)
- **Prisma** (PostgreSQL ORM)
- **TanStack Query** (React Query v5)
- **Tailwind CSS** + shadcn/ui components
- **NextAuth.js** (authentication)
## Project Structure
```
src/
├── app/ # Next.js App Router
│ ├── admin/ # Admin dashboard pages
│ │ ├── customers/ # Customer management
│ │ │ └── [id]/ # Customer detail with order creation
│ │ ├── orders/ # Order management
│ │ │ └── [id]/ # Order detail with DNS, provisioning
│ │ ├── servers/ # Server monitoring
│ │ │ └── netcup/ # Netcup servers management
│ │ │ └── [id]/ # Netcup server detail
│ │ ├── settings/ # Admin settings
│ │ └── layout.tsx # Admin layout with sidebar
│ ├── api/v1/ # API routes
│ │ ├── admin/ # Admin API endpoints
│ │ │ ├── customers/ # Customer CRUD
│ │ │ ├── orders/ # Order CRUD + provisioning
│ │ │ ├── netcup/ # Netcup SCP API integration
│ │ │ └── servers/ # Server management
│ │ └── public/ # Public API endpoints
│ └── (auth)/ # Authentication pages
├── components/
│ ├── admin/ # Admin-specific components
│ │ ├── create-order-dialog.tsx # Order creation wizard
│ │ ├── netcup-auth-setup.tsx # Netcup OAuth setup
│ │ ├── netcup-server-link.tsx # Link orders to Netcup servers
│ │ └── dns-verification-panel.tsx # DNS verification UI
│ └── ui/ # Reusable UI components (shadcn/ui)
├── hooks/ # React Query hooks
│ ├── use-customers.ts # Customer data hooks
│ ├── use-orders.ts # Order data hooks
│ ├── use-netcup.ts # Netcup API hooks
│ └── use-dns.ts # DNS verification hooks
├── lib/ # Utilities and shared code
│ ├── prisma.ts # Prisma client singleton
│ └── services/ # Backend services
│ ├── netcup-service.ts # Netcup SCP API client
│ ├── dns-service.ts # DNS verification service
│ └── settings-service.ts # System settings storage
└── types/ # TypeScript type definitions
```
## API Routes
### Admin Endpoints (authenticated)
```
# Customers
GET /api/v1/admin/customers # List customers
GET /api/v1/admin/customers/[id] # Get customer detail
PATCH /api/v1/admin/customers/[id] # Update customer
# Orders
GET /api/v1/admin/orders # List orders
POST /api/v1/admin/orders # Create order
GET /api/v1/admin/orders/[id] # Get order detail
PATCH /api/v1/admin/orders/[id] # Update order
GET /api/v1/admin/orders/[id]/logs # Get provisioning logs (SSE)
POST /api/v1/admin/orders/[id]/provision # Start provisioning
# DNS Verification
GET /api/v1/admin/orders/[id]/dns # Get DNS status
POST /api/v1/admin/orders/[id]/dns/verify # Trigger DNS verification
POST /api/v1/admin/orders/[id]/dns/skip # Manual DNS override
# Servers
GET /api/v1/admin/servers # List servers (derived from orders)
# Netcup Integration
GET /api/v1/admin/netcup/auth # Get auth status / poll for token
POST /api/v1/admin/netcup/auth # Initiate device auth flow
DELETE /api/v1/admin/netcup/auth # Disconnect Netcup account
GET /api/v1/admin/netcup/servers # List all Netcup servers
GET /api/v1/admin/netcup/servers/[id] # Get server detail
POST /api/v1/admin/netcup/servers/[id]/power # Power actions
POST /api/v1/admin/netcup/servers/[id]/rescue # Rescue mode
GET /api/v1/admin/netcup/servers/[id]/metrics # Performance metrics
GET /api/v1/admin/netcup/servers/[id]/snapshots # List snapshots
POST /api/v1/admin/netcup/servers/[id]/snapshots # Create snapshot
# Dashboard
GET /api/v1/admin/dashboard/stats # Dashboard statistics
```
## Netcup SCP Integration
The Hub integrates with Netcup's Server Control Panel API for full server management.
### Authentication
Uses OAuth2 Device Flow:
1. Hub initiates device auth, gets `user_code` and `verification_uri`
2. User visits Netcup and enters the code
3. Hub polls for token exchange
4. Tokens stored in `SystemSettings` table
5. Access tokens auto-refresh (5min expiry, offline refresh token)
### Capabilities
- **Server List**: View all Netcup servers with live status
- **Power Control**: ON/OFF/POWERCYCLE/RESET/POWEROFF
- **Rescue Mode**: Activate/deactivate rescue system
- **Metrics**: CPU, disk I/O, network throughput (up to 30 days)
- **Snapshots**: Create, list, delete, revert snapshots
- **Server Linking**: Link orders to Netcup servers by IP
### Key Service: `netcup-service.ts`
```typescript
// Core methods
netcupService.initiateDeviceAuth() // Start OAuth flow
netcupService.pollForToken(deviceCode) // Complete OAuth
netcupService.getServers() // List with IPs from interfaces
netcupService.getServer(id, liveInfo) // Detail with live status
netcupService.powerAction(id, action) // Power control
netcupService.getServerInterfaces(id) // Get IP addresses
netcupService.getAllMetrics(id, hours) // CPU/disk/network metrics
```
## Development Commands
```bash
# Start database (required first)
docker compose up -d
# Install dependencies
npm install
# Start development server
npm run dev
# Run database migrations
npx prisma migrate dev
# Generate Prisma client
npx prisma generate
# Seed database
npm run db:seed
# Type checking
npm run typecheck
# Build for production
npm run build
# App available at http://localhost:3000
```
## Key Patterns
### React Query Hooks
All data fetching uses React Query hooks in `src/hooks/`:
- `useCustomers()`, `useCustomer(id)` - Customer data
- `useOrders()`, `useOrder(id)` - Order data
- `useServers()` - Server list
- `useDashboardStats()` - Dashboard metrics
- `useNetcupServers()`, `useNetcupServer(id)` - Netcup servers
- `useNetcupAuth()` - Netcup authentication status
- `useServerMetrics(id, hours)` - Server performance metrics
- `useServerSnapshots(id)` - Server snapshots
Mutations follow the pattern:
- `useCreateOrder()`, `useUpdateOrder()`
- `useNetcupPowerAction()`, `useNetcupRescue()`
- `useCreateSnapshot()`, `useDeleteSnapshot()`, `useRevertSnapshot()`
- Automatic cache invalidation via `queryClient.invalidateQueries()`
### API Route Pattern
```typescript
export async function GET(request: NextRequest) {
// Auth check
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const searchParams = request.nextUrl.searchParams
// Database query with Prisma
const data = await prisma.model.findMany({...})
// Return JSON response
return NextResponse.json(data)
}
```
### Component Pattern
```typescript
'use client'
export function MyComponent() {
const { data, isLoading, error } = useMyData()
if (isLoading) return <Skeleton />
if (error) return <ErrorMessage error={error} />
return <div>...</div>
}
```
### Service Pattern
Backend services in `src/lib/services/`:
```typescript
class MyService {
private static instance: MyService
static getInstance(): MyService {
if (!MyService.instance) {
MyService.instance = new MyService()
}
return MyService.instance
}
async doSomething(): Promise<Result> {
// Implementation
}
}
export const myService = MyService.getInstance()
```
## Database Schema (Key Models)
```prisma
model Customer {
id String @id @default(cuid())
name String
email String @unique
company String?
orders Order[]
}
model Order {
id String @id @default(cuid())
status OrderStatus
domain String
customerId String
serverIp String?
serverPassword String?
netcupServerId String? # Linked Netcup server
automationMode AutomationMode @default(MANUAL)
customer Customer @relation(...)
dnsVerification DnsVerification?
}
model SystemSettings {
id String @id @default(cuid())
key String @unique
value String @db.Text
}
```
## Coding Conventions
- Use `'use client'` directive for client components
- All API routes return `NextResponse.json()`
- Use Prisma for all database operations
- Follow existing shadcn/ui component patterns
- Use React Query for server state management
- TypeScript strict mode - no `any` types
- Services are singletons exported from `lib/services/`
- Environment variables in `.env.local` (never commit)

87
letsbe-hub/Dockerfile Normal file
View File

@@ -0,0 +1,87 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install
# Generate Prisma Client (Prisma 7 uses prisma.config.mjs for datasource URL)
COPY prisma ./prisma/
COPY prisma.config.mjs ./
RUN npx prisma generate
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Ensure public directory exists
RUN mkdir -p public
# Next.js telemetry
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install Docker CLI for spawning provisioning containers
RUN apk add --no-cache docker-cli
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Add nextjs user to docker group for socket access
# Note: The actual docker group GID might differ - using 999 as common default
RUN addgroup -g 999 docker || true
RUN addgroup nextjs docker || true
# Create jobs and logs directories for provisioning
RUN mkdir -p /app/jobs /app/logs
RUN chown -R nextjs:nodejs /app/jobs /app/logs
# Create public directory and copy contents if they exist
RUN mkdir -p public
COPY --from=builder /app/public/. ./public/
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma client and schema (for runtime + migrations)
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
COPY prisma ./prisma/
COPY prisma.config.mjs ./
# Install Prisma CLI globally for running migrations on startup
# (copying just node_modules/prisma misses transitive deps like valibot)
RUN npm install -g prisma@7
# Copy startup script (runs migrations before starting app)
# Use tr to strip Windows CRLF line endings (more reliable than sed on Alpine)
COPY startup.sh /tmp/startup.sh
RUN tr -d '\r' < /tmp/startup.sh > startup.sh && chmod +x startup.sh && rm /tmp/startup.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["./startup.sh"]

View File

@@ -0,0 +1,46 @@
# LetsBe Hub Production Configuration
# Copy this file to .env and fill in the values
# =============================================================================
# REQUIRED - Must be set before deployment
# =============================================================================
# Hub public URL (used for auth callbacks and runner communication)
HUB_URL=https://hub.yourdomain.com
# Database password (generate a strong random password)
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE
# NextAuth secret (generate with: openssl rand -base64 32)
NEXTAUTH_SECRET=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_BASE64_32
# Credential encryption key (generate with: openssl rand -hex 32)
CREDENTIAL_ENCRYPTION_KEY=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_HEX_32
# Settings encryption key (generate with: openssl rand -hex 32)
SETTINGS_ENCRYPTION_KEY=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_HEX_32
# =============================================================================
# OPTIONAL - Defaults are usually fine
# =============================================================================
# Database settings
POSTGRES_USER=letsbe_hub
POSTGRES_DB=letsbe_hub
# Hub port (change if 3000 is occupied)
HUB_PORT=3847
# Hub image tag (default: master)
HUB_IMAGE_TAG=master
# Ansible Runner settings
DOCKER_REGISTRY_URL=code.letsbe.solutions
DOCKER_IMAGE_NAME=letsbe/ansible-runner
DOCKER_IMAGE_TAG=master
DOCKER_MAX_CONCURRENT=3
# Host paths for job configs (runner containers need access)
# These directories will be created automatically by Docker
JOBS_HOST_DIR=/opt/letsbe-hub/jobs
LOGS_HOST_DIR=/opt/letsbe-hub/logs

View File

@@ -0,0 +1,51 @@
services:
db:
image: postgres:16-alpine
container_name: letsbe-hub-db
env_file: .env
environment:
POSTGRES_USER: ${POSTGRES_USER:-letsbe_hub}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-letsbe_hub}
volumes:
- hub-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-letsbe_hub} -d ${POSTGRES_DB:-letsbe_hub}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
hub:
image: code.letsbe.solutions/letsbe/hub:${HUB_IMAGE_TAG:-master}
container_name: letsbe-hub-app
env_file: .env
ports:
- "127.0.0.1:${HUB_PORT:-3847}:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-letsbe_hub}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-letsbe_hub}
NEXTAUTH_URL: ${HUB_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
AUTH_TRUST_HOST: "true"
HUB_URL: ${HUB_URL}
CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY}
SETTINGS_ENCRYPTION_KEY: ${SETTINGS_ENCRYPTION_KEY}
DOCKER_REGISTRY_URL: ${DOCKER_REGISTRY_URL:-code.letsbe.solutions}
DOCKER_IMAGE_NAME: ${DOCKER_IMAGE_NAME:-letsbe/ansible-runner}
DOCKER_IMAGE_TAG: ${DOCKER_IMAGE_TAG:-master}
DOCKER_MAX_CONCURRENT: ${DOCKER_MAX_CONCURRENT:-3}
JOBS_HOST_DIR: ${JOBS_HOST_DIR:-/opt/letsbe-hub/jobs}
LOGS_HOST_DIR: ${LOGS_HOST_DIR:-/opt/letsbe-hub/logs}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${JOBS_HOST_DIR:-/opt/letsbe-hub/jobs}:/app/jobs
- ${LOGS_HOST_DIR:-/opt/letsbe-hub/logs}:/app/logs
user: "0:0"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
hub-db-data:
name: letsbe-hub-db

View File

@@ -0,0 +1,44 @@
# LetsBe Hub - Nginx Configuration
# Place this in /etc/nginx/sites-available/hub.conf
# Then: ln -s /etc/nginx/sites-available/hub.conf /etc/nginx/sites-enabled/
# Then: nginx -t && systemctl reload nginx
# Then: certbot --nginx -d hub.letsbe.solutions
server {
listen 80;
listen [::]:80;
server_name hub.letsbe.solutions;
# Logging
access_log /var/log/nginx/hub.access.log;
error_log /var/log/nginx/hub.error.log;
# Proxy to Hub container
location / {
proxy_pass http://127.0.0.1:3847;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket/SSE support (for log streaming)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts (longer for SSE streams)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 300s;
# Buffering (disable for SSE)
proxy_buffering off;
proxy_cache off;
# Max body size
client_max_body_size 50M;
}
}

View File

@@ -0,0 +1,65 @@
#!/bin/bash
#
# LetsBe Hub Production Setup Script
#
# Usage: ./setup.sh
#
set -e
echo "=== LetsBe Hub Setup ==="
echo ""
# Check if .env exists
if [[ ! -f .env ]]; then
echo "ERROR: .env file not found!"
echo "Copy .env.example to .env and fill in the values first."
exit 1
fi
# Source .env to get variables
source .env
# Validate required variables
REQUIRED_VARS=(
"HUB_URL"
"HUB_DOMAIN"
"POSTGRES_PASSWORD"
"NEXTAUTH_SECRET"
"CREDENTIAL_ENCRYPTION_KEY"
"SETTINGS_ENCRYPTION_KEY"
)
for var in "${REQUIRED_VARS[@]}"; do
if [[ -z "${!var}" || "${!var}" == *"CHANGE_ME"* ]]; then
echo "ERROR: $var is not set or still has placeholder value"
exit 1
fi
done
echo "1. Creating job directories..."
JOBS_DIR="${JOBS_HOST_DIR:-/opt/letsbe-hub/jobs}"
LOGS_DIR="${LOGS_HOST_DIR:-/opt/letsbe-hub/logs}"
mkdir -p "$JOBS_DIR" "$LOGS_DIR"
chmod 755 "$JOBS_DIR" "$LOGS_DIR"
echo " Created: $JOBS_DIR"
echo " Created: $LOGS_DIR"
echo ""
echo "2. Logging into Gitea registry..."
echo " (You will need your Gitea username and password/token)"
docker login code.letsbe.solutions
echo ""
echo "3. Pulling images..."
docker pull code.letsbe.solutions/letsbe/hub:${HUB_IMAGE_TAG:-master}
docker pull code.letsbe.solutions/letsbe/ansible-runner:${DOCKER_IMAGE_TAG:-master}
docker pull postgres:16-alpine
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Now run: docker compose up -d"
echo ""
echo "Then configure Docker Hub and Gitea credentials in:"
echo " ${HUB_URL}/admin/settings"

View File

@@ -0,0 +1,64 @@
services:
db:
image: postgres:16-alpine
container_name: letsbe-hub-db
environment:
POSTGRES_USER: letsbe_hub
POSTGRES_PASSWORD: letsbe_hub_dev
POSTGRES_DB: letsbe_hub
ports:
- "5433:5432"
volumes:
- hub-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U letsbe_hub -d letsbe_hub"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
hub:
build:
context: .
dockerfile: Dockerfile
container_name: letsbe-hub-app
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://letsbe_hub:letsbe_hub_dev@db:5432/letsbe_hub
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: dev-secret-change-in-production-min-32-chars
AUTH_TRUST_HOST: "true"
HUB_URL: http://host.docker.internal:3000
# Use local Docker images (no registry)
DOCKER_REGISTRY_URL: ""
# Encryption key for storing sensitive credentials (Portainer passwords, etc.)
CREDENTIAL_ENCRYPTION_KEY: letsbe-hub-credential-encryption-key-dev-only
# Encryption key for settings service (SMTP passwords, tokens, etc.)
SETTINGS_ENCRYPTION_KEY: letsbe-hub-settings-encryption-key-dev-only
# Email sending via Resend (optional in dev)
# RESEND_API_KEY: ""
# RESEND_FROM_EMAIL: ""
# Cron job secret for scheduled tasks
# CRON_SECRET: ""
# Public API key for client-side usage
# PUBLIC_API_KEY: ""
# Host paths for job config files (used when spawning runner containers)
# On Windows with Docker Desktop, use /c/Repos/... format
JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs
LOGS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/logs
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Use bind mounts for jobs/logs so spawned runner containers can access them
- ./jobs:/app/jobs
- ./logs:/app/logs
# Run as root to access Docker socket (needed for spawning provisioning containers)
user: "0:0"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
hub-db-data:
name: letsbe-hub-db

View File

@@ -0,0 +1,27 @@
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
{
ignores: [
'node_modules/**',
'.next/**',
'out/**',
'dist/**',
'build/**',
],
},
...tseslint.configs.recommended,
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-require-imports': 'off',
},
},
);

59
letsbe-hub/hub.nginx.conf Normal file
View File

@@ -0,0 +1,59 @@
# Nginx configuration for LetsBe Hub
# Usage:
# 1. Copy to /etc/nginx/sites-available/hub.letsbe.biz
# 2. Create symlink: ln -s /etc/nginx/sites-available/hub.letsbe.biz /etc/nginx/sites-enabled/
# 3. Create placeholder certs (for initial nginx -t):
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
# -keyout /etc/nginx/placeholder.key -out /etc/nginx/placeholder.crt \
# -subj "/CN=placeholder"
# 4. Test: nginx -t
# 5. Reload: systemctl reload nginx
# 6. Get real certs: certbot --nginx -d hub.letsbe.biz
server {
listen 80;
server_name hub.letsbe.biz;
location / {
return 301 https://$host$request_uri;
}
location ^~ /.well-known/acme-challenge/ {
alias /var/www/html/.well-known/acme-challenge/;
default_type "text/plain";
allow all;
}
}
server {
listen 443 ssl http2;
server_name hub.letsbe.biz;
# Placeholder certs - certbot will replace these
ssl_certificate /etc/nginx/placeholder.crt;
ssl_certificate_key /etc/nginx/placeholder.key;
# SSL settings (certbot will add its own)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
location / {
proxy_pass http://127.0.0.1:8200;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (if needed later)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location ^~ /.well-known/acme-challenge/ {
alias /var/www/html/.well-known/acme-challenge/;
default_type "text/plain";
allow all;
}
}

89
letsbe-hub/next.config.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { NextConfig } from 'next'
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.letsbe.solutions",
"font-src 'self' data:",
"connect-src 'self' https://*.letsbe.solutions",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
]
const nextConfig: NextConfig = {
output: 'standalone',
// reactCompiler: true, // Requires babel-plugin-react-compiler - enable later
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.letsbe.solutions',
},
],
},
// Turbopack config (Next.js 16 default bundler)
turbopack: {},
// Handle native modules like ssh2 (for webpack fallback)
webpack: (config, { isServer }) => {
if (isServer) {
// Externalize ssh2 and its native dependencies
config.externals = config.externals || []
config.externals.push({
'ssh2': 'commonjs ssh2',
})
}
return config
},
// Externalize ssh2 for both Turbopack and Webpack
serverExternalPackages: ['ssh2'],
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
export default nextConfig

14830
letsbe-hub/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

92
letsbe-hub/package.json Normal file
View File

@@ -0,0 +1,92 @@
{
"name": "letsbe-hub",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
"@aws-sdk/client-s3": "^3.968.0",
"@hookform/resolvers": "^3.9.1",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-table": "^8.20.6",
"@types/ssh2": "^1.15.5",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",
"next": "16.1.1",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.4",
"nodemailer": "^7.0.12",
"otplib": "^13.1.0",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.54.2",
"recharts": "^3.6.0",
"ssh2": "^1.17.0",
"stripe": "^17.7.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"undici": "^7.18.2",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@testing-library/react": "^16.3.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/nodemailer": "^7.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.20",
"dotenv": "^16.5.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^27.0.1",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prisma": "^7.0.0",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.53.0",
"vitest": "^4.0.16",
"vitest-mock-extended": "^3.1.0"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
module.exports = config

View File

@@ -0,0 +1,20 @@
// Load .env file for local development (silently skip if not available)
try {
const dotenv = await import('dotenv')
dotenv.config()
} catch {
// dotenv not available (e.g., in Docker) - env vars passed directly
}
// DATABASE_URL is optional during build (prisma generate), required for migrations
const databaseUrl = process.env.DATABASE_URL || 'postgresql://placeholder:placeholder@localhost:5432/placeholder'
export default {
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: databaseUrl,
},
}

View File

@@ -0,0 +1,272 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('PENDING_VERIFICATION', 'ACTIVE', 'SUSPENDED');
-- CreateEnum
CREATE TYPE "StaffRole" AS ENUM ('ADMIN', 'SUPPORT');
-- CreateEnum
CREATE TYPE "SubscriptionPlan" AS ENUM ('TRIAL', 'STARTER', 'PRO', 'ENTERPRISE');
-- CreateEnum
CREATE TYPE "SubscriptionTier" AS ENUM ('HUB_DASHBOARD', 'ADVANCED');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'CANCELED', 'PAST_DUE');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PAYMENT_CONFIRMED', 'AWAITING_SERVER', 'SERVER_READY', 'DNS_PENDING', 'DNS_READY', 'PROVISIONING', 'FULFILLED', 'EMAIL_CONFIGURED', 'FAILED');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'CLAIMED', 'RUNNING', 'COMPLETED', 'FAILED', 'DEAD');
-- CreateEnum
CREATE TYPE "LogLevel" AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR');
-- CreateEnum
CREATE TYPE "ServerConnectionStatus" AS ENUM ('PENDING', 'REGISTERED', 'ONLINE', 'OFFLINE');
-- CreateEnum
CREATE TYPE "CommandStatus" AS ENUM ('PENDING', 'SENT', 'EXECUTING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"company" TEXT,
"status" "UserStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION',
"email_verified" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "staff" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "StaffRole" NOT NULL DEFAULT 'SUPPORT',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "staff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "subscriptions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"plan" "SubscriptionPlan" NOT NULL DEFAULT 'TRIAL',
"tier" "SubscriptionTier" NOT NULL DEFAULT 'HUB_DASHBOARD',
"token_limit" INTEGER NOT NULL DEFAULT 10000,
"tokens_used" INTEGER NOT NULL DEFAULT 0,
"trial_ends_at" TIMESTAMP(3),
"stripe_customer_id" TEXT,
"stripe_subscription_id" TEXT,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'TRIAL',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PAYMENT_CONFIRMED',
"tier" "SubscriptionTier" NOT NULL,
"domain" TEXT NOT NULL,
"tools" TEXT[],
"config_json" JSONB NOT NULL,
"server_ip" TEXT,
"server_password_encrypted" TEXT,
"ssh_port" INTEGER NOT NULL DEFAULT 22,
"portainer_url" TEXT,
"dashboard_url" TEXT,
"failure_reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"server_ready_at" TIMESTAMP(3),
"provisioning_started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "provisioning_logs" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"level" "LogLevel" NOT NULL DEFAULT 'INFO',
"message" TEXT NOT NULL,
"step" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "provisioning_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "provisioning_jobs" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"job_type" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
"priority" INTEGER NOT NULL DEFAULT 0,
"claimed_at" TIMESTAMP(3),
"claimed_by" TEXT,
"container_name" TEXT,
"attempt" INTEGER NOT NULL DEFAULT 1,
"max_attempts" INTEGER NOT NULL DEFAULT 3,
"next_retry_at" TIMESTAMP(3),
"config_snapshot" JSONB NOT NULL,
"runner_token_hash" TEXT,
"result" JSONB,
"error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3),
CONSTRAINT "provisioning_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_logs" (
"id" TEXT NOT NULL,
"job_id" TEXT NOT NULL,
"level" "LogLevel" NOT NULL DEFAULT 'INFO',
"message" TEXT NOT NULL,
"step" TEXT,
"progress" INTEGER,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "job_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "token_usage" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"instance_id" TEXT,
"operation" TEXT NOT NULL,
"tokens_input" INTEGER NOT NULL,
"tokens_output" INTEGER NOT NULL,
"model" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "token_usage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "runner_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"last_used" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "runner_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "server_connections" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"registration_token" TEXT NOT NULL,
"hub_api_key" TEXT,
"orchestrator_url" TEXT,
"agent_version" TEXT,
"status" "ServerConnectionStatus" NOT NULL DEFAULT 'PENDING',
"registered_at" TIMESTAMP(3),
"last_heartbeat" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "server_connections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "remote_commands" (
"id" TEXT NOT NULL,
"server_connection_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" "CommandStatus" NOT NULL DEFAULT 'PENDING',
"result" JSONB,
"error_message" TEXT,
"queued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sent_at" TIMESTAMP(3),
"executed_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"initiated_by" TEXT,
CONSTRAINT "remote_commands_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "staff_email_key" ON "staff"("email");
-- CreateIndex
CREATE INDEX "provisioning_logs_order_id_timestamp_idx" ON "provisioning_logs"("order_id", "timestamp");
-- CreateIndex
CREATE INDEX "provisioning_jobs_status_priority_created_at_idx" ON "provisioning_jobs"("status", "priority", "created_at");
-- CreateIndex
CREATE INDEX "provisioning_jobs_order_id_idx" ON "provisioning_jobs"("order_id");
-- CreateIndex
CREATE INDEX "job_logs_job_id_timestamp_idx" ON "job_logs"("job_id", "timestamp");
-- CreateIndex
CREATE INDEX "token_usage_user_id_created_at_idx" ON "token_usage"("user_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "runner_tokens_token_hash_key" ON "runner_tokens"("token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_order_id_key" ON "server_connections"("order_id");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_registration_token_key" ON "server_connections"("registration_token");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_hub_api_key_key" ON "server_connections"("hub_api_key");
-- CreateIndex
CREATE INDEX "remote_commands_server_connection_id_status_idx" ON "remote_commands"("server_connection_id", "status");
-- CreateIndex
CREATE INDEX "remote_commands_status_queued_at_idx" ON "remote_commands"("status", "queued_at");
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "provisioning_logs" ADD CONSTRAINT "provisioning_logs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "provisioning_jobs" ADD CONSTRAINT "provisioning_jobs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_logs" ADD CONSTRAINT "job_logs_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "provisioning_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "token_usage" ADD CONSTRAINT "token_usage_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "server_connections" ADD CONSTRAINT "server_connections_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "remote_commands" ADD CONSTRAINT "remote_commands_server_connection_id_fkey" FOREIGN KEY ("server_connection_id") REFERENCES "server_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "company_name" TEXT,
ADD COLUMN "customer" TEXT,
ADD COLUMN "license_key" TEXT;
-- CreateTable
CREATE TABLE "system_settings" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"encrypted" BOOLEAN NOT NULL DEFAULT false,
"category" TEXT NOT NULL DEFAULT 'general',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "system_settings_key_key" ON "system_settings"("key");

View File

@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "AutomationMode" AS ENUM ('AUTO', 'MANUAL', 'PAUSED');
-- CreateEnum
CREATE TYPE "DnsRecordStatus" AS ENUM ('PENDING', 'VERIFIED', 'MISMATCH', 'NOT_FOUND', 'ERROR', 'SKIPPED');
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "automationMode" "AutomationMode" NOT NULL DEFAULT 'MANUAL',
ADD COLUMN "automation_paused_at" TIMESTAMP(3),
ADD COLUMN "automation_paused_reason" TEXT,
ADD COLUMN "dns_verified_at" TIMESTAMP(3),
ADD COLUMN "netcup_server_id" TEXT,
ADD COLUMN "source" TEXT;
-- CreateTable
CREATE TABLE "dns_verifications" (
"id" TEXT NOT NULL,
"order_id" TEXT NOT NULL,
"wildcard_passed" BOOLEAN NOT NULL DEFAULT false,
"manual_override" BOOLEAN NOT NULL DEFAULT false,
"all_passed" BOOLEAN NOT NULL DEFAULT false,
"total_subdomains" INTEGER NOT NULL DEFAULT 0,
"passed_count" INTEGER NOT NULL DEFAULT 0,
"last_checked_at" TIMESTAMP(3),
"verified_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dns_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dns_records" (
"id" TEXT NOT NULL,
"dns_verification_id" TEXT NOT NULL,
"subdomain" TEXT NOT NULL,
"full_domain" TEXT NOT NULL,
"expected_ip" TEXT NOT NULL,
"resolved_ip" TEXT,
"status" "DnsRecordStatus" NOT NULL DEFAULT 'PENDING',
"error_message" TEXT,
"checked_at" TIMESTAMP(3),
CONSTRAINT "dns_records_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "dns_verifications_order_id_key" ON "dns_verifications"("order_id");
-- CreateIndex
CREATE INDEX "dns_records_dns_verification_id_idx" ON "dns_records"("dns_verification_id");
-- AddForeignKey
ALTER TABLE "dns_verifications" ADD CONSTRAINT "dns_verifications_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dns_records" ADD CONSTRAINT "dns_records_dns_verification_id_fkey" FOREIGN KEY ("dns_verification_id") REFERENCES "dns_verifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "credentials_synced_at" TIMESTAMP(3),
ADD COLUMN "portainer_password_enc" TEXT,
ADD COLUMN "portainer_username" TEXT;

View File

@@ -0,0 +1,143 @@
-- CreateEnum
CREATE TYPE "ErrorSeverity" AS ENUM ('INFO', 'WARNING', 'ERROR', 'CRITICAL');
-- CreateTable
CREATE TABLE "enterprise_clients" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"company_name" TEXT,
"contact_email" TEXT NOT NULL,
"contact_phone" TEXT,
"notes" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "enterprise_clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "enterprise_servers" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"netcup_server_id" TEXT NOT NULL,
"nickname" TEXT,
"purpose" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"portainer_url" TEXT,
"portainer_username" TEXT,
"portainer_password_enc" TEXT,
CONSTRAINT "enterprise_servers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "server_stats_snapshots" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cpu_percent" DOUBLE PRECISION,
"memory_used_mb" DOUBLE PRECISION,
"memory_total_mb" DOUBLE PRECISION,
"disk_read_mbps" DOUBLE PRECISION,
"disk_write_mbps" DOUBLE PRECISION,
"network_in_mbps" DOUBLE PRECISION,
"network_out_mbps" DOUBLE PRECISION,
"containers_running" INTEGER,
"containers_stopped" INTEGER,
CONSTRAINT "server_stats_snapshots_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "error_detection_rules" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"pattern" TEXT NOT NULL,
"severity" "ErrorSeverity" NOT NULL DEFAULT 'WARNING',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "error_detection_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "detected_errors" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"rule_id" TEXT NOT NULL,
"container_id" TEXT,
"container_name" TEXT,
"log_line" TEXT NOT NULL,
"context" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acknowledged_at" TIMESTAMP(3),
"acknowledged_by" TEXT,
CONSTRAINT "detected_errors_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "security_verification_codes" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"action" TEXT NOT NULL,
"target_server_id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "security_verification_codes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "enterprise_servers_client_id_idx" ON "enterprise_servers"("client_id");
-- CreateIndex
CREATE UNIQUE INDEX "enterprise_servers_client_id_netcup_server_id_key" ON "enterprise_servers"("client_id", "netcup_server_id");
-- CreateIndex
CREATE INDEX "server_stats_snapshots_server_id_timestamp_idx" ON "server_stats_snapshots"("server_id", "timestamp");
-- CreateIndex
CREATE INDEX "server_stats_snapshots_client_id_timestamp_idx" ON "server_stats_snapshots"("client_id", "timestamp");
-- CreateIndex
CREATE INDEX "error_detection_rules_client_id_idx" ON "error_detection_rules"("client_id");
-- CreateIndex
CREATE INDEX "detected_errors_server_id_timestamp_idx" ON "detected_errors"("server_id", "timestamp");
-- CreateIndex
CREATE INDEX "detected_errors_rule_id_timestamp_idx" ON "detected_errors"("rule_id", "timestamp");
-- CreateIndex
CREATE INDEX "security_verification_codes_client_id_code_idx" ON "security_verification_codes"("client_id", "code");
-- AddForeignKey
ALTER TABLE "enterprise_servers" ADD CONSTRAINT "enterprise_servers_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "server_stats_snapshots" ADD CONSTRAINT "server_stats_snapshots_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "server_stats_snapshots" ADD CONSTRAINT "server_stats_snapshots_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "error_detection_rules" ADD CONSTRAINT "error_detection_rules_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "detected_errors" ADD CONSTRAINT "detected_errors_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "detected_errors" ADD CONSTRAINT "detected_errors_rule_id_fkey" FOREIGN KEY ("rule_id") REFERENCES "error_detection_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "security_verification_codes" ADD CONSTRAINT "security_verification_codes_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,84 @@
-- CreateEnum
CREATE TYPE "ContainerEventType" AS ENUM ('CRASH', 'OOM_KILLED', 'RESTART', 'STOPPED');
-- CreateTable
CREATE TABLE "log_scan_positions" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"last_line_count" INTEGER NOT NULL DEFAULT 0,
"last_log_hash" TEXT,
"last_scanned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "log_scan_positions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "container_state_snapshots" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"container_name" TEXT NOT NULL,
"state" TEXT NOT NULL,
"exit_code" INTEGER,
"captured_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "container_state_snapshots_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "container_events" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"container_name" TEXT NOT NULL,
"event_type" "ContainerEventType" NOT NULL,
"exit_code" INTEGER,
"details" TEXT,
"acknowledged_at" TIMESTAMP(3),
"acknowledged_by" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "container_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_settings" (
"id" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"critical_errors_only" BOOLEAN NOT NULL DEFAULT true,
"container_crashes" BOOLEAN NOT NULL DEFAULT true,
"recipients" TEXT[] DEFAULT ARRAY[]::TEXT[],
"cooldown_minutes" INTEGER NOT NULL DEFAULT 30,
"last_notified_at" TIMESTAMP(3),
CONSTRAINT "notification_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "log_scan_positions_server_id_container_id_key" ON "log_scan_positions"("server_id", "container_id");
-- CreateIndex
CREATE INDEX "container_state_snapshots_server_id_container_id_captured_a_idx" ON "container_state_snapshots"("server_id", "container_id", "captured_at");
-- CreateIndex
CREATE INDEX "container_events_server_id_timestamp_idx" ON "container_events"("server_id", "timestamp");
-- CreateIndex
CREATE INDEX "container_events_event_type_acknowledged_at_idx" ON "container_events"("event_type", "acknowledged_at");
-- CreateIndex
CREATE UNIQUE INDEX "notification_settings_client_id_key" ON "notification_settings"("client_id");
-- AddForeignKey
ALTER TABLE "log_scan_positions" ADD CONSTRAINT "log_scan_positions_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "container_state_snapshots" ADD CONSTRAINT "container_state_snapshots_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "container_events" ADD CONSTRAINT "container_events_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_settings" ADD CONSTRAINT "notification_settings_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,46 @@
-- CreateEnum
CREATE TYPE "StaffStatus" AS ENUM ('ACTIVE', 'SUSPENDED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "StaffRole" ADD VALUE 'OWNER';
ALTER TYPE "StaffRole" ADD VALUE 'MANAGER';
-- AlterTable
ALTER TABLE "staff" ADD COLUMN "backup_codes_enc" TEXT,
ADD COLUMN "invited_by" TEXT,
ADD COLUMN "status" "StaffStatus" NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "two_factor_secret_enc" TEXT,
ADD COLUMN "two_factor_verified_at" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "users" ADD COLUMN "backup_codes_enc" TEXT,
ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "two_factor_secret_enc" TEXT,
ADD COLUMN "two_factor_verified_at" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "staff_invitations" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "StaffRole" NOT NULL DEFAULT 'SUPPORT',
"token" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"invited_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "staff_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "staff_invitations_email_key" ON "staff_invitations"("email");
-- CreateIndex
CREATE UNIQUE INDEX "staff_invitations_token_key" ON "staff_invitations"("token");

View File

@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "notification_cooldowns" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"last_sent_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_cooldowns_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "notification_cooldowns_type_key" ON "notification_cooldowns"("type");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "staff" ADD COLUMN "profile_photo_key" TEXT;

View File

@@ -0,0 +1,34 @@
-- AlterTable: Add brute-force attempt tracking to security verification codes
ALTER TABLE "security_verification_codes" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
-- AlterTable: Add hash-based API key lookup to server connections
ALTER TABLE "server_connections" ADD COLUMN "hub_api_key_hash" TEXT;
-- CreateTable: DB-backed 2FA sessions (replacing in-memory Map)
CREATE TABLE "pending_2fa_sessions" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"user_type" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"role" TEXT,
"company" TEXT,
"subscription" JSONB,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "pending_2fa_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "pending_2fa_sessions_token_key" ON "pending_2fa_sessions"("token");
-- CreateIndex
CREATE INDEX "pending_2fa_sessions_token_idx" ON "pending_2fa_sessions"("token");
-- CreateIndex
CREATE INDEX "pending_2fa_sessions_expires_at_idx" ON "pending_2fa_sessions"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "server_connections_hub_api_key_hash_key" ON "server_connections"("hub_api_key_hash");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,743 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
// url configured in prisma.config.mjs (Prisma 7+)
}
// ============================================================================
// ENUMS
// ============================================================================
enum UserStatus {
PENDING_VERIFICATION
ACTIVE
SUSPENDED
}
enum StaffRole {
OWNER // Full access, cannot be deleted
ADMIN // Full access, can manage staff
MANAGER // Orders + servers, no staff/settings
SUPPORT // View only + limited actions
}
enum StaffStatus {
ACTIVE
SUSPENDED
}
enum SubscriptionPlan {
TRIAL
STARTER
PRO
ENTERPRISE
}
enum SubscriptionTier {
HUB_DASHBOARD
ADVANCED
}
enum SubscriptionStatus {
TRIAL
ACTIVE
CANCELED
PAST_DUE
}
enum OrderStatus {
PAYMENT_CONFIRMED
AWAITING_SERVER
SERVER_READY
DNS_PENDING
DNS_READY
PROVISIONING
FULFILLED
EMAIL_CONFIGURED
FAILED
}
enum AutomationMode {
AUTO // Website orders - self-executing
MANUAL // Staff-created - step-by-step
PAUSED // Stopped for intervention
}
enum DnsRecordStatus {
PENDING
VERIFIED
MISMATCH
NOT_FOUND
ERROR
SKIPPED // For wildcard pass or manual override
}
enum JobStatus {
PENDING
CLAIMED
RUNNING
COMPLETED
FAILED
DEAD
}
enum LogLevel {
DEBUG
INFO
WARN
ERROR
}
enum ServerConnectionStatus {
PENDING // Awaiting orchestrator registration
REGISTERED // Orchestrator has registered
ONLINE // Recent heartbeat received
OFFLINE // No recent heartbeat
}
enum CommandStatus {
PENDING
SENT
EXECUTING
COMPLETED
FAILED
}
// ============================================================================
// SYSTEM SETTINGS
// ============================================================================
model SystemSetting {
id String @id @default(cuid())
key String @unique
value String // Encrypted for sensitive values
encrypted Boolean @default(false)
category String @default("general")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("system_settings")
}
// ============================================================================
// USER & STAFF MODELS
// ============================================================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String?
company String?
status UserStatus @default(PENDING_VERIFICATION)
emailVerified DateTime? @map("email_verified")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 2FA fields
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
twoFactorSecretEnc String? @map("two_factor_secret_enc")
twoFactorVerifiedAt DateTime? @map("two_factor_verified_at")
backupCodesEnc String? @map("backup_codes_enc")
subscriptions Subscription[]
orders Order[]
tokenUsage TokenUsage[]
@@map("users")
}
model Staff {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String
role StaffRole @default(SUPPORT)
status StaffStatus @default(ACTIVE)
invitedBy String? @map("invited_by") // Staff ID who sent invite
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Profile
profilePhotoKey String? @map("profile_photo_key") // S3/MinIO key for profile photo
// 2FA fields
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
twoFactorSecretEnc String? @map("two_factor_secret_enc")
twoFactorVerifiedAt DateTime? @map("two_factor_verified_at")
backupCodesEnc String? @map("backup_codes_enc")
@@map("staff")
}
model StaffInvitation {
id String @id @default(cuid())
email String @unique
role StaffRole @default(SUPPORT)
token String @unique
expiresAt DateTime @map("expires_at")
invitedBy String @map("invited_by")
createdAt DateTime @default(now()) @map("created_at")
@@map("staff_invitations")
}
// ============================================================================
// SUBSCRIPTION & BILLING
// ============================================================================
model Subscription {
id String @id @default(cuid())
userId String @map("user_id")
plan SubscriptionPlan @default(TRIAL)
tier SubscriptionTier @default(HUB_DASHBOARD)
tokenLimit Int @default(10000) @map("token_limit")
tokensUsed Int @default(0) @map("tokens_used")
trialEndsAt DateTime? @map("trial_ends_at")
stripeCustomerId String? @map("stripe_customer_id")
stripeSubscriptionId String? @map("stripe_subscription_id")
status SubscriptionStatus @default(TRIAL)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("subscriptions")
}
// ============================================================================
// ORDERS & PROVISIONING
// ============================================================================
model Order {
id String @id @default(cuid())
userId String @map("user_id")
status OrderStatus @default(PAYMENT_CONFIRMED)
tier SubscriptionTier
domain String
tools String[]
configJson Json @map("config_json")
// Automation mode
automationMode AutomationMode @default(MANUAL)
automationPausedAt DateTime? @map("automation_paused_at")
automationPausedReason String? @map("automation_paused_reason")
source String? // "website" | "staff" | "api"
// Customer/provisioning config
customer String? @map("customer") // Short name for subdomains (e.g., "acme")
companyName String? @map("company_name") // Display name (e.g., "Acme Corporation")
licenseKey String? @map("license_key") // Generated: lb_inst_xxx
// Server credentials (entered by staff)
serverIp String? @map("server_ip")
serverPasswordEncrypted String? @map("server_password_encrypted")
sshPort Int @default(22) @map("ssh_port")
netcupServerId String? @map("netcup_server_id") // Netcup API server ID for linking
// Generated after provisioning
portainerUrl String? @map("portainer_url")
dashboardUrl String? @map("dashboard_url")
failureReason String? @map("failure_reason")
// Portainer credentials (encrypted, synced from agent)
portainerUsername String? @map("portainer_username") // e.g., "admin-xyz123"
portainerPasswordEnc String? @map("portainer_password_enc") // AES-256-CBC encrypted
credentialsSyncedAt DateTime? @map("credentials_synced_at") // Last sync from agent
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
serverReadyAt DateTime? @map("server_ready_at")
provisioningStartedAt DateTime? @map("provisioning_started_at")
completedAt DateTime? @map("completed_at")
dnsVerifiedAt DateTime? @map("dns_verified_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provisioningLogs ProvisioningLog[]
jobs ProvisioningJob[]
serverConnection ServerConnection?
dnsVerification DnsVerification?
@@map("orders")
}
// ============================================================================
// DNS VERIFICATION
// ============================================================================
model DnsVerification {
id String @id @default(cuid())
orderId String @unique @map("order_id")
wildcardPassed Boolean @default(false) @map("wildcard_passed")
manualOverride Boolean @default(false) @map("manual_override") // Staff skipped check
allPassed Boolean @default(false) @map("all_passed")
totalSubdomains Int @default(0) @map("total_subdomains")
passedCount Int @default(0) @map("passed_count")
lastCheckedAt DateTime? @map("last_checked_at")
verifiedAt DateTime? @map("verified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
records DnsRecord[]
@@map("dns_verifications")
}
model DnsRecord {
id String @id @default(cuid())
dnsVerificationId String @map("dns_verification_id")
subdomain String // "cloud"
fullDomain String @map("full_domain") // "cloud.example.com"
expectedIp String @map("expected_ip")
resolvedIp String? @map("resolved_ip")
status DnsRecordStatus @default(PENDING)
errorMessage String? @map("error_message")
checkedAt DateTime? @map("checked_at")
dnsVerification DnsVerification @relation(fields: [dnsVerificationId], references: [id], onDelete: Cascade)
@@index([dnsVerificationId])
@@map("dns_records")
}
model ProvisioningLog {
id String @id @default(cuid())
orderId String @map("order_id")
level LogLevel @default(INFO)
message String
step String?
timestamp DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId, timestamp])
@@map("provisioning_logs")
}
// ============================================================================
// JOB QUEUE
// ============================================================================
model ProvisioningJob {
id String @id @default(cuid())
orderId String @map("order_id")
jobType String @map("job_type")
status JobStatus @default(PENDING)
priority Int @default(0)
claimedAt DateTime? @map("claimed_at")
claimedBy String? @map("claimed_by")
containerName String? @map("container_name")
attempt Int @default(1)
maxAttempts Int @default(3) @map("max_attempts")
nextRetryAt DateTime? @map("next_retry_at")
configSnapshot Json @map("config_snapshot")
runnerTokenHash String? @map("runner_token_hash")
result Json?
error String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
logs JobLog[]
@@index([status, priority, createdAt])
@@index([orderId])
@@map("provisioning_jobs")
}
model JobLog {
id String @id @default(cuid())
jobId String @map("job_id")
level LogLevel @default(INFO)
message String
step String?
progress Int?
timestamp DateTime @default(now())
job ProvisioningJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId, timestamp])
@@map("job_logs")
}
// ============================================================================
// TOKEN USAGE (AI Tracking)
// ============================================================================
model TokenUsage {
id String @id @default(cuid())
userId String @map("user_id")
instanceId String? @map("instance_id")
operation String // chat, analysis, setup
tokensInput Int @map("tokens_input")
tokensOutput Int @map("tokens_output")
model String
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@map("token_usage")
}
// ============================================================================
// RUNNER TOKENS
// ============================================================================
model RunnerToken {
id String @id @default(cuid())
tokenHash String @unique @map("token_hash")
name String
isActive Boolean @default(true) @map("is_active")
lastUsed DateTime? @map("last_used")
createdAt DateTime @default(now()) @map("created_at")
@@map("runner_tokens")
}
// ============================================================================
// SERVER CONNECTION (Phone-Home System)
// ============================================================================
model ServerConnection {
id String @id @default(cuid())
orderId String @unique @map("order_id")
// Registration token (generated during provisioning, used by orchestrator to register)
registrationToken String @unique @map("registration_token")
// Hub API key (issued after successful registration, used for heartbeats/commands)
hubApiKey String? @unique @map("hub_api_key")
hubApiKeyHash String? @unique @map("hub_api_key_hash")
// Orchestrator connection info (provided during registration)
orchestratorUrl String? @map("orchestrator_url")
agentVersion String? @map("agent_version")
// Status tracking
status ServerConnectionStatus @default(PENDING)
registeredAt DateTime? @map("registered_at")
lastHeartbeat DateTime? @map("last_heartbeat")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
remoteCommands RemoteCommand[]
@@map("server_connections")
}
// ============================================================================
// REMOTE COMMANDS (Support Backdoor)
// ============================================================================
model RemoteCommand {
id String @id @default(cuid())
serverConnectionId String @map("server_connection_id")
// Command details
type String // SHELL, RESTART_SERVICE, UPDATE, ECHO, etc.
payload Json // Command-specific payload
// Execution tracking
status CommandStatus @default(PENDING)
result Json? // Command result
errorMessage String? @map("error_message")
// Timestamps
queuedAt DateTime @default(now()) @map("queued_at")
sentAt DateTime? @map("sent_at")
executedAt DateTime? @map("executed_at")
completedAt DateTime? @map("completed_at")
// Staff who initiated (for audit)
initiatedBy String? @map("initiated_by")
serverConnection ServerConnection @relation(fields: [serverConnectionId], references: [id], onDelete: Cascade)
@@index([serverConnectionId, status])
@@index([status, queuedAt])
@@map("remote_commands")
}
// ============================================================================
// ENTERPRISE CLIENTS
// ============================================================================
enum ErrorSeverity {
INFO
WARNING
ERROR
CRITICAL
}
model EnterpriseClient {
id String @id @default(cuid())
name String
companyName String? @map("company_name")
contactEmail String @map("contact_email") // For security codes
contactPhone String? @map("contact_phone")
notes String? @db.Text
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
servers EnterpriseServer[]
errorRules ErrorDetectionRule[]
securityCodes SecurityVerificationCode[]
statsHistory ServerStatsSnapshot[]
notificationSetting NotificationSetting?
@@map("enterprise_clients")
}
model EnterpriseServer {
id String @id @default(cuid())
clientId String @map("client_id")
netcupServerId String @map("netcup_server_id") // Link to Netcup server
nickname String? // Optional friendly name
purpose String? // e.g., "Production", "Staging", "Database"
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Portainer credentials (encrypted)
portainerUrl String? @map("portainer_url")
portainerUsername String? @map("portainer_username")
portainerPasswordEnc String? @map("portainer_password_enc")
// Relations
client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
statsSnapshots ServerStatsSnapshot[]
errorLogs DetectedError[]
logScanPositions LogScanPosition[]
stateSnapshots ContainerStateSnapshot[]
containerEvents ContainerEvent[]
@@unique([clientId, netcupServerId])
@@index([clientId])
@@map("enterprise_servers")
}
// ============================================================================
// ENTERPRISE STATS HISTORY (90-day retention)
// ============================================================================
model ServerStatsSnapshot {
id String @id @default(cuid())
serverId String @map("server_id")
clientId String @map("client_id")
timestamp DateTime @default(now())
// Server metrics (from Netcup)
cpuPercent Float? @map("cpu_percent")
memoryUsedMb Float? @map("memory_used_mb")
memoryTotalMb Float? @map("memory_total_mb")
diskReadMbps Float? @map("disk_read_mbps")
diskWriteMbps Float? @map("disk_write_mbps")
networkInMbps Float? @map("network_in_mbps")
networkOutMbps Float? @map("network_out_mbps")
// Container summary
containersRunning Int? @map("containers_running")
containersStopped Int? @map("containers_stopped")
// Relations
server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@index([serverId, timestamp])
@@index([clientId, timestamp])
@@map("server_stats_snapshots")
}
// ============================================================================
// ERROR DETECTION
// ============================================================================
model ErrorDetectionRule {
id String @id @default(cuid())
clientId String @map("client_id")
name String // e.g., "Database Connection Failed"
pattern String // Regex pattern
severity ErrorSeverity @default(WARNING)
isActive Boolean @default(true) @map("is_active")
description String? // What this rule detects
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
detectedErrors DetectedError[]
@@index([clientId])
@@map("error_detection_rules")
}
model DetectedError {
id String @id @default(cuid())
serverId String @map("server_id")
ruleId String @map("rule_id")
containerId String? @map("container_id") // Optional: which container
containerName String? @map("container_name")
logLine String @db.Text @map("log_line") // The actual log line that matched
context String? @db.Text // Surrounding log context
timestamp DateTime @default(now())
acknowledgedAt DateTime? @map("acknowledged_at")
acknowledgedBy String? @map("acknowledged_by") // User ID who acknowledged
// Relations
server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
rule ErrorDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
@@index([serverId, timestamp])
@@index([ruleId, timestamp])
@@map("detected_errors")
}
// ============================================================================
// SECURITY VERIFICATION (for destructive actions)
// ============================================================================
model SecurityVerificationCode {
id String @id @default(cuid())
clientId String @map("client_id")
code String // 8-digit code
action String // "WIPE" | "REINSTALL"
targetServerId String @map("target_server_id") // Which server
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
attempts Int @default(0) // Failed verification attempts
createdAt DateTime @default(now()) @map("created_at")
// Relations
client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@index([clientId, code])
@@map("security_verification_codes")
}
// ============================================================================
// INTELLIGENT ERROR TRACKING
// ============================================================================
enum ContainerEventType {
CRASH // Was running, now exited with non-zero exit code
OOM_KILLED // Out of memory kill
RESTART // Container restarted
STOPPED // Intentional stop (exit code 0 or manual)
}
// Track log scanning position to avoid re-scanning same content
model LogScanPosition {
id String @id @default(cuid())
serverId String @map("server_id")
containerId String @map("container_id")
lastLineCount Int @default(0) @map("last_line_count")
lastLogHash String? @map("last_log_hash") // Detect log rotation
lastScannedAt DateTime @default(now()) @map("last_scanned_at")
server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, containerId])
@@map("log_scan_positions")
}
// Track container state over time for crash detection
model ContainerStateSnapshot {
id String @id @default(cuid())
serverId String @map("server_id")
containerId String @map("container_id")
containerName String @map("container_name")
state String // "running", "exited", "dead"
exitCode Int? @map("exit_code")
capturedAt DateTime @default(now()) @map("captured_at")
server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@index([serverId, containerId, capturedAt])
@@map("container_state_snapshots")
}
// Record significant container lifecycle events
model ContainerEvent {
id String @id @default(cuid())
serverId String @map("server_id")
containerId String @map("container_id")
containerName String @map("container_name")
eventType ContainerEventType @map("event_type")
exitCode Int? @map("exit_code")
details String? @db.Text
acknowledgedAt DateTime? @map("acknowledged_at")
acknowledgedBy String? @map("acknowledged_by")
timestamp DateTime @default(now())
server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@index([serverId, timestamp])
@@index([eventType, acknowledgedAt])
@@map("container_events")
}
// Email notification settings per client
model NotificationSetting {
id String @id @default(cuid())
clientId String @unique @map("client_id")
enabled Boolean @default(false)
criticalErrorsOnly Boolean @default(true) @map("critical_errors_only")
containerCrashes Boolean @default(true) @map("container_crashes")
recipients String[] @default([])
cooldownMinutes Int @default(30) @map("cooldown_minutes")
lastNotifiedAt DateTime? @map("last_notified_at")
client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("notification_settings")
}
// ============================================================================
// PENDING 2FA SESSIONS
// ============================================================================
model Pending2FASession {
id String @id @default(cuid())
token String @unique
userId String @map("user_id")
userType String @map("user_type") // 'customer' | 'staff'
email String
name String?
role String? // StaffRole for staff users
company String?
subscription Json? // Subscription data for customer users
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([token])
@@index([expiresAt])
@@map("pending_2fa_sessions")
}
// ============================================================================
// SYSTEM-WIDE NOTIFICATION COOLDOWN
// ============================================================================
// Track last notification time per notification type for system-wide cooldown
model NotificationCooldown {
id String @id @default(cuid())
type String @unique // e.g., "container_crash", "critical_error", "stats_cpu"
lastSentAt DateTime @map("last_sent_at")
@@map("notification_cooldowns")
}

320
letsbe-hub/prisma/seed.ts Normal file
View File

@@ -0,0 +1,320 @@
import { PrismaClient, OrderStatus, SubscriptionPlan, SubscriptionTier, SubscriptionStatus, UserStatus, LogLevel } from '@prisma/client'
import bcrypt from 'bcryptjs'
const { hash } = bcrypt
const prisma = new PrismaClient()
// Random data helpers
const companies = [
'Acme Corp', 'TechStart Inc', 'Digital Solutions', 'CloudFirst Ltd',
'InnovateTech', 'DataDriven Co', 'SmartBiz Solutions', 'FutureTech Labs',
'AgileWorks', 'NextGen Systems', null, null, null
]
const domains = [
'acme.letsbe.cloud', 'techstart.letsbe.cloud', 'digital.letsbe.cloud',
'cloudfirst.letsbe.cloud', 'innovate.letsbe.cloud', 'datadriven.letsbe.cloud',
'smartbiz.letsbe.cloud', 'futuretech.letsbe.cloud', 'agileworks.letsbe.cloud',
'nextgen.letsbe.cloud', 'startup.letsbe.cloud', 'enterprise.letsbe.cloud',
'demo.letsbe.cloud', 'test.letsbe.cloud', 'dev.letsbe.cloud'
]
const toolSets = {
basic: ['nextcloud', 'keycloak'],
standard: ['nextcloud', 'keycloak', 'minio', 'poste'],
advanced: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser'],
full: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser', 'portainer', 'grafana'],
}
const logMessages = {
PAYMENT_CONFIRMED: ['Payment received via Stripe', 'Order confirmed'],
AWAITING_SERVER: ['Waiting for server allocation', 'Server request submitted to provider'],
SERVER_READY: ['Server provisioned', 'SSH access verified', 'Root password received'],
DNS_PENDING: ['DNS records submitted', 'Waiting for DNS propagation'],
DNS_READY: ['DNS records verified', 'Domain is resolving correctly'],
PROVISIONING: [
'Starting provisioning process',
'Downloading Docker images',
'Configuring Nginx reverse proxy',
'Installing Keycloak',
'Configuring Nextcloud',
'Setting up MinIO storage',
'Configuring email server',
'Running health checks',
],
FULFILLED: ['Provisioning complete', 'All services healthy', 'Welcome email sent'],
EMAIL_CONFIGURED: ['SMTP credentials configured', 'Email sending verified'],
FAILED: ['Provisioning failed', 'See error details below'],
}
function randomDate(daysAgo: number): Date {
const date = new Date()
date.setDate(date.getDate() - Math.floor(Math.random() * daysAgo))
date.setHours(Math.floor(Math.random() * 24))
date.setMinutes(Math.floor(Math.random() * 60))
return date
}
function randomChoice<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}
async function main() {
console.log('Starting seed...')
// 1. Create admin user if not exists
const adminEmail = process.env.ADMIN_EMAIL || 'admin@letsbe.solutions'
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'
const existingAdmin = await prisma.staff.findUnique({
where: { email: adminEmail },
})
if (!existingAdmin) {
const passwordHash = await hash(adminPassword, 12)
await prisma.staff.create({
data: {
email: adminEmail,
passwordHash,
name: 'Admin',
role: 'ADMIN',
},
})
console.log(`Created admin user: ${adminEmail}`)
} else {
console.log(`Admin user ${adminEmail} already exists`)
}
// 2. Create support staff
const supportEmail = 'support@letsbe.solutions'
const existingSupport = await prisma.staff.findUnique({
where: { email: supportEmail },
})
if (!existingSupport) {
const passwordHash = await hash('support123', 12)
await prisma.staff.create({
data: {
email: supportEmail,
passwordHash,
name: 'Support Agent',
role: 'SUPPORT',
},
})
console.log(`Created support user: ${supportEmail}`)
}
// 3. Create test customers
const customerData = [
{ email: 'john@acme.com', name: 'John Smith', company: 'Acme Corp', status: UserStatus.ACTIVE },
{ email: 'sarah@techstart.io', name: 'Sarah Johnson', company: 'TechStart Inc', status: UserStatus.ACTIVE },
{ email: 'mike@cloudfirst.co', name: 'Mike Davis', company: 'CloudFirst Ltd', status: UserStatus.ACTIVE },
{ email: 'emma@digital.io', name: 'Emma Wilson', company: 'Digital Solutions', status: UserStatus.ACTIVE },
{ email: 'david@innovate.co', name: 'David Brown', company: 'InnovateTech', status: UserStatus.ACTIVE },
{ email: 'lisa@datadriven.io', name: 'Lisa Chen', company: 'DataDriven Co', status: UserStatus.ACTIVE },
{ email: 'james@smartbiz.com', name: 'James Miller', company: 'SmartBiz Solutions', status: UserStatus.ACTIVE },
{ email: 'amy@futuretech.io', name: 'Amy Taylor', company: 'FutureTech Labs', status: UserStatus.PENDING_VERIFICATION },
{ email: 'robert@agile.co', name: 'Robert Anderson', company: 'AgileWorks', status: UserStatus.ACTIVE },
{ email: 'jennifer@nextgen.io', name: 'Jennifer Lee', company: 'NextGen Systems', status: UserStatus.SUSPENDED },
{ email: 'freelancer@gmail.com', name: 'Alex Freelancer', company: null, status: UserStatus.ACTIVE },
{ email: 'startup@mail.com', name: 'Startup Founder', company: null, status: UserStatus.PENDING_VERIFICATION },
]
const customers: { id: string; email: string }[] = []
for (const customer of customerData) {
const existing = await prisma.user.findUnique({
where: { email: customer.email },
})
if (!existing) {
const passwordHash = await hash('customer123', 12)
const created = await prisma.user.create({
data: {
email: customer.email,
passwordHash,
name: customer.name,
company: customer.company,
status: customer.status,
emailVerified: customer.status === UserStatus.ACTIVE ? new Date() : null,
},
})
customers.push({ id: created.id, email: created.email })
console.log(`Created customer: ${customer.email}`)
} else {
customers.push({ id: existing.id, email: existing.email })
console.log(`Customer ${customer.email} already exists`)
}
}
// 4. Create subscriptions for customers
const subscriptionConfigs = [
{ plan: SubscriptionPlan.ENTERPRISE, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.PAST_DUE },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.TRIAL },
{ plan: SubscriptionPlan.ENTERPRISE, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.CANCELED },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.TRIAL },
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.TRIAL },
]
for (let i = 0; i < customers.length; i++) {
const customer = customers[i]
const config = subscriptionConfigs[i] || subscriptionConfigs[0]
const existingSub = await prisma.subscription.findFirst({
where: { userId: customer.id },
})
if (!existingSub) {
await prisma.subscription.create({
data: {
userId: customer.id,
plan: config.plan,
tier: config.tier,
status: config.status,
tokenLimit: config.plan === SubscriptionPlan.ENTERPRISE ? 100000 :
config.plan === SubscriptionPlan.PRO ? 50000 :
config.plan === SubscriptionPlan.STARTER ? 20000 : 10000,
tokensUsed: Math.floor(Math.random() * 5000),
trialEndsAt: config.status === SubscriptionStatus.TRIAL ?
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) : null,
},
})
console.log(`Created subscription for: ${customer.email}`)
}
}
// 5. Create orders with various statuses
const orderConfigs = [
// Orders in various pipeline stages
{ status: OrderStatus.PAYMENT_CONFIRMED, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.PAYMENT_CONFIRMED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.AWAITING_SERVER, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.AWAITING_SERVER, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.SERVER_READY, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.DNS_PENDING, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.DNS_PENDING, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.DNS_READY, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.DNS_READY, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.PROVISIONING, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.EMAIL_CONFIGURED, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.EMAIL_CONFIGURED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.FAILED, tier: SubscriptionTier.HUB_DASHBOARD },
]
for (let i = 0; i < orderConfigs.length; i++) {
const config = orderConfigs[i]
const customer = customers[i % customers.length]
const domain = domains[i % domains.length]
const existingOrder = await prisma.order.findFirst({
where: { domain },
})
if (!existingOrder) {
const tools = config.tier === SubscriptionTier.HUB_DASHBOARD
? toolSets.full
: randomChoice([toolSets.basic, toolSets.standard, toolSets.advanced])
const createdAt = randomDate(30)
const serverStatuses: OrderStatus[] = [
OrderStatus.SERVER_READY, OrderStatus.DNS_PENDING, OrderStatus.DNS_READY,
OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED,
OrderStatus.FAILED
]
const hasServer = serverStatuses.includes(config.status)
const order = await prisma.order.create({
data: {
userId: customer.id,
status: config.status,
tier: config.tier,
domain,
tools,
configJson: { tools, tier: config.tier, domain },
serverIp: hasServer ? `192.168.1.${100 + i}` : null,
serverPasswordEncrypted: hasServer ? 'encrypted_placeholder' : null,
sshPort: 22,
portainerUrl: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? `https://portainer.${domain}` : null,
dashboardUrl: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? `https://dashboard.${domain}` : null,
failureReason: config.status === OrderStatus.FAILED
? 'Connection timeout during Docker installation' : null,
createdAt,
serverReadyAt: hasServer ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
provisioningStartedAt: config.status === OrderStatus.PROVISIONING ||
config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? new Date(createdAt.getTime() + 4 * 60 * 60 * 1000) : null,
completedAt: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? new Date(createdAt.getTime() + 5 * 60 * 60 * 1000) : null,
},
})
// Add provisioning logs based on status
const statusIndex = Object.keys(logMessages).indexOf(config.status)
const statusesToLog = Object.keys(logMessages).slice(0, statusIndex + 1) as OrderStatus[]
let logTime = new Date(createdAt)
for (const logStatus of statusesToLog) {
const messages = logMessages[logStatus] || []
for (const message of messages) {
await prisma.provisioningLog.create({
data: {
orderId: order.id,
level: logStatus === OrderStatus.FAILED ? LogLevel.ERROR : LogLevel.INFO,
message,
step: logStatus,
timestamp: new Date(logTime),
},
})
logTime = new Date(logTime.getTime() + Math.random() * 5 * 60 * 1000) // 0-5 min later
}
}
console.log(`Created order: ${domain} (${config.status})`)
}
}
// 6. Create a runner token for testing
const runnerTokenHash = await hash('test-runner-token', 12)
const existingRunner = await prisma.runnerToken.findFirst({
where: { name: 'test-runner' },
})
if (!existingRunner) {
await prisma.runnerToken.create({
data: {
tokenHash: runnerTokenHash,
name: 'test-runner',
isActive: true,
},
})
console.log('Created test runner token')
}
console.log('\nSeed completed successfully!')
console.log('\nTest credentials:')
console.log(' Admin: admin@letsbe.solutions / admin123')
console.log(' Support: support@letsbe.solutions / support123')
console.log(' Customers: <email> / customer123')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -0,0 +1 @@
{"name":"letsbe-hub"}

View File

@@ -0,0 +1,246 @@
import { Order, OrderStatus, AutomationMode, LogLevel, ProvisioningLog, SubscriptionTier } from '@prisma/client'
import { customerUser, customerUser2 } from './users'
// Payment confirmed order (just created)
export const paymentConfirmedOrder: Order = {
id: 'order-001',
userId: customerUser.id,
domain: 'test.letsbe.cloud',
tier: SubscriptionTier.HUB_DASHBOARD,
status: OrderStatus.PAYMENT_CONFIRMED,
automationMode: AutomationMode.MANUAL,
serverIp: null,
serverPasswordEncrypted: null,
sshPort: 22,
netcupServerId: null,
tools: ['orchestrator', 'sysadmin-agent'],
configJson: {},
failureReason: null,
provisioningStartedAt: null,
completedAt: null,
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: null,
automationPausedAt: null,
automationPausedReason: null,
source: null,
portainerUrl: null,
dashboardUrl: null,
portainerUsername: null,
portainerPasswordEnc: null,
credentialsSyncedAt: null,
serverReadyAt: null,
dnsVerifiedAt: null,
createdAt: new Date('2024-01-20T10:00:00Z'),
updatedAt: new Date('2024-01-20T10:00:00Z'),
}
// Server ready order
export const serverReadyOrder: Order = {
id: 'order-002',
userId: customerUser.id,
domain: 'ready.letsbe.cloud',
tier: SubscriptionTier.ADVANCED,
status: OrderStatus.SERVER_READY,
automationMode: AutomationMode.MANUAL,
serverIp: '192.168.1.100',
serverPasswordEncrypted: 'encrypted-password-here',
sshPort: 22,
netcupServerId: 'netcup-12345',
tools: ['orchestrator', 'sysadmin-agent', 'nextcloud'],
configJson: {},
failureReason: null,
provisioningStartedAt: null,
completedAt: null,
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: null,
automationPausedAt: null,
automationPausedReason: null,
source: null,
portainerUrl: null,
dashboardUrl: null,
portainerUsername: null,
portainerPasswordEnc: null,
credentialsSyncedAt: null,
serverReadyAt: new Date('2024-01-21T12:00:00Z'),
dnsVerifiedAt: null,
createdAt: new Date('2024-01-21T10:00:00Z'),
updatedAt: new Date('2024-01-21T14:00:00Z'),
}
// Order currently provisioning
export const provisioningOrder: Order = {
id: 'order-003',
userId: customerUser.id,
domain: 'provisioning.letsbe.cloud',
tier: SubscriptionTier.ADVANCED,
status: OrderStatus.PROVISIONING,
automationMode: AutomationMode.MANUAL,
serverIp: '192.168.1.101',
serverPasswordEncrypted: 'encrypted-password-here',
sshPort: 22,
netcupServerId: 'netcup-12346',
tools: ['orchestrator', 'sysadmin-agent', 'nextcloud', 'keycloak'],
configJson: {},
failureReason: null,
provisioningStartedAt: new Date('2024-01-22T10:00:00Z'),
completedAt: null,
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: 'lb_inst_abc123',
automationPausedAt: null,
automationPausedReason: null,
source: null,
portainerUrl: null,
dashboardUrl: null,
portainerUsername: null,
portainerPasswordEnc: null,
credentialsSyncedAt: null,
serverReadyAt: new Date('2024-01-22T09:30:00Z'),
dnsVerifiedAt: null,
createdAt: new Date('2024-01-22T09:00:00Z'),
updatedAt: new Date('2024-01-22T10:00:00Z'),
}
// Successfully fulfilled order
export const fulfilledOrder: Order = {
id: 'order-004',
userId: customerUser2.id,
domain: 'complete.letsbe.cloud',
tier: SubscriptionTier.HUB_DASHBOARD,
status: OrderStatus.FULFILLED,
automationMode: AutomationMode.MANUAL,
serverIp: '192.168.1.102',
serverPasswordEncrypted: null, // Cleared after provisioning
sshPort: 22022, // Updated after provisioning
netcupServerId: 'netcup-12347',
tools: ['orchestrator', 'sysadmin-agent'],
configJson: {},
failureReason: null,
provisioningStartedAt: new Date('2024-01-19T10:00:00Z'),
completedAt: new Date('2024-01-19T10:30:00Z'),
customer: 'Another Customer',
companyName: 'Another Company',
licenseKey: 'lb_inst_def456',
automationPausedAt: null,
automationPausedReason: null,
source: null,
portainerUrl: 'https://portainer.complete.letsbe.cloud',
dashboardUrl: 'https://dashboard.complete.letsbe.cloud',
portainerUsername: 'admin-complete',
portainerPasswordEnc: 'encrypted-portainer-password',
credentialsSyncedAt: new Date('2024-01-19T10:30:00Z'),
serverReadyAt: new Date('2024-01-19T09:30:00Z'),
dnsVerifiedAt: new Date('2024-01-19T09:45:00Z'),
createdAt: new Date('2024-01-19T09:00:00Z'),
updatedAt: new Date('2024-01-19T10:30:00Z'),
}
// Failed order
export const failedOrder: Order = {
id: 'order-005',
userId: customerUser.id,
domain: 'failed.letsbe.cloud',
tier: SubscriptionTier.ADVANCED,
status: OrderStatus.FAILED,
automationMode: AutomationMode.MANUAL,
serverIp: '192.168.1.103',
serverPasswordEncrypted: 'encrypted-password-here',
sshPort: 22,
netcupServerId: 'netcup-12348',
tools: ['orchestrator', 'sysadmin-agent'],
configJson: {},
failureReason: 'SSH connection timeout after 3 retries',
provisioningStartedAt: new Date('2024-01-18T10:00:00Z'),
completedAt: null,
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: null,
automationPausedAt: null,
automationPausedReason: null,
source: null,
portainerUrl: null,
dashboardUrl: null,
portainerUsername: null,
portainerPasswordEnc: null,
credentialsSyncedAt: null,
serverReadyAt: new Date('2024-01-18T09:30:00Z'),
dnsVerifiedAt: null,
createdAt: new Date('2024-01-18T09:00:00Z'),
updatedAt: new Date('2024-01-18T10:15:00Z'),
}
// All orders
export const allOrders: Order[] = [
paymentConfirmedOrder,
serverReadyOrder,
provisioningOrder,
fulfilledOrder,
failedOrder,
]
// Sample provisioning logs
export const provisioningLogs: ProvisioningLog[] = [
{
id: 'log-001',
orderId: provisioningOrder.id,
level: LogLevel.INFO,
message: 'Starting provisioning for provisioning.letsbe.cloud',
step: 'init',
timestamp: new Date('2024-01-22T10:00:00Z'),
},
{
id: 'log-002',
orderId: provisioningOrder.id,
level: LogLevel.INFO,
message: 'SSH connection established',
step: 'ssh-connect',
timestamp: new Date('2024-01-22T10:00:05Z'),
},
{
id: 'log-003',
orderId: provisioningOrder.id,
level: LogLevel.INFO,
message: 'Running server hardening playbook',
step: 'hardening',
timestamp: new Date('2024-01-22T10:00:10Z'),
},
]
// Factory function for creating custom orders
export function createOrder(overrides: Partial<Order> = {}): Order {
return {
id: `order-${Date.now()}`,
userId: customerUser.id,
domain: `test-${Date.now()}.letsbe.cloud`,
tier: SubscriptionTier.HUB_DASHBOARD,
status: OrderStatus.PAYMENT_CONFIRMED,
automationMode: AutomationMode.MANUAL,
serverIp: null,
serverPasswordEncrypted: null,
sshPort: 22,
netcupServerId: null,
tools: ['orchestrator', 'sysadmin-agent'],
configJson: {},
failureReason: null,
provisioningStartedAt: null,
completedAt: null,
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: null,
automationPausedAt: null,
automationPausedReason: null,
source: null,
portainerUrl: null,
dashboardUrl: null,
portainerUsername: null,
portainerPasswordEnc: null,
credentialsSyncedAt: null,
serverReadyAt: null,
dnsVerifiedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}
}

View File

@@ -0,0 +1,95 @@
import { Subscription, SubscriptionStatus, SubscriptionPlan, SubscriptionTier } from '@prisma/client'
import { customerUser, customerUser2 } from './users'
// Active subscription
export const activeSubscription: Subscription = {
id: 'sub-001',
userId: customerUser.id,
status: SubscriptionStatus.ACTIVE,
plan: SubscriptionPlan.PRO,
tier: SubscriptionTier.ADVANCED,
tokenLimit: 50000,
tokensUsed: 12500,
trialEndsAt: null,
stripeCustomerId: 'cus_test123',
stripeSubscriptionId: 'sub_test123',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
// Trial subscription
export const trialSubscription: Subscription = {
id: 'sub-002',
userId: customerUser2.id,
status: SubscriptionStatus.TRIAL,
plan: SubscriptionPlan.TRIAL,
tier: SubscriptionTier.HUB_DASHBOARD,
tokenLimit: 10000,
tokensUsed: 500,
trialEndsAt: new Date('2024-01-29T00:00:00Z'), // 14-day trial
stripeCustomerId: null,
stripeSubscriptionId: null,
createdAt: new Date('2024-01-15T00:00:00Z'),
updatedAt: new Date('2024-01-15T00:00:00Z'),
}
// Cancelled subscription
export const cancelledSubscription: Subscription = {
id: 'sub-003',
userId: customerUser.id,
status: SubscriptionStatus.CANCELED,
plan: SubscriptionPlan.PRO,
tier: SubscriptionTier.ADVANCED,
tokenLimit: 50000,
tokensUsed: 45000,
trialEndsAt: null,
stripeCustomerId: 'cus_test789',
stripeSubscriptionId: 'sub_test789',
createdAt: new Date('2023-12-01T00:00:00Z'),
updatedAt: new Date('2024-01-20T00:00:00Z'),
}
// Past due subscription
export const pastDueSubscription: Subscription = {
id: 'sub-004',
userId: customerUser.id,
status: SubscriptionStatus.PAST_DUE,
plan: SubscriptionPlan.ENTERPRISE,
tier: SubscriptionTier.ADVANCED,
tokenLimit: 100000,
tokensUsed: 78000,
trialEndsAt: null,
stripeCustomerId: 'cus_testABC',
stripeSubscriptionId: 'sub_testABC',
createdAt: new Date('2023-06-01T00:00:00Z'),
updatedAt: new Date('2024-01-20T00:00:00Z'),
}
// All subscriptions
export const allSubscriptions: Subscription[] = [
activeSubscription,
trialSubscription,
cancelledSubscription,
pastDueSubscription,
]
// Factory function for creating custom subscriptions
export function createSubscription(overrides: Partial<Subscription> = {}): Subscription {
const now = new Date()
return {
id: `sub-${Date.now()}`,
userId: customerUser.id,
status: SubscriptionStatus.ACTIVE,
plan: SubscriptionPlan.STARTER,
tier: SubscriptionTier.HUB_DASHBOARD,
tokenLimit: 25000,
tokensUsed: 0,
trialEndsAt: null,
stripeCustomerId: `cus_${Date.now()}`,
stripeSubscriptionId: `sub_${Date.now()}`,
createdAt: now,
updatedAt: now,
...overrides,
}
}

View File

@@ -0,0 +1,75 @@
import { User, UserStatus } from '@prisma/client'
// Customer user fixture
export const customerUser: User = {
id: 'customer-user-001',
email: 'customer@example.com',
passwordHash: '$2a$10$hashedpassword',
name: 'Test Customer',
company: 'Test Company',
status: UserStatus.ACTIVE,
emailVerified: new Date('2024-01-01T00:00:00Z'),
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
twoFactorEnabled: false,
twoFactorSecretEnc: null,
twoFactorVerifiedAt: null,
backupCodesEnc: null,
}
// Second customer for testing lists
export const customerUser2: User = {
id: 'customer-user-002',
email: 'another@example.com',
passwordHash: '$2a$10$hashedpassword',
name: 'Another Customer',
company: 'Another Company',
status: UserStatus.ACTIVE,
emailVerified: new Date('2024-02-01T00:00:00Z'),
createdAt: new Date('2024-02-01T00:00:00Z'),
updatedAt: new Date('2024-02-01T00:00:00Z'),
twoFactorEnabled: false,
twoFactorSecretEnc: null,
twoFactorVerifiedAt: null,
backupCodesEnc: null,
}
// Pending verification user
export const pendingUser: User = {
id: 'customer-user-003',
email: 'pending@example.com',
passwordHash: '$2a$10$hashedpassword',
name: 'Pending User',
company: null,
status: UserStatus.PENDING_VERIFICATION,
emailVerified: null,
createdAt: new Date('2024-03-01T00:00:00Z'),
updatedAt: new Date('2024-03-01T00:00:00Z'),
twoFactorEnabled: false,
twoFactorSecretEnc: null,
twoFactorVerifiedAt: null,
backupCodesEnc: null,
}
// All users
export const allUsers: User[] = [customerUser, customerUser2, pendingUser]
// Factory function for creating custom users
export function createUser(overrides: Partial<User> = {}): User {
return {
id: `user-${Date.now()}`,
email: `user-${Date.now()}@example.com`,
passwordHash: '$2a$10$hashedpassword',
name: 'Test User',
company: null,
status: UserStatus.ACTIVE,
emailVerified: null,
createdAt: new Date(),
updatedAt: new Date(),
twoFactorEnabled: false,
twoFactorSecretEnc: null,
twoFactorVerifiedAt: null,
backupCodesEnc: null,
...overrides,
}
}

View File

@@ -0,0 +1,69 @@
import { vi } from 'vitest'
// Type for mock response configuration
export interface MockResponseConfig {
status?: number
ok?: boolean
json?: unknown
text?: string
headers?: Record<string, string>
}
// Create a mock fetch response
export function createMockResponse(config: MockResponseConfig = {}): Response {
const {
status = 200,
ok = status >= 200 && status < 300,
json = {},
text = '',
headers = {},
} = config
return {
ok,
status,
statusText: ok ? 'OK' : 'Error',
headers: new Headers(headers),
json: vi.fn().mockResolvedValue(json),
text: vi.fn().mockResolvedValue(text || JSON.stringify(json)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
formData: vi.fn().mockResolvedValue(new FormData()),
clone: vi.fn(),
body: null,
bodyUsed: false,
redirected: false,
type: 'basic' as ResponseType,
url: '',
} as unknown as Response
}
// Mock fetch function
export const mockFetch = vi.fn()
// Setup fetch to return a specific response
export function setMockFetchResponse(config: MockResponseConfig) {
mockFetch.mockResolvedValue(createMockResponse(config))
}
// Setup fetch to return different responses based on URL
export function setMockFetchResponses(responses: Record<string, MockResponseConfig>) {
mockFetch.mockImplementation((url: string) => {
const config = responses[url] || { status: 404, ok: false }
return Promise.resolve(createMockResponse(config))
})
}
// Setup fetch to throw an error
export function setMockFetchError(error: Error) {
mockFetch.mockRejectedValue(error)
}
// Reset fetch mock
export function resetFetchMock() {
mockFetch.mockReset()
mockFetch.mockResolvedValue(createMockResponse())
}
// Replace global fetch
vi.stubGlobal('fetch', mockFetch)

View File

@@ -0,0 +1,45 @@
import { vi } from 'vitest'
import type { Session } from 'next-auth'
// Default mock session for staff users
export const mockStaffSession: Session = {
user: {
id: 'staff-user-id',
email: 'admin@letsbe.cloud',
name: 'Test Admin',
userType: 'staff',
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
}
// Mock session for customer users
export const mockCustomerSession: Session = {
user: {
id: 'customer-user-id',
email: 'customer@example.com',
name: 'Test Customer',
userType: 'customer',
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
}
// No session (unauthenticated)
export const mockNoSession = null
// Create auth mock function
export const mockAuth = vi.fn()
// Setup auth mock with a specific session
export function setMockSession(session: Session | null) {
mockAuth.mockResolvedValue(session)
}
// Reset to staff session (default)
export function resetAuthMock() {
mockAuth.mockResolvedValue(mockStaffSession)
}
// Mock the auth module
vi.mock('@/lib/auth', () => ({
auth: mockAuth,
}))

View File

@@ -0,0 +1,10 @@
/**
* Mock for @prisma/adapter-pg (Prisma 7 driver adapter).
* The actual adapter isn't available locally on Node 23,
* but tests mock the entire prisma module anyway.
*/
export class PrismaPg {
constructor(_opts: { connectionString: string }) {
// no-op mock
}
}

View File

@@ -0,0 +1,18 @@
import { vi } from 'vitest'
import { PrismaClient } from '@prisma/client'
import { mockDeep, mockReset, DeepMockProxy } from 'vitest-mock-extended'
// Create a deep mock of PrismaClient
export const prismaMock = mockDeep<PrismaClient>()
// Reset mock between tests
export function resetPrismaMock() {
mockReset(prismaMock)
}
// Mock the prisma module
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
export type MockPrismaClient = DeepMockProxy<PrismaClient>

View File

@@ -0,0 +1,20 @@
import { beforeAll, afterAll, beforeEach, vi } from 'vitest'
// Mock environment variables
beforeAll(() => {
process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars-long'
process.env.CREDENTIAL_ENCRYPTION_KEY = 'test-credential-encryption-key!!'
process.env.SETTINGS_ENCRYPTION_KEY = 'test-settings-encryption-key!!!'
process.env.NEXTAUTH_SECRET = 'test-secret'
process.env.NEXTAUTH_URL = 'http://localhost:3000'
})
// Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks()
})
// Clean up after all tests
afterAll(() => {
vi.restoreAllMocks()
})

View File

@@ -0,0 +1,266 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { apiGet, apiPost, apiPatch, apiPut, apiDelete, ApiError } from '@/lib/api/client'
import { mockFetch, setMockFetchResponse, setMockFetchError, resetFetchMock } from '../../../mocks/fetch'
// Mock window.location for URL building
const mockLocation = {
origin: 'http://localhost:3000',
}
vi.stubGlobal('location', mockLocation)
describe('API Client', () => {
beforeEach(() => {
resetFetchMock()
})
describe('apiGet', () => {
it('should make GET request and return JSON response', async () => {
const mockData = { id: '1', name: 'Test' }
setMockFetchResponse({ json: mockData })
const result = await apiGet('/api/test')
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/test',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
)
expect(result).toEqual(mockData)
})
it('should append query params to URL', async () => {
setMockFetchResponse({ json: {} })
await apiGet('/api/test', { params: { page: 1, limit: 10, active: true } })
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('page=1'),
expect.anything()
)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('limit=10'),
expect.anything()
)
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('active=true'),
expect.anything()
)
})
it('should skip undefined params', async () => {
setMockFetchResponse({ json: {} })
await apiGet('/api/test', { params: { page: 1, filter: undefined } })
const calledUrl = mockFetch.mock.calls[0][0] as string
expect(calledUrl).toContain('page=1')
expect(calledUrl).not.toContain('filter')
})
it('should throw ApiError on non-OK response', async () => {
setMockFetchResponse({ status: 404, ok: false, json: { error: 'Not found' } })
await expect(apiGet('/api/test')).rejects.toThrow(ApiError)
await expect(apiGet('/api/test')).rejects.toMatchObject({
status: 404,
statusText: 'Error',
})
})
it('should handle network errors', async () => {
setMockFetchError(new Error('Network error'))
await expect(apiGet('/api/test')).rejects.toThrow('Network error')
})
})
describe('apiPost', () => {
it('should make POST request with JSON body', async () => {
const mockData = { id: '1' }
const requestBody = { name: 'New Item', value: 42 }
setMockFetchResponse({ json: mockData })
const result = await apiPost('/api/test', requestBody)
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/test',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
body: JSON.stringify(requestBody),
})
)
expect(result).toEqual(mockData)
})
it('should handle POST without body', async () => {
setMockFetchResponse({ json: { success: true } })
await apiPost('/api/test')
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/test',
expect.objectContaining({
method: 'POST',
body: undefined,
})
)
})
it('should throw ApiError on 400 response', async () => {
setMockFetchResponse({
status: 400,
ok: false,
json: { error: 'Validation failed' }
})
await expect(apiPost('/api/test', { invalid: 'data' })).rejects.toThrow(ApiError)
})
it('should throw ApiError on 401 response', async () => {
setMockFetchResponse({
status: 401,
ok: false,
json: { error: 'Unauthorized' }
})
await expect(apiPost('/api/test')).rejects.toMatchObject({
status: 401,
})
})
})
describe('apiPatch', () => {
it('should make PATCH request with JSON body', async () => {
const mockData = { id: '1', updated: true }
const patchBody = { name: 'Updated Name' }
setMockFetchResponse({ json: mockData })
const result = await apiPatch('/api/test/1', patchBody)
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/test/1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify(patchBody),
})
)
expect(result).toEqual(mockData)
})
})
describe('apiPut', () => {
it('should make PUT request with JSON body', async () => {
const mockData = { id: '1', replaced: true }
const putBody = { name: 'Replaced Item', value: 100 }
setMockFetchResponse({ json: mockData })
const result = await apiPut('/api/test/1', putBody)
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/test/1',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify(putBody),
})
)
expect(result).toEqual(mockData)
})
})
describe('apiDelete', () => {
it('should make DELETE request', async () => {
setMockFetchResponse({ json: { deleted: true } })
const result = await apiDelete('/api/test/1')
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/api/test/1',
expect.objectContaining({
method: 'DELETE',
})
)
expect(result).toEqual({ deleted: true })
})
it('should handle 204 No Content response', async () => {
// Simulate empty response
mockFetch.mockResolvedValueOnce({
ok: true,
status: 204,
statusText: 'No Content',
text: vi.fn().mockResolvedValue(''),
json: vi.fn().mockRejectedValue(new Error('No JSON')),
} as unknown as Response)
const result = await apiDelete('/api/test/1')
expect(result).toBeNull()
})
})
describe('ApiError', () => {
it('should include status and data', async () => {
const errorData = { message: 'Validation failed', errors: ['field required'] }
setMockFetchResponse({
status: 422,
ok: false,
json: errorData
})
try {
await apiPost('/api/test', {})
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
expect((error as ApiError).status).toBe(422)
expect((error as ApiError).data).toEqual(errorData)
}
})
it('should handle non-JSON error responses', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
text: vi.fn().mockResolvedValue('Internal Server Error'),
} as unknown as Response)
try {
await apiGet('/api/test')
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
expect((error as ApiError).status).toBe(500)
expect((error as ApiError).data).toBeUndefined()
}
})
})
describe('custom headers', () => {
it('should merge custom headers with defaults', async () => {
setMockFetchResponse({ json: {} })
await apiGet('/api/test', {
headers: {
'X-Custom-Header': 'custom-value',
},
})
expect(mockFetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value',
}),
})
)
})
})
})

View File

@@ -0,0 +1,308 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StaffRole } from '@prisma/client'
// Mock the auth module
const mockAuth = vi.fn()
vi.mock('@/lib/auth', () => ({
auth: mockAuth,
}))
// Mock the permission service
vi.mock('@/lib/services/permission-service', () => ({
hasPermission: vi.fn((role: string, permission: string) => {
// Simplified permission check for testing
const ROLE_PERMISSIONS: Record<string, string[]> = {
OWNER: [
'dashboard:view', 'orders:view', 'orders:create', 'orders:edit',
'orders:provision', 'orders:delete', 'customers:view', 'customers:create',
'customers:edit', 'customers:delete', 'servers:view', 'servers:power',
'servers:snapshots', 'servers:rescue', 'staff:view', 'staff:invite',
'staff:manage', 'staff:delete', 'settings:view', 'settings:edit',
'enterprise:view', 'enterprise:manage',
],
ADMIN: [
'dashboard:view', 'orders:view', 'orders:create', 'orders:edit',
'orders:provision', 'orders:delete', 'customers:view', 'customers:create',
'customers:edit', 'customers:delete', 'servers:view', 'servers:power',
'servers:snapshots', 'servers:rescue', 'staff:view', 'staff:invite',
'staff:manage', 'settings:view', 'settings:edit',
'enterprise:view', 'enterprise:manage',
],
MANAGER: [
'dashboard:view', 'orders:view', 'orders:create', 'orders:edit',
'orders:provision', 'customers:view', 'customers:create', 'customers:edit',
'servers:view', 'servers:power', 'servers:snapshots', 'servers:rescue',
'enterprise:view', 'enterprise:manage',
],
SUPPORT: [
'dashboard:view', 'orders:view', 'customers:view', 'servers:view',
'enterprise:view',
],
}
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false
}),
}))
import { requireStaffPermission, requireStaffSession } from '@/lib/auth-helpers'
describe('Auth Helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('requireStaffPermission', () => {
it('should throw 401 when no session exists', async () => {
mockAuth.mockResolvedValue(null)
try {
await requireStaffPermission('orders:view')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(401)
expect(error.message).toBe('Unauthorized')
}
})
it('should throw 401 when session has no user', async () => {
mockAuth.mockResolvedValue({ user: null })
try {
await requireStaffPermission('orders:view')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(401)
expect(error.message).toBe('Unauthorized')
}
})
it('should throw 403 when user is not staff', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'user-123',
email: 'customer@example.com',
name: 'Customer',
userType: 'customer',
},
})
try {
await requireStaffPermission('orders:view')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Staff access required')
}
})
it('should throw 403 when staff lacks required permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-123',
email: 'support@letsbe.cloud',
name: 'Support Staff',
userType: 'staff',
role: 'SUPPORT',
},
})
try {
await requireStaffPermission('staff:delete')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Insufficient permissions')
}
})
it('should return session for OWNER with any permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-owner',
email: 'owner@letsbe.cloud',
name: 'Owner',
userType: 'staff',
role: 'OWNER',
},
})
const session = await requireStaffPermission('staff:delete')
expect(session.user.id).toBe('staff-owner')
expect(session.user.role).toBe('OWNER')
expect(session.user.userType).toBe('staff')
})
it('should return session for ADMIN with orders:view permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-admin',
email: 'admin@letsbe.cloud',
name: 'Admin',
userType: 'staff',
role: 'ADMIN',
},
})
const session = await requireStaffPermission('orders:view')
expect(session.user.id).toBe('staff-admin')
expect(session.user.role).toBe('ADMIN')
})
it('should deny ADMIN staff:delete permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-admin',
email: 'admin@letsbe.cloud',
name: 'Admin',
userType: 'staff',
role: 'ADMIN',
},
})
try {
await requireStaffPermission('staff:delete')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Insufficient permissions')
}
})
it('should allow MANAGER orders:create permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-manager',
email: 'manager@letsbe.cloud',
name: 'Manager',
userType: 'staff',
role: 'MANAGER',
},
})
const session = await requireStaffPermission('orders:create')
expect(session.user.role).toBe('MANAGER')
})
it('should deny MANAGER settings:edit permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-manager',
email: 'manager@letsbe.cloud',
name: 'Manager',
userType: 'staff',
role: 'MANAGER',
},
})
try {
await requireStaffPermission('settings:edit')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
}
})
it('should deny SUPPORT orders:create permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-support',
email: 'support@letsbe.cloud',
name: 'Support',
userType: 'staff',
role: 'SUPPORT',
},
})
try {
await requireStaffPermission('orders:create')
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
}
})
it('should allow SUPPORT orders:view permission', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-support',
email: 'support@letsbe.cloud',
name: 'Support',
userType: 'staff',
role: 'SUPPORT',
},
})
const session = await requireStaffPermission('orders:view')
expect(session.user.role).toBe('SUPPORT')
})
it('should set email to empty string when not provided', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-123',
email: null,
name: 'No Email',
userType: 'staff',
role: 'OWNER',
},
})
const session = await requireStaffPermission('dashboard:view')
expect(session.user.email).toBe('')
})
})
describe('requireStaffSession', () => {
it('should throw 401 when no session exists', async () => {
mockAuth.mockResolvedValue(null)
try {
await requireStaffSession()
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(401)
expect(error.message).toBe('Unauthorized')
}
})
it('should throw 403 when user is not staff', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'customer-123',
email: 'customer@example.com',
userType: 'customer',
},
})
try {
await requireStaffSession()
expect.fail('Should have thrown')
} catch (error: any) {
expect(error.status).toBe(403)
expect(error.message).toBe('Staff access required')
}
})
it('should return session for any staff role without permission check', async () => {
mockAuth.mockResolvedValue({
user: {
id: 'staff-support',
email: 'support@letsbe.cloud',
name: 'Support',
userType: 'staff',
role: 'SUPPORT',
},
})
const session = await requireStaffSession()
expect(session.user.id).toBe('staff-support')
expect(session.user.userType).toBe('staff')
expect(session.user.role).toBe('SUPPORT')
})
})
})

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import crypto from 'crypto'
// Import after prisma mock is set up
import { apiKeyService } from '@/lib/services/api-key-service'
describe('ApiKeyService', () => {
beforeEach(() => {
resetPrismaMock()
})
describe('generateKey', () => {
it('should generate a key with hk_ prefix', () => {
const key = apiKeyService.generateKey()
expect(key).toMatch(/^hk_/)
})
it('should generate a 68-character key (hk_ + 64 hex chars)', () => {
const key = apiKeyService.generateKey()
// "hk_" (3 chars) + 32 bytes hex (64 chars) = 67 chars
expect(key).toHaveLength(67)
})
it('should generate unique keys each time', () => {
const key1 = apiKeyService.generateKey()
const key2 = apiKeyService.generateKey()
expect(key1).not.toBe(key2)
})
it('should generate key with valid hex after prefix', () => {
const key = apiKeyService.generateKey()
const hexPart = key.slice(3)
expect(hexPart).toMatch(/^[0-9a-f]+$/)
})
})
describe('hashKey', () => {
it('should return a SHA-256 hex hash', () => {
const key = 'hk_test-api-key'
const hash = apiKeyService.hashKey(key)
// SHA-256 produces 64 hex chars
expect(hash).toHaveLength(64)
expect(hash).toMatch(/^[0-9a-f]+$/)
})
it('should produce consistent hashes for the same input', () => {
const key = 'hk_same-key'
const hash1 = apiKeyService.hashKey(key)
const hash2 = apiKeyService.hashKey(key)
expect(hash1).toBe(hash2)
})
it('should produce different hashes for different inputs', () => {
const hash1 = apiKeyService.hashKey('hk_key-one')
const hash2 = apiKeyService.hashKey('hk_key-two')
expect(hash1).not.toBe(hash2)
})
it('should match Node.js crypto SHA-256 output', () => {
const key = 'hk_verification-key'
const expectedHash = crypto.createHash('sha256').update(key).digest('hex')
const hash = apiKeyService.hashKey(key)
expect(hash).toBe(expectedHash)
})
})
describe('findByApiKey', () => {
const mockConnection = {
id: 'conn-123',
hubApiKey: 'hk_plain-key',
hubApiKeyHash: null,
orderId: 'order-456',
order: {
id: 'order-456',
domain: 'test.letsbe.cloud',
serverIp: '192.168.1.100',
},
}
it('should find connection by hash (preferred path)', async () => {
const apiKey = 'hk_test-key'
const hash = crypto.createHash('sha256').update(apiKey).digest('hex')
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
hubApiKeyHash: hash,
} as any)
const result = await apiKeyService.findByApiKey(apiKey)
expect(result).toBeDefined()
// First call should be hash lookup
expect(prismaMock.serverConnection.findUnique).toHaveBeenCalledWith({
where: { hubApiKeyHash: hash },
include: {
order: {
select: {
id: true,
domain: true,
serverIp: true,
},
},
},
})
})
it('should fallback to plaintext lookup when hash not found', async () => {
const apiKey = 'hk_legacy-key'
// First call (hash lookup) returns null
prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null)
// Second call (plaintext lookup) returns the connection
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
hubApiKey: apiKey,
hubApiKeyHash: null,
} as any)
prismaMock.serverConnection.update.mockResolvedValue({} as any)
const result = await apiKeyService.findByApiKey(apiKey)
expect(result).toBeDefined()
// Should have been called twice: hash lookup, then plaintext
expect(prismaMock.serverConnection.findUnique).toHaveBeenCalledTimes(2)
expect(prismaMock.serverConnection.findUnique).toHaveBeenNthCalledWith(2, {
where: { hubApiKey: apiKey },
include: expect.any(Object),
})
})
it('should migrate plaintext key to hash when found via fallback', async () => {
const apiKey = 'hk_migrate-me'
const expectedHash = crypto.createHash('sha256').update(apiKey).digest('hex')
// Hash lookup fails
prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null)
// Plaintext lookup succeeds with no hash
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
id: 'conn-migrate',
hubApiKey: apiKey,
hubApiKeyHash: null,
} as any)
prismaMock.serverConnection.update.mockResolvedValue({} as any)
await apiKeyService.findByApiKey(apiKey)
// Should have triggered migration update
expect(prismaMock.serverConnection.update).toHaveBeenCalledWith({
where: { id: 'conn-migrate' },
data: { hubApiKeyHash: expectedHash },
})
})
it('should not migrate if hash already exists', async () => {
const apiKey = 'hk_already-migrated'
const hash = crypto.createHash('sha256').update(apiKey).digest('hex')
// Hash lookup fails
prismaMock.serverConnection.findUnique.mockResolvedValueOnce(null)
// Plaintext lookup succeeds but hash already set
prismaMock.serverConnection.findUnique.mockResolvedValueOnce({
...mockConnection,
hubApiKey: apiKey,
hubApiKeyHash: hash,
} as any)
await apiKeyService.findByApiKey(apiKey)
// Should NOT have triggered migration
expect(prismaMock.serverConnection.update).not.toHaveBeenCalled()
})
it('should return null when key not found by either method', async () => {
prismaMock.serverConnection.findUnique.mockResolvedValue(null)
const result = await apiKeyService.findByApiKey('hk_nonexistent')
expect(result).toBeNull()
})
})
})

View File

@@ -0,0 +1,458 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import { OrderStatus, AutomationMode, LogLevel } from '@prisma/client'
import {
processAutomation,
setAutomationMode,
resumeAutomation,
pauseAutomation,
takeManualControl,
enableAutoMode,
} from '@/lib/services/automation-worker'
describe('Automation Worker', () => {
beforeEach(() => {
resetPrismaMock()
// Suppress console.error from logAutomation error handling
vi.spyOn(console, 'error').mockImplementation(() => {})
// Mock provisioningLog.create for all tests (used by logAutomation)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
})
describe('processAutomation', () => {
it('should return not triggered when order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
const result = await processAutomation('invalid-id')
expect(result.triggered).toBe(false)
expect(result.error).toBe('Order not found')
})
it('should skip processing when mode is MANUAL', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.MANUAL,
status: OrderStatus.SERVER_READY,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('MANUAL')
})
it('should skip processing when mode is PAUSED', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.PAUSED,
status: OrderStatus.SERVER_READY,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('PAUSED')
})
it('should wait for server credentials in AWAITING_SERVER status', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.AWAITING_SERVER,
serverIp: null,
serverPasswordEncrypted: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('Waiting for server credentials')
})
it('should auto-transition AWAITING_SERVER to SERVER_READY when credentials present', async () => {
// First call: AWAITING_SERVER with credentials
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.AWAITING_SERVER,
serverIp: '10.0.0.1',
serverPasswordEncrypted: 'enc-password',
dnsVerification: null,
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Recursive call after transition: now SERVER_READY
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.SERVER_READY,
serverIp: '10.0.0.1',
serverPasswordEncrypted: 'enc-password',
dnsVerification: null,
serverConnection: null,
} as any)
await processAutomation('order-123')
// Should have updated to SERVER_READY
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.SERVER_READY,
serverReadyAt: expect.any(Date),
}),
})
})
it('should auto-transition SERVER_READY to DNS_PENDING', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.SERVER_READY,
dnsVerification: null,
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toContain('DNS_PENDING')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: { status: OrderStatus.DNS_PENDING },
})
})
it('should wait for DNS in DNS_PENDING status when not verified', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_PENDING,
dnsVerification: { allPassed: false, manualOverride: false },
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('Waiting for DNS verification')
})
it('should auto-transition DNS_PENDING to DNS_READY when verified', async () => {
// First call: DNS_PENDING with allPassed
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_PENDING,
dnsVerification: { allPassed: true, manualOverride: false },
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Recursive call: DNS_READY
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: { allPassed: true, manualOverride: false },
serverConnection: null,
} as any)
await processAutomation('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
dnsVerifiedAt: expect.any(Date),
}),
})
})
it('should auto-transition DNS_PENDING to DNS_READY when manually overridden', async () => {
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_PENDING,
dnsVerification: { allPassed: false, manualOverride: true },
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Recursive call
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: { allPassed: false, manualOverride: true },
serverConnection: null,
} as any)
await processAutomation('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
}),
})
})
it('should signal ready for provisioning in DNS_READY status', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: { allPassed: true },
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toContain('provision')
})
it('should report provisioning in progress', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.PROVISIONING,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('Provisioning in progress')
})
it('should report order fulfilled', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FULFILLED,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.action).toContain('fulfilled')
})
it('should auto-pause on failure', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FAILED,
dnsVerification: null,
serverConnection: null,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
const result = await processAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toContain('Paused')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.PAUSED,
automationPausedAt: expect.any(Date),
}),
})
})
})
describe('setAutomationMode', () => {
it('should update order to PAUSED with reason', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
prismaMock.order.findUnique.mockResolvedValue(null) // For processAutomation if called
await setAutomationMode('order-123', AutomationMode.PAUSED, 'Test pause reason')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.PAUSED,
automationPausedAt: expect.any(Date),
automationPausedReason: 'Test pause reason',
}),
})
})
it('should clear pause info when switching to AUTO', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
// processAutomation is called after switching to AUTO
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FULFILLED,
dnsVerification: null,
serverConnection: null,
} as any)
await setAutomationMode('order-123', AutomationMode.AUTO)
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.AUTO,
automationPausedAt: null,
automationPausedReason: null,
}),
})
})
it('should clear pause info when switching to MANUAL', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await setAutomationMode('order-123', AutomationMode.MANUAL)
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.MANUAL,
automationPausedAt: null,
automationPausedReason: null,
}),
})
})
it('should trigger processAutomation when switching to AUTO', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.SERVER_READY,
dnsVerification: null,
serverConnection: null,
} as any)
await setAutomationMode('order-123', AutomationMode.AUTO)
// processAutomation should have been called (it reads order)
expect(prismaMock.order.findUnique).toHaveBeenCalled()
})
})
describe('resumeAutomation', () => {
it('should return error when order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
const result = await resumeAutomation('invalid-id')
expect(result.triggered).toBe(false)
expect(result.error).toBe('Order not found')
})
it('should return error when order is not paused', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.MANUAL,
} as any)
const result = await resumeAutomation('order-123')
expect(result.triggered).toBe(false)
expect(result.error).toBe('Order is not paused')
})
it('should resume paused order to AUTO mode', async () => {
// First call: check if paused
prismaMock.order.findUnique.mockResolvedValueOnce({
id: 'order-123',
automationMode: AutomationMode.PAUSED,
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
// Second call from processAutomation after setAutomationMode
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.FULFILLED,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await resumeAutomation('order-123')
expect(result.triggered).toBe(true)
expect(result.action).toBe('Automation resumed')
})
})
describe('pauseAutomation', () => {
it('should set mode to PAUSED with reason', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await pauseAutomation('order-123', 'Need to investigate')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.PAUSED,
automationPausedReason: 'Need to investigate',
}),
})
})
it('should use default reason when none provided', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await pauseAutomation('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationPausedReason: 'Manually paused by staff',
}),
})
})
})
describe('takeManualControl', () => {
it('should set mode to MANUAL', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
await takeManualControl('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.MANUAL,
}),
})
})
})
describe('enableAutoMode', () => {
it('should set mode to AUTO and trigger processing', async () => {
prismaMock.order.update.mockResolvedValue({} as any)
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
automationMode: AutomationMode.AUTO,
status: OrderStatus.DNS_READY,
dnsVerification: null,
serverConnection: null,
} as any)
const result = await enableAutoMode('order-123')
expect(result.triggered).toBe(true)
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
automationMode: AutomationMode.AUTO,
}),
})
})
})
})

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { SubscriptionTier } from '@prisma/client'
import crypto from 'crypto'
// Mock the credential service
vi.mock('@/lib/services/credential-service', () => ({
credentialService: {
decrypt: vi.fn().mockReturnValue('decrypted-password'),
decryptLegacy: vi.fn().mockReturnValue('legacy-password'),
},
}))
import {
generateJobConfig,
generateRunnerToken,
hashRunnerToken,
verifyRunnerToken,
decryptPassword,
} from '@/lib/services/config-generator'
import { credentialService } from '@/lib/services/credential-service'
describe('Config Generator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateJobConfig', () => {
const validOrder = {
id: 'order-123',
serverIp: '192.168.1.100',
sshPort: 22,
domain: 'test.letsbe.cloud',
customer: 'Test Customer',
companyName: 'Test Company',
licenseKey: 'lb_inst_abc123',
tier: SubscriptionTier.HUB_DASHBOARD,
tools: ['orchestrator', 'sysadmin-agent', 'nextcloud'],
} as any
it('should generate valid config from order', () => {
const config = generateJobConfig(validOrder, 'my-password')
expect(config.server.ip).toBe('192.168.1.100')
expect(config.server.port).toBe(22)
expect(config.server.rootPassword).toBe('my-password')
expect(config.customer).toBe('Test Customer')
expect(config.domain).toBe('test.letsbe.cloud')
expect(config.companyName).toBe('Test Company')
expect(config.licenseKey).toBe('lb_inst_abc123')
expect(config.tools).toEqual(['orchestrator', 'sysadmin-agent', 'nextcloud'])
})
it('should map HUB_DASHBOARD tier correctly', () => {
const config = generateJobConfig(validOrder, 'password')
expect(config.dashboardTier).toBe('HUB_DASHBOARD')
})
it('should map ADVANCED tier correctly', () => {
const order = { ...validOrder, tier: SubscriptionTier.ADVANCED }
const config = generateJobConfig(order, 'password')
expect(config.dashboardTier).toBe('ADVANCED')
})
it('should default SSH port to 22 when not set', () => {
const order = { ...validOrder, sshPort: null }
const config = generateJobConfig(order, 'password')
expect(config.server.port).toBe(22)
})
it('should set hubUrl from HUB_URL env var', () => {
const original = process.env.HUB_URL
process.env.HUB_URL = 'https://hub.letsbe.cloud'
const config = generateJobConfig(validOrder, 'password')
expect(config.hubUrl).toBe('https://hub.letsbe.cloud')
process.env.HUB_URL = original
})
it('should set hubTelemetryEnabled to true', () => {
const config = generateJobConfig(validOrder, 'password')
expect(config.hubTelemetryEnabled).toBe(true)
})
it('should throw if server IP is missing', () => {
const order = { ...validOrder, serverIp: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing server IP')
})
it('should throw if customer is missing', () => {
const order = { ...validOrder, customer: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing customer identifier')
})
it('should throw if company name is missing', () => {
const order = { ...validOrder, companyName: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing company name')
})
it('should throw if license key is missing', () => {
const order = { ...validOrder, licenseKey: null }
expect(() => generateJobConfig(order, 'password')).toThrow('missing license key')
})
it('should include Docker Hub credentials when provided', () => {
const dockerHub = {
username: 'docker-user',
token: 'docker-token',
registry: 'https://registry.example.com',
}
const config = generateJobConfig(validOrder, 'password', dockerHub)
expect(config.dockerHub).toBeDefined()
expect(config.dockerHub?.username).toBe('docker-user')
expect(config.dockerHub?.token).toBe('docker-token')
expect(config.dockerHub?.registry).toBe('https://registry.example.com')
})
it('should not include Docker Hub when credentials are empty', () => {
const dockerHub = { username: '', token: '' }
const config = generateJobConfig(validOrder, 'password', dockerHub)
expect(config.dockerHub).toBeUndefined()
})
it('should include Gitea credentials when provided', () => {
const gitea = {
registry: 'https://code.letsbe.solutions',
username: 'gitea-user',
token: 'gitea-token',
}
const config = generateJobConfig(validOrder, 'password', undefined, gitea)
expect(config.gitea).toBeDefined()
expect(config.gitea?.registry).toBe('https://code.letsbe.solutions')
expect(config.gitea?.username).toBe('gitea-user')
expect(config.gitea?.token).toBe('gitea-token')
})
it('should not include Gitea when credentials are incomplete', () => {
const gitea = { registry: 'https://code.letsbe.solutions', username: '', token: '' }
const config = generateJobConfig(validOrder, 'password', undefined, gitea)
expect(config.gitea).toBeUndefined()
})
})
describe('decryptPassword', () => {
it('should use new credential service decrypt by default', () => {
const result = decryptPassword('encrypted-value')
expect(credentialService.decrypt).toHaveBeenCalledWith('encrypted-value')
expect(result).toBe('decrypted-password')
})
it('should fall back to legacy decrypt when new format fails', () => {
vi.mocked(credentialService.decrypt).mockImplementation(() => {
throw new Error('Invalid format')
})
const result = decryptPassword('legacy-encrypted-value')
expect(credentialService.decryptLegacy).toHaveBeenCalledWith('legacy-encrypted-value')
expect(result).toBe('legacy-password')
})
})
describe('generateRunnerToken', () => {
it('should generate a 64-character hex string', () => {
const token = generateRunnerToken()
expect(token).toHaveLength(64)
expect(token).toMatch(/^[0-9a-f]+$/)
})
it('should generate unique tokens', () => {
const token1 = generateRunnerToken()
const token2 = generateRunnerToken()
expect(token1).not.toBe(token2)
})
})
describe('hashRunnerToken', () => {
it('should return SHA-256 hash', () => {
const token = 'test-token'
const expectedHash = crypto.createHash('sha256').update(token).digest('hex')
const hash = hashRunnerToken(token)
expect(hash).toBe(expectedHash)
expect(hash).toHaveLength(64)
})
it('should produce consistent hashes', () => {
const token = 'consistent-token'
expect(hashRunnerToken(token)).toBe(hashRunnerToken(token))
})
})
describe('verifyRunnerToken', () => {
it('should return true for matching token', () => {
const token = 'valid-token'
const hash = hashRunnerToken(token)
expect(verifyRunnerToken(token, hash)).toBe(true)
})
it('should return false for non-matching token', () => {
const hash = hashRunnerToken('correct-token')
expect(verifyRunnerToken('wrong-token', hash)).toBe(false)
})
it('should use timing-safe comparison', () => {
// Verify it uses crypto.timingSafeEqual by checking it handles
// matching tokens correctly (if it wasn't timing-safe, this would still work)
const token = generateRunnerToken()
const hash = hashRunnerToken(token)
expect(verifyRunnerToken(token, hash)).toBe(true)
})
})
})

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import { credentialService } from '@/lib/services/credential-service'
describe('CredentialService', () => {
// Store original env values
const originalEnv = { ...process.env }
beforeAll(() => {
// Set up test encryption keys
process.env.CREDENTIAL_ENCRYPTION_KEY = 'test-credential-encryption-key!!'
process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars-long'
})
afterAll(() => {
// Restore original env
process.env = originalEnv
})
describe('encrypt', () => {
it('should encrypt plaintext to iv:ciphertext format', () => {
const plaintext = 'my-secret-password'
const encrypted = credentialService.encrypt(plaintext)
// Should have iv:ciphertext format
expect(encrypted).toContain(':')
const [iv, ciphertext] = encrypted.split(':')
// IV should be 32 hex chars (16 bytes)
expect(iv).toHaveLength(32)
expect(iv).toMatch(/^[0-9a-f]+$/)
// Ciphertext should be hex encoded
expect(ciphertext).toMatch(/^[0-9a-f]+$/)
})
it('should produce different ciphertexts for same plaintext (random IV)', () => {
const plaintext = 'same-password'
const encrypted1 = credentialService.encrypt(plaintext)
const encrypted2 = credentialService.encrypt(plaintext)
// Different IVs mean different ciphertexts
expect(encrypted1).not.toBe(encrypted2)
})
it('should handle empty strings', () => {
const encrypted = credentialService.encrypt('')
expect(encrypted).toContain(':')
// Empty string still produces ciphertext (padding)
const [, ciphertext] = encrypted.split(':')
expect(ciphertext.length).toBeGreaterThan(0)
})
it('should handle special characters', () => {
const plaintext = 'p@$$w0rd!#$%^&*()_+-=[]{}|;:,.<>?'
const encrypted = credentialService.encrypt(plaintext)
const decrypted = credentialService.decrypt(encrypted)
expect(decrypted).toBe(plaintext)
})
it('should handle unicode characters', () => {
const plaintext = '密码123🔐'
const encrypted = credentialService.encrypt(plaintext)
const decrypted = credentialService.decrypt(encrypted)
expect(decrypted).toBe(plaintext)
})
it('should handle very long strings', () => {
const plaintext = 'a'.repeat(10000)
const encrypted = credentialService.encrypt(plaintext)
const decrypted = credentialService.decrypt(encrypted)
expect(decrypted).toBe(plaintext)
})
})
describe('decrypt', () => {
it('should decrypt ciphertext to original plaintext', () => {
const plaintext = 'my-secret-password'
const encrypted = credentialService.encrypt(plaintext)
const decrypted = credentialService.decrypt(encrypted)
expect(decrypted).toBe(plaintext)
})
it('should throw on invalid format (no colon)', () => {
expect(() => credentialService.decrypt('invalid-no-colon')).toThrow(
'Invalid ciphertext format'
)
})
it('should throw on invalid format (empty parts)', () => {
expect(() => credentialService.decrypt(':encrypted')).toThrow(
'Invalid ciphertext format'
)
expect(() => credentialService.decrypt('iv:')).toThrow(
'Invalid ciphertext format'
)
})
it('should throw on invalid hex in IV', () => {
expect(() => credentialService.decrypt('not-hex:abcdef')).toThrow()
})
it('should throw on tampered ciphertext', () => {
const encrypted = credentialService.encrypt('secret')
// Tamper with the ciphertext
const [iv, ciphertext] = encrypted.split(':')
const tampered = `${iv}:ff${ciphertext.slice(2)}`
expect(() => credentialService.decrypt(tampered)).toThrow()
})
it('should throw on wrong key', () => {
// Encrypt with current key
const encrypted = credentialService.encrypt('secret')
// Change the key
const originalKey = process.env.CREDENTIAL_ENCRYPTION_KEY
process.env.CREDENTIAL_ENCRYPTION_KEY = 'different-key-32-characters!!!!!'
// Try to decrypt - should fail due to key mismatch
expect(() => credentialService.decrypt(encrypted)).toThrow()
// Restore
process.env.CREDENTIAL_ENCRYPTION_KEY = originalKey
})
})
describe('isConfigured', () => {
it('should return true when CREDENTIAL_ENCRYPTION_KEY is set', () => {
process.env.CREDENTIAL_ENCRYPTION_KEY = 'some-key'
expect(credentialService.isConfigured()).toBe(true)
})
it('should return false when CREDENTIAL_ENCRYPTION_KEY is not set', () => {
const original = process.env.CREDENTIAL_ENCRYPTION_KEY
delete process.env.CREDENTIAL_ENCRYPTION_KEY
expect(credentialService.isConfigured()).toBe(false)
process.env.CREDENTIAL_ENCRYPTION_KEY = original
})
})
describe('decryptLegacy', () => {
it('should decrypt values encrypted with legacy ENCRYPTION_KEY', () => {
// Create a value encrypted with legacy format
// Legacy uses ENCRYPTION_KEY with 'salt' as the scrypt salt
const crypto = require('crypto')
const legacyKey = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32)
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv('aes-256-cbc', legacyKey, iv)
let encrypted = cipher.update('legacy-secret', 'utf8', 'hex')
encrypted += cipher.final('hex')
const legacyCiphertext = `${iv.toString('hex')}:${encrypted}`
const decrypted = credentialService.decryptLegacy(legacyCiphertext)
expect(decrypted).toBe('legacy-secret')
})
it('should throw on invalid format', () => {
expect(() => credentialService.decryptLegacy('invalid')).toThrow(
'Invalid ciphertext format'
)
})
})
describe('migrateFromLegacy', () => {
it('should re-encrypt from legacy format to new format', () => {
// Create legacy encrypted value
const crypto = require('crypto')
const legacyKey = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32)
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv('aes-256-cbc', legacyKey, iv)
let encrypted = cipher.update('migrate-me', 'utf8', 'hex')
encrypted += cipher.final('hex')
const legacyCiphertext = `${iv.toString('hex')}:${encrypted}`
// Migrate to new format
const newCiphertext = credentialService.migrateFromLegacy(legacyCiphertext)
// Should be decryptable with new format
const decrypted = credentialService.decrypt(newCiphertext)
expect(decrypted).toBe('migrate-me')
// Should NOT be decryptable with legacy format (different key derivation)
expect(newCiphertext).not.toBe(legacyCiphertext)
})
})
describe('isLegacyConfigured', () => {
it('should return true when ENCRYPTION_KEY is set', () => {
process.env.ENCRYPTION_KEY = 'some-legacy-key'
expect(credentialService.isLegacyConfigured()).toBe(true)
})
it('should return false when ENCRYPTION_KEY is not set', () => {
const original = process.env.ENCRYPTION_KEY
delete process.env.ENCRYPTION_KEY
expect(credentialService.isLegacyConfigured()).toBe(false)
process.env.ENCRYPTION_KEY = original
})
})
describe('error handling without keys', () => {
it('should throw when encrypting without CREDENTIAL_ENCRYPTION_KEY', () => {
const original = process.env.CREDENTIAL_ENCRYPTION_KEY
delete process.env.CREDENTIAL_ENCRYPTION_KEY
expect(() => credentialService.encrypt('test')).toThrow(
'CREDENTIAL_ENCRYPTION_KEY environment variable is required'
)
process.env.CREDENTIAL_ENCRYPTION_KEY = original
})
it('should throw when decrypting legacy without ENCRYPTION_KEY', () => {
const original = process.env.ENCRYPTION_KEY
delete process.env.ENCRYPTION_KEY
expect(() => credentialService.decryptLegacy('abc:def')).toThrow(
'ENCRYPTION_KEY environment variable is required for legacy decryption'
)
process.env.ENCRYPTION_KEY = original
})
})
describe('round-trip encryption', () => {
it('should successfully round-trip various data types', () => {
const testCases = [
'simple-password',
'with spaces and tabs\t',
'multi\nline\nstring',
JSON.stringify({ user: 'admin', pass: 'secret123' }),
'12345',
'!@#$%^&*()',
]
for (const plaintext of testCases) {
const encrypted = credentialService.encrypt(plaintext)
const decrypted = credentialService.decrypt(encrypted)
expect(decrypted).toBe(plaintext)
}
})
})
})

View File

@@ -0,0 +1,456 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import { DnsRecordStatus, OrderStatus, LogLevel } from '@prisma/client'
// Mock the dns module
vi.mock('dns', () => {
const mockResolve4 = vi.fn()
return {
default: {
resolve4: mockResolve4,
},
resolve4: mockResolve4,
}
})
// Import after mocking
import dns from 'dns'
import {
getSubdomainsForTools,
verifyDns,
runDnsVerification,
skipDnsVerification,
getDnsStatus,
} from '@/lib/services/dns-service'
// Helper to make dns.resolve4 work as expected with promisify
function mockDnsResolve(mapping: Record<string, string[] | null>) {
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
const result = mapping[domain]
if (result === null || result === undefined) {
callback(new Error('ENOTFOUND'))
} else {
callback(null, result)
}
})
}
describe('DNS Service', () => {
beforeEach(() => {
resetPrismaMock()
vi.clearAllMocks()
})
describe('getSubdomainsForTools', () => {
it('should return empty array for core tools with no subdomains', () => {
const subdomains = getSubdomainsForTools(['core', 'portainer', 'orchestrator'])
expect(subdomains).toEqual([])
})
it('should return correct subdomains for poste', () => {
const subdomains = getSubdomainsForTools(['poste'])
expect(subdomains).toContain('mail')
})
it('should return correct subdomains for chatwoot', () => {
const subdomains = getSubdomainsForTools(['chatwoot'])
expect(subdomains).toContain('support')
expect(subdomains).toContain('helpdesk')
})
it('should return correct subdomains for nextcloud', () => {
const subdomains = getSubdomainsForTools(['nextcloud'])
expect(subdomains).toContain('cloud')
expect(subdomains).toContain('collabora')
expect(subdomains).toContain('whiteboard')
})
it('should deduplicate subdomains across tools', () => {
const subdomains = getSubdomainsForTools(['nextcloud', 'nextcloud'])
// Should not have duplicates
const uniqueSubdomains = [...new Set(subdomains)]
expect(subdomains.length).toBe(uniqueSubdomains.length)
})
it('should combine subdomains from multiple tools', () => {
const subdomains = getSubdomainsForTools(['poste', 'keycloak', 'n8n'])
expect(subdomains).toContain('mail')
expect(subdomains).toContain('auth')
expect(subdomains).toContain('n8n')
})
it('should return sorted subdomains', () => {
const subdomains = getSubdomainsForTools(['nextcloud', 'poste', 'keycloak'])
const sorted = [...subdomains].sort()
expect(subdomains).toEqual(sorted)
})
it('should handle unknown tool names gracefully', () => {
const subdomains = getSubdomainsForTools(['unknown-tool', 'poste'])
// Should still include known tool subdomains
expect(subdomains).toContain('mail')
})
it('should be case-insensitive for tool names', () => {
const subdomains = getSubdomainsForTools(['POSTE', 'Keycloak'])
expect(subdomains).toContain('mail')
expect(subdomains).toContain('auth')
})
it('should return correct subdomains for minio', () => {
const subdomains = getSubdomainsForTools(['minio'])
expect(subdomains).toContain('minio')
expect(subdomains).toContain('s3')
})
it('should return correct subdomains for calcom', () => {
const subdomains = getSubdomainsForTools(['calcom'])
expect(subdomains).toContain('bookings')
})
it('should return empty for tools using root domain (ghost, wordpress)', () => {
const subdomains = getSubdomainsForTools(['ghost', 'wordpress'])
expect(subdomains).toEqual([])
})
})
describe('verifyDns', () => {
it('should mark all subdomains as passed via wildcard when wildcard resolves', async () => {
// Mock wildcard check - any subdomain resolves to expected IP
mockDnsResolve({
// The wildcard test uses a random subdomain pattern
})
// Make all DNS lookups resolve to the target IP
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callback(null, ['10.0.0.1'])
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak'])
expect(result.wildcardPassed).toBe(true)
expect(result.allPassed).toBe(true)
expect(result.records.every((r) => r.status === DnsRecordStatus.SKIPPED)).toBe(true)
})
it('should check individual subdomains when wildcard fails', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
// First call is wildcard check - fail it
callback(new Error('ENOTFOUND'))
} else if (domain === 'mail.example.com') {
callback(null, ['10.0.0.1'])
} else if (domain === 'auth.example.com') {
callback(null, ['10.0.0.1'])
} else {
callback(new Error('ENOTFOUND'))
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak'])
expect(result.wildcardPassed).toBe(false)
expect(result.allPassed).toBe(true)
expect(result.passedCount).toBe(2)
})
it('should detect IP mismatch', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
// Wildcard check fails
callback(new Error('ENOTFOUND'))
} else {
// Subdomain resolves to wrong IP
callback(null, ['10.0.0.99'])
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste'])
expect(result.allPassed).toBe(false)
const mailRecord = result.records.find((r) => r.subdomain === 'mail')
expect(mailRecord?.status).toBe(DnsRecordStatus.MISMATCH)
expect(mailRecord?.resolvedIp).toBe('10.0.0.99')
})
it('should handle NOT_FOUND when subdomain has no A record', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((_domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
// Wildcard check fails
callback(new Error('ENOTFOUND'))
} else {
callback(new Error('ENOTFOUND'))
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste'])
expect(result.allPassed).toBe(false)
const mailRecord = result.records.find((r) => r.subdomain === 'mail')
expect(mailRecord?.status).toBe(DnsRecordStatus.NOT_FOUND)
})
it('should return correct counts', async () => {
let callCount = 0
const resolve4 = dns.resolve4 as unknown as ReturnType<typeof vi.fn>
resolve4.mockImplementation((domain: string, callback: (err: Error | null, addresses?: string[]) => void) => {
callCount++
if (callCount === 1) {
callback(new Error('ENOTFOUND')) // wildcard
} else if (domain.startsWith('mail')) {
callback(null, ['10.0.0.1']) // mail passes
} else {
callback(new Error('ENOTFOUND')) // others fail
}
})
const result = await verifyDns('example.com', '10.0.0.1', ['poste', 'keycloak'])
expect(result.totalSubdomains).toBe(2)
expect(result.passedCount).toBe(1) // only mail passes
})
it('should return empty records for tools with no subdomains', async () => {
const result = await verifyDns('example.com', '10.0.0.1', ['core', 'portainer'])
expect(result.totalSubdomains).toBe(0)
expect(result.allPassed).toBe(true)
expect(result.records).toEqual([])
})
})
describe('runDnsVerification', () => {
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(runDnsVerification('invalid-id')).rejects.toThrow('Order not found')
})
it('should throw if server IP not configured', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
serverIp: null,
domain: 'test.com',
tools: ['poste'],
dnsVerification: null,
} as any)
await expect(runDnsVerification('order-123')).rejects.toThrow('Server IP not configured')
})
})
describe('skipDnsVerification', () => {
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(skipDnsVerification('invalid-id')).rejects.toThrow('Order not found')
})
it('should create new dns verification with manual override', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.dnsVerification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
orderId: 'order-123',
manualOverride: true,
allPassed: true,
verifiedAt: expect.any(Date),
}),
})
})
it('should update existing dns verification when one exists', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: {
id: 'dns-456',
},
} as any)
prismaMock.dnsVerification.update.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.dnsVerification.update).toHaveBeenCalledWith({
where: { id: 'dns-456' },
data: expect.objectContaining({
manualOverride: true,
allPassed: true,
}),
})
})
it('should update order status to DNS_READY when currently DNS_PENDING', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
dnsVerifiedAt: expect.any(Date),
}),
})
})
it('should update order status to DNS_READY when currently SERVER_READY', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.SERVER_READY,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.DNS_READY,
}),
})
})
it('should not update order status when not in DNS_PENDING or SERVER_READY', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.FULFILLED,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.order.update).not.toHaveBeenCalled()
})
it('should log the manual override', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
status: OrderStatus.DNS_PENDING,
dnsVerification: null,
} as any)
prismaMock.dnsVerification.create.mockResolvedValue({} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await skipDnsVerification('order-123')
expect(prismaMock.provisioningLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
orderId: 'order-123',
level: LogLevel.WARN,
message: 'DNS verification skipped via manual override',
step: 'dns',
}),
})
})
})
describe('getDnsStatus', () => {
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(getDnsStatus('invalid-id')).rejects.toThrow('Order not found')
})
it('should return status with null verification when none exists', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
domain: 'test.letsbe.cloud',
serverIp: '10.0.0.1',
status: OrderStatus.DNS_PENDING,
tools: ['poste'],
dnsVerification: null,
} as any)
const result = await getDnsStatus('order-123')
expect(result.domain).toBe('test.letsbe.cloud')
expect(result.serverIp).toBe('10.0.0.1')
expect(result.verification).toBeNull()
expect(result.requiredSubdomains).toContain('mail')
})
it('should return status with verification data when it exists', async () => {
const lastChecked = new Date()
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
domain: 'test.letsbe.cloud',
serverIp: '10.0.0.1',
status: OrderStatus.DNS_READY,
tools: ['poste'],
dnsVerification: {
id: 'dns-456',
wildcardPassed: false,
manualOverride: false,
allPassed: true,
totalSubdomains: 1,
passedCount: 1,
lastCheckedAt: lastChecked,
verifiedAt: lastChecked,
records: [
{
subdomain: 'mail',
fullDomain: 'mail.test.letsbe.cloud',
expectedIp: '10.0.0.1',
resolvedIp: '10.0.0.1',
status: DnsRecordStatus.VERIFIED,
},
],
},
} as any)
const result = await getDnsStatus('order-123')
expect(result.verification).not.toBeNull()
expect(result.verification?.allPassed).toBe(true)
expect(result.verification?.records).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,428 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
import { JobStatus, OrderStatus, LogLevel } from '@prisma/client'
import crypto from 'crypto'
// Mock the credential service
vi.mock('@/lib/services/credential-service', () => ({
credentialService: {
decrypt: vi.fn().mockReturnValue('decrypted-password'),
decryptLegacy: vi.fn().mockReturnValue('legacy-decrypted-password'),
},
}))
// Import after mocking
import { JobService, JobConfig } from '@/lib/services/job-service'
describe('JobService', () => {
let jobService: JobService
beforeEach(() => {
resetPrismaMock()
jobService = new JobService()
})
describe('createJobForOrder', () => {
const mockOrder = {
id: 'order-123',
serverIp: '192.168.1.100',
serverPasswordEncrypted: 'encrypted-password',
sshPort: 22,
domain: 'test.letsbe.cloud',
tier: 'professional',
tools: ['orchestrator', 'sysadmin-agent'],
user: {
id: 'user-123',
email: 'customer@example.com',
name: 'Test Customer',
company: 'Test Company',
subscriptions: [{ id: 'sub-1', status: 'ACTIVE' }],
},
}
it('should create a job for a valid order', async () => {
prismaMock.order.findUnique.mockResolvedValue(mockOrder as any)
prismaMock.provisioningJob.create.mockResolvedValue({
id: 'job-456',
orderId: 'order-123',
jobType: 'provision',
status: JobStatus.PENDING,
configSnapshot: {},
runnerTokenHash: 'hash',
createdAt: new Date(),
updatedAt: new Date(),
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
const result = await jobService.createJobForOrder('order-123')
const parsed = JSON.parse(result)
expect(parsed.jobId).toBe('job-456')
expect(parsed.runnerToken).toBeDefined()
expect(parsed.runnerToken.length).toBe(64) // 32 bytes hex
// Verify order was looked up
expect(prismaMock.order.findUnique).toHaveBeenCalledWith({
where: { id: 'order-123' },
include: expect.any(Object),
})
// Verify job was created with config snapshot
expect(prismaMock.provisioningJob.create).toHaveBeenCalledWith({
data: expect.objectContaining({
orderId: 'order-123',
jobType: 'provision',
configSnapshot: expect.any(Object),
runnerTokenHash: expect.any(String),
}),
})
// Verify order status was updated
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-123' },
data: expect.objectContaining({
status: OrderStatus.PROVISIONING,
}),
})
})
it('should throw if order not found', async () => {
prismaMock.order.findUnique.mockResolvedValue(null)
await expect(jobService.createJobForOrder('invalid-id')).rejects.toThrow(
'Order invalid-id not found'
)
})
it('should throw if order missing server credentials', async () => {
prismaMock.order.findUnique.mockResolvedValue({
id: 'order-123',
serverIp: null,
serverPasswordEncrypted: null,
user: { email: 'test@example.com' },
} as any)
await expect(jobService.createJobForOrder('order-123')).rejects.toThrow(
'missing server credentials'
)
})
})
describe('verifyRunnerToken', () => {
it('should return true for valid token', async () => {
const token = 'test-token'
const hash = crypto.createHash('sha256').update(token).digest('hex')
prismaMock.provisioningJob.findUnique.mockResolvedValue({
id: 'job-123',
runnerTokenHash: hash,
} as any)
const result = await jobService.verifyRunnerToken('job-123', token)
expect(result).toBe(true)
})
it('should return false for invalid token', async () => {
const correctHash = crypto.createHash('sha256').update('correct-token').digest('hex')
prismaMock.provisioningJob.findUnique.mockResolvedValue({
id: 'job-123',
runnerTokenHash: correctHash,
} as any)
const result = await jobService.verifyRunnerToken('job-123', 'wrong-token')
expect(result).toBe(false)
})
it('should return false for non-existent job', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue(null)
const result = await jobService.verifyRunnerToken('invalid-job', 'any-token')
expect(result).toBe(false)
})
it('should return false if job has no token hash', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue({
id: 'job-123',
runnerTokenHash: null,
} as any)
const result = await jobService.verifyRunnerToken('job-123', 'any-token')
expect(result).toBe(false)
})
})
describe('addLog', () => {
it('should create job log and provisioning log', async () => {
prismaMock.jobLog.create.mockResolvedValue({} as any)
prismaMock.provisioningJob.findUnique.mockResolvedValue({
id: 'job-123',
orderId: 'order-456',
} as any)
prismaMock.provisioningLog.create.mockResolvedValue({} as any)
await jobService.addLog('job-123', 'info', 'Test message', 'test-step', 50)
expect(prismaMock.jobLog.create).toHaveBeenCalledWith({
data: {
jobId: 'job-123',
level: LogLevel.INFO,
message: 'Test message',
step: 'test-step',
progress: 50,
},
})
expect(prismaMock.provisioningLog.create).toHaveBeenCalledWith({
data: {
orderId: 'order-456',
level: LogLevel.INFO,
message: 'Test message',
step: 'test-step',
},
})
})
it('should map log levels correctly', async () => {
prismaMock.jobLog.create.mockResolvedValue({} as any)
prismaMock.provisioningJob.findUnique.mockResolvedValue(null)
await jobService.addLog('job-123', 'info', 'Info message')
expect(prismaMock.jobLog.create).toHaveBeenLastCalledWith(
expect.objectContaining({
data: expect.objectContaining({ level: LogLevel.INFO }),
})
)
await jobService.addLog('job-123', 'warn', 'Warn message')
expect(prismaMock.jobLog.create).toHaveBeenLastCalledWith(
expect.objectContaining({
data: expect.objectContaining({ level: LogLevel.WARN }),
})
)
await jobService.addLog('job-123', 'error', 'Error message')
expect(prismaMock.jobLog.create).toHaveBeenLastCalledWith(
expect.objectContaining({
data: expect.objectContaining({ level: LogLevel.ERROR }),
})
)
})
})
describe('completeJob', () => {
it('should mark job as completed and update order', async () => {
prismaMock.provisioningJob.update.mockResolvedValue({
id: 'job-123',
orderId: 'order-456',
} as any)
prismaMock.order.update.mockResolvedValue({} as any)
await jobService.completeJob('job-123', { outputFiles: ['file1.txt'] })
expect(prismaMock.provisioningJob.update).toHaveBeenCalledWith({
where: { id: 'job-123' },
data: expect.objectContaining({
status: JobStatus.COMPLETED,
result: { outputFiles: ['file1.txt'] },
}),
})
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-456' },
data: expect.objectContaining({
status: OrderStatus.FULFILLED,
sshPort: 22022, // SSH_PORT_AFTER_PROVISION
serverPasswordEncrypted: null, // Cleared for security
}),
})
})
})
describe('failJob', () => {
const mockJob = {
id: 'job-123',
orderId: 'order-456',
attempt: 1,
maxAttempts: 3,
}
beforeEach(() => {
// Mock addLog to prevent it from making real calls
vi.spyOn(jobService, 'addLog').mockResolvedValue()
})
it('should retry job if attempts remaining', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue(mockJob as any)
prismaMock.provisioningJob.update.mockResolvedValue({} as any)
const result = await jobService.failJob('job-123', 'Connection timeout')
expect(result.willRetry).toBe(true)
expect(result.nextRetryAt).toBeDefined()
expect(prismaMock.provisioningJob.update).toHaveBeenCalledWith({
where: { id: 'job-123' },
data: expect.objectContaining({
status: JobStatus.PENDING,
attempt: 2,
nextRetryAt: expect.any(Date),
claimedAt: null,
claimedBy: null,
runnerTokenHash: null,
}),
})
})
it('should mark job as dead after max attempts', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue({
...mockJob,
attempt: 3, // Already at max
} as any)
prismaMock.provisioningJob.update.mockResolvedValue({} as any)
prismaMock.order.update.mockResolvedValue({} as any)
const result = await jobService.failJob('job-123', 'Final failure')
expect(result.willRetry).toBe(false)
expect(result.nextRetryAt).toBeUndefined()
expect(prismaMock.provisioningJob.update).toHaveBeenCalledWith({
where: { id: 'job-123' },
data: expect.objectContaining({
status: JobStatus.DEAD,
error: 'Final failure',
}),
})
expect(prismaMock.order.update).toHaveBeenCalledWith({
where: { id: 'order-456' },
data: expect.objectContaining({
status: OrderStatus.FAILED,
failureReason: 'Final failure',
}),
})
})
it('should throw if job not found', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue(null)
await expect(jobService.failJob('invalid-job', 'error')).rejects.toThrow(
'Job invalid-job not found'
)
})
})
describe('getJobStatus', () => {
it('should return job status with progress', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue({
status: JobStatus.RUNNING,
attempt: 1,
maxAttempts: 3,
error: null,
} as any)
prismaMock.jobLog.findFirst.mockResolvedValue({
progress: 75,
} as any)
const result = await jobService.getJobStatus('job-123')
expect(result).toEqual({
status: JobStatus.RUNNING,
attempt: 1,
maxAttempts: 3,
progress: 75,
error: undefined,
})
})
it('should return null for non-existent job', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue(null)
const result = await jobService.getJobStatus('invalid-job')
expect(result).toBeNull()
})
it('should include error if present', async () => {
prismaMock.provisioningJob.findUnique.mockResolvedValue({
status: JobStatus.DEAD,
attempt: 3,
maxAttempts: 3,
error: 'Connection refused',
} as any)
prismaMock.jobLog.findFirst.mockResolvedValue(null)
const result = await jobService.getJobStatus('job-123')
expect(result?.error).toBe('Connection refused')
})
})
describe('getPendingJobCount', () => {
it('should return count of pending jobs ready to process', async () => {
prismaMock.provisioningJob.count.mockResolvedValue(5)
const result = await jobService.getPendingJobCount()
expect(result).toBe(5)
expect(prismaMock.provisioningJob.count).toHaveBeenCalledWith({
where: expect.objectContaining({
status: JobStatus.PENDING,
}),
})
})
})
describe('getRunningJobCount', () => {
it('should return count of running jobs', async () => {
prismaMock.provisioningJob.count.mockResolvedValue(2)
const result = await jobService.getRunningJobCount()
expect(result).toBe(2)
expect(prismaMock.provisioningJob.count).toHaveBeenCalledWith({
where: { status: JobStatus.RUNNING },
})
})
})
describe('getLogs', () => {
it('should return logs for a job', async () => {
const mockLogs = [
{ id: 'log-1', timestamp: new Date(), level: 'INFO', message: 'Started', step: 'init', progress: 0 },
{ id: 'log-2', timestamp: new Date(), level: 'INFO', message: 'Running', step: 'execute', progress: 50 },
]
prismaMock.jobLog.findMany.mockResolvedValue(mockLogs as any)
const result = await jobService.getLogs('job-123')
expect(result).toEqual(mockLogs)
expect(prismaMock.jobLog.findMany).toHaveBeenCalledWith({
where: { jobId: 'job-123' },
orderBy: { timestamp: 'asc' },
select: expect.any(Object),
})
})
it('should filter logs by since date', async () => {
const since = new Date('2024-01-01')
prismaMock.jobLog.findMany.mockResolvedValue([])
await jobService.getLogs('job-123', since)
expect(prismaMock.jobLog.findMany).toHaveBeenCalledWith({
where: {
jobId: 'job-123',
timestamp: { gt: since },
},
orderBy: { timestamp: 'asc' },
select: expect.any(Object),
})
})
})
})

View File

@@ -0,0 +1,233 @@
import { describe, it, expect } from 'vitest'
import { StaffRole } from '@prisma/client'
import {
hasPermission,
hasAllPermissions,
hasAnyPermission,
getPermissions,
getRoleDisplayName,
getRoleDescription,
canManageRole,
canDeleteRole,
getAssignableRoles,
} from '@/lib/services/permission-service'
describe('Permission Service', () => {
describe('hasPermission', () => {
it('should grant OWNER all permissions', () => {
expect(hasPermission('OWNER' as StaffRole, 'dashboard:view')).toBe(true)
expect(hasPermission('OWNER' as StaffRole, 'staff:delete')).toBe(true)
expect(hasPermission('OWNER' as StaffRole, 'settings:edit')).toBe(true)
expect(hasPermission('OWNER' as StaffRole, 'enterprise:manage')).toBe(true)
})
it('should grant ADMIN most permissions but not staff:delete', () => {
expect(hasPermission('ADMIN' as StaffRole, 'orders:view')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'orders:create')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'staff:manage')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'settings:edit')).toBe(true)
expect(hasPermission('ADMIN' as StaffRole, 'staff:delete')).toBe(false)
})
it('should grant MANAGER operational permissions but not staff or settings', () => {
expect(hasPermission('MANAGER' as StaffRole, 'orders:view')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'orders:create')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'orders:provision')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'servers:power')).toBe(true)
expect(hasPermission('MANAGER' as StaffRole, 'enterprise:manage')).toBe(true)
// Should NOT have staff or settings access
expect(hasPermission('MANAGER' as StaffRole, 'staff:view')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'staff:invite')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'settings:view')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'settings:edit')).toBe(false)
// Should NOT have delete permissions
expect(hasPermission('MANAGER' as StaffRole, 'orders:delete')).toBe(false)
expect(hasPermission('MANAGER' as StaffRole, 'customers:delete')).toBe(false)
})
it('should grant SUPPORT only view permissions', () => {
expect(hasPermission('SUPPORT' as StaffRole, 'dashboard:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'orders:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'customers:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'servers:view')).toBe(true)
expect(hasPermission('SUPPORT' as StaffRole, 'enterprise:view')).toBe(true)
// Should NOT have any action permissions
expect(hasPermission('SUPPORT' as StaffRole, 'orders:create')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'orders:edit')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'servers:power')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'staff:view')).toBe(false)
expect(hasPermission('SUPPORT' as StaffRole, 'settings:view')).toBe(false)
})
it('should return false for unknown role', () => {
expect(hasPermission('UNKNOWN' as StaffRole, 'dashboard:view')).toBe(false)
})
})
describe('hasAllPermissions', () => {
it('should return true when role has all listed permissions', () => {
expect(
hasAllPermissions('OWNER' as StaffRole, ['orders:view', 'orders:create', 'staff:delete'])
).toBe(true)
})
it('should return false when role lacks any permission', () => {
expect(
hasAllPermissions('SUPPORT' as StaffRole, ['orders:view', 'orders:create'])
).toBe(false)
})
it('should return true for empty permissions list', () => {
expect(hasAllPermissions('SUPPORT' as StaffRole, [])).toBe(true)
})
})
describe('hasAnyPermission', () => {
it('should return true when role has at least one permission', () => {
expect(
hasAnyPermission('SUPPORT' as StaffRole, ['orders:create', 'orders:view'])
).toBe(true)
})
it('should return false when role has none of the permissions', () => {
expect(
hasAnyPermission('SUPPORT' as StaffRole, ['staff:delete', 'settings:edit'])
).toBe(false)
})
it('should return false for empty permissions list', () => {
expect(hasAnyPermission('OWNER' as StaffRole, [])).toBe(false)
})
})
describe('getPermissions', () => {
it('should return all permissions for OWNER', () => {
const perms = getPermissions('OWNER' as StaffRole)
expect(perms).toContain('staff:delete')
expect(perms).toContain('settings:edit')
expect(perms).toContain('enterprise:manage')
expect(perms.length).toBeGreaterThan(15)
})
it('should return fewer permissions for SUPPORT than OWNER', () => {
const ownerPerms = getPermissions('OWNER' as StaffRole)
const supportPerms = getPermissions('SUPPORT' as StaffRole)
expect(supportPerms.length).toBeLessThan(ownerPerms.length)
})
it('should return empty array for unknown role', () => {
expect(getPermissions('UNKNOWN' as StaffRole)).toEqual([])
})
})
describe('getRoleDisplayName', () => {
it('should return correct display names', () => {
expect(getRoleDisplayName('OWNER' as StaffRole)).toBe('Owner')
expect(getRoleDisplayName('ADMIN' as StaffRole)).toBe('Administrator')
expect(getRoleDisplayName('MANAGER' as StaffRole)).toBe('Manager')
expect(getRoleDisplayName('SUPPORT' as StaffRole)).toBe('Support')
})
it('should return raw role for unknown role', () => {
expect(getRoleDisplayName('UNKNOWN' as StaffRole)).toBe('UNKNOWN')
})
})
describe('getRoleDescription', () => {
it('should return non-empty description for each role', () => {
expect(getRoleDescription('OWNER' as StaffRole).length).toBeGreaterThan(0)
expect(getRoleDescription('ADMIN' as StaffRole).length).toBeGreaterThan(0)
expect(getRoleDescription('MANAGER' as StaffRole).length).toBeGreaterThan(0)
expect(getRoleDescription('SUPPORT' as StaffRole).length).toBeGreaterThan(0)
})
it('should mention key restrictions in descriptions', () => {
expect(getRoleDescription('OWNER' as StaffRole)).toContain('Cannot be deleted')
expect(getRoleDescription('ADMIN' as StaffRole)).toContain('not delete')
expect(getRoleDescription('SUPPORT' as StaffRole)).toContain('View-only')
})
it('should return empty string for unknown role', () => {
expect(getRoleDescription('UNKNOWN' as StaffRole)).toBe('')
})
})
describe('canManageRole', () => {
it('should allow OWNER to manage any role', () => {
expect(canManageRole('OWNER' as StaffRole, 'ADMIN' as StaffRole)).toBe(true)
expect(canManageRole('OWNER' as StaffRole, 'MANAGER' as StaffRole)).toBe(true)
expect(canManageRole('OWNER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true)
expect(canManageRole('OWNER' as StaffRole, 'OWNER' as StaffRole)).toBe(true)
})
it('should allow ADMIN to manage MANAGER and SUPPORT', () => {
expect(canManageRole('ADMIN' as StaffRole, 'MANAGER' as StaffRole)).toBe(true)
expect(canManageRole('ADMIN' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true)
})
it('should deny ADMIN managing ADMIN or OWNER', () => {
expect(canManageRole('ADMIN' as StaffRole, 'ADMIN' as StaffRole)).toBe(false)
expect(canManageRole('ADMIN' as StaffRole, 'OWNER' as StaffRole)).toBe(false)
})
it('should deny MANAGER managing anyone', () => {
expect(canManageRole('MANAGER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
expect(canManageRole('MANAGER' as StaffRole, 'MANAGER' as StaffRole)).toBe(false)
})
it('should deny SUPPORT managing anyone', () => {
expect(canManageRole('SUPPORT' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
})
describe('canDeleteRole', () => {
it('should allow OWNER to delete ADMIN, MANAGER, SUPPORT', () => {
expect(canDeleteRole('OWNER' as StaffRole, 'ADMIN' as StaffRole)).toBe(true)
expect(canDeleteRole('OWNER' as StaffRole, 'MANAGER' as StaffRole)).toBe(true)
expect(canDeleteRole('OWNER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(true)
})
it('should deny OWNER deleting another OWNER', () => {
expect(canDeleteRole('OWNER' as StaffRole, 'OWNER' as StaffRole)).toBe(false)
})
it('should deny ADMIN deleting anyone', () => {
expect(canDeleteRole('ADMIN' as StaffRole, 'MANAGER' as StaffRole)).toBe(false)
expect(canDeleteRole('ADMIN' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
it('should deny MANAGER deleting anyone', () => {
expect(canDeleteRole('MANAGER' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
it('should deny SUPPORT deleting anyone', () => {
expect(canDeleteRole('SUPPORT' as StaffRole, 'SUPPORT' as StaffRole)).toBe(false)
})
})
describe('getAssignableRoles', () => {
it('should return ADMIN, MANAGER, SUPPORT for OWNER', () => {
const roles = getAssignableRoles('OWNER' as StaffRole)
expect(roles).toEqual(['ADMIN', 'MANAGER', 'SUPPORT'])
expect(roles).not.toContain('OWNER')
})
it('should return ADMIN, MANAGER, SUPPORT for ADMIN', () => {
const roles = getAssignableRoles('ADMIN' as StaffRole)
expect(roles).toEqual(['ADMIN', 'MANAGER', 'SUPPORT'])
})
it('should return empty array for MANAGER', () => {
expect(getAssignableRoles('MANAGER' as StaffRole)).toEqual([])
})
it('should return empty array for SUPPORT', () => {
expect(getAssignableRoles('SUPPORT' as StaffRole)).toEqual([])
})
})
})

View File

@@ -0,0 +1,296 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { prismaMock, resetPrismaMock } from '../../../mocks/prisma'
// Mock the email service
vi.mock('@/lib/services/email-service', () => ({
emailService: {
isConfigured: vi.fn().mockResolvedValue(false),
sendEmail: vi.fn().mockResolvedValue({ success: true }),
},
}))
import { securityVerificationService } from '@/lib/services/security-verification-service'
describe('SecurityVerificationService', () => {
beforeEach(() => {
resetPrismaMock()
vi.clearAllMocks()
// Suppress console.log from email fallback logging
vi.spyOn(console, 'log').mockImplementation(() => {})
})
describe('requestVerificationCode', () => {
const mockClient = {
id: 'client-123',
name: 'Test Client',
contactEmail: 'admin@testclient.com',
}
const mockServer = {
id: 'server-456',
nickname: 'Production Server',
netcupServerId: 'ns-789',
}
beforeEach(() => {
prismaMock.enterpriseClient.findUnique.mockResolvedValue(mockClient as any)
prismaMock.enterpriseServer.findFirst.mockResolvedValue(mockServer as any)
prismaMock.securityVerificationCode.updateMany.mockResolvedValue({ count: 0 } as any)
prismaMock.securityVerificationCode.create.mockResolvedValue({} as any)
})
it('should throw when client not found', async () => {
prismaMock.enterpriseClient.findUnique.mockResolvedValue(null)
await expect(
securityVerificationService.requestVerificationCode('bad-id', 'server-456', 'WIPE')
).rejects.toThrow('Client not found')
})
it('should throw when server not found', async () => {
prismaMock.enterpriseServer.findFirst.mockResolvedValue(null)
await expect(
securityVerificationService.requestVerificationCode('client-123', 'bad-id', 'WIPE')
).rejects.toThrow('Server not found or does not belong to this client')
})
it('should invalidate existing unused codes before creating new one', async () => {
const result = await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
expect(prismaMock.securityVerificationCode.updateMany).toHaveBeenCalledWith({
where: {
clientId: 'client-123',
targetServerId: 'server-456',
action: 'WIPE',
usedAt: null,
},
data: {
usedAt: expect.any(Date),
},
})
})
it('should create a new verification code', async () => {
await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'REINSTALL'
)
expect(prismaMock.securityVerificationCode.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clientId: 'client-123',
action: 'REINSTALL',
targetServerId: 'server-456',
code: expect.any(String),
expiresAt: expect.any(Date),
}),
})
})
it('should generate 8-digit code', async () => {
await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
const createCall = prismaMock.securityVerificationCode.create.mock.calls[0][0]
const code = createCall.data.code as string
expect(code).toHaveLength(8)
expect(code).toMatch(/^\d{8}$/)
})
it('should set expiry 15 minutes in the future', async () => {
const before = Date.now()
await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
const createCall = prismaMock.securityVerificationCode.create.mock.calls[0][0]
const expiresAt = createCall.data.expiresAt as Date
const after = Date.now()
const fifteenMinMs = 15 * 60 * 1000
// expiresAt should be approximately 15 minutes from now
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + fifteenMinMs - 1000)
expect(expiresAt.getTime()).toBeLessThanOrEqual(after + fifteenMinMs + 1000)
})
it('should return masked email and expiry', async () => {
const result = await securityVerificationService.requestVerificationCode(
'client-123',
'server-456',
'WIPE'
)
expect(result.expiresAt).toBeInstanceOf(Date)
// Email should be masked (a***n@testclient.com pattern)
expect(result.email).toContain('@testclient.com')
expect(result.email).toContain('*')
})
})
describe('verifyCode', () => {
it('should return invalid when no active code exists', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue(null)
const result = await securityVerificationService.verifyCode('client-123', '12345678')
expect(result.valid).toBe(false)
expect(result.errorMessage).toBe('Invalid or expired verification code')
})
it('should return invalid when max attempts exceeded', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 5,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '12345678')
expect(result.valid).toBe(false)
expect(result.errorMessage).toContain('Too many failed attempts')
// Should invalidate the code
expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({
where: { id: 'code-123' },
data: { usedAt: expect.any(Date) },
})
})
it('should increment attempt counter on wrong code', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 2,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '00000000')
expect(result.valid).toBe(false)
expect(result.errorMessage).toContain('attempt(s) remaining')
expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({
where: { id: 'code-123' },
data: { attempts: { increment: 1 } },
})
})
it('should show remaining attempts in error message', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 3,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '00000000')
// 5 max - 3 current - 1 this attempt = 1 remaining
expect(result.errorMessage).toContain('1 attempt(s) remaining')
})
it('should show too many attempts when at last attempt', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 4,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '00000000')
expect(result.errorMessage).toContain('Too many failed attempts')
})
it('should return valid with action and serverId on correct code', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 0,
clientId: 'client-123',
action: 'WIPE',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
const result = await securityVerificationService.verifyCode('client-123', '12345678')
expect(result.valid).toBe(true)
expect(result.action).toBe('WIPE')
expect(result.serverId).toBe('server-456')
})
it('should mark code as used after successful verification', async () => {
prismaMock.securityVerificationCode.findFirst.mockResolvedValue({
id: 'code-123',
code: '12345678',
attempts: 0,
clientId: 'client-123',
action: 'REINSTALL',
targetServerId: 'server-456',
} as any)
prismaMock.securityVerificationCode.update.mockResolvedValue({} as any)
await securityVerificationService.verifyCode('client-123', '12345678')
expect(prismaMock.securityVerificationCode.update).toHaveBeenCalledWith({
where: { id: 'code-123' },
data: { usedAt: expect.any(Date) },
})
})
})
describe('cleanupExpiredCodes', () => {
it('should delete expired and used codes older than 24h', async () => {
prismaMock.securityVerificationCode.deleteMany.mockResolvedValue({ count: 5 } as any)
const result = await securityVerificationService.cleanupExpiredCodes()
expect(result).toBe(5)
expect(prismaMock.securityVerificationCode.deleteMany).toHaveBeenCalledWith({
where: {
OR: [
{ expiresAt: { lt: expect.any(Date) } },
{ usedAt: { not: null } },
],
createdAt: { lt: expect.any(Date) },
},
})
})
it('should return 0 when no codes to clean up', async () => {
prismaMock.securityVerificationCode.deleteMany.mockResolvedValue({ count: 0 } as any)
const result = await securityVerificationService.cleanupExpiredCodes()
expect(result).toBe(0)
})
})
})

View File

@@ -0,0 +1,265 @@
'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
interface InviteInfo {
valid: boolean
email: string
role: string
expiresAt: string
invitedBy: string
}
export default function AcceptInvitePage({
params,
}: {
params: Promise<{ token: string }>
}) {
const { token } = use(params)
const router = useRouter()
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
useEffect(() => {
async function validateToken() {
try {
const res = await fetch(`/api/v1/auth/invite/${token}`)
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Invalid invitation')
return
}
setInviteInfo(data)
} catch {
setError('Failed to validate invitation')
} finally {
setLoading(false)
}
}
validateToken()
}, [token])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitError(null)
if (password !== confirmPassword) {
setSubmitError('Passwords do not match')
return
}
if (password.length < 8) {
setSubmitError('Password must be at least 8 characters')
return
}
setSubmitting(true)
try {
const res = await fetch('/api/v1/auth/accept-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, name, password }),
})
const data = await res.json()
if (!res.ok) {
setSubmitError(data.error || 'Failed to create account')
return
}
setSuccess(true)
setTimeout(() => {
router.push('/login')
}, 3000)
} catch {
setSubmitError('Failed to create account')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<AlertCircle className="h-6 w-6 text-red-600" />
</div>
<CardTitle>Invalid Invitation</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardFooter className="justify-center">
<Button asChild>
<Link href="/login">Go to Login</Link>
</Button>
</CardFooter>
</Card>
</div>
)
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle>Account Created!</CardTitle>
<CardDescription>
Your account has been created successfully. Redirecting to login...
</CardDescription>
</CardHeader>
<CardFooter className="justify-center">
<Button asChild>
<Link href="/login">Go to Login</Link>
</Button>
</CardFooter>
</Card>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
Join LetsBe Hub
</CardTitle>
<CardDescription className="text-center">
Complete your registration to get started
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="rounded-lg bg-gray-50 p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Email</span>
<span className="text-sm font-medium">{inviteInfo?.email}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Role</span>
<Badge variant="secondary">{inviteInfo?.role}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Invited by</span>
<span className="text-sm font-medium">{inviteInfo?.invitedBy}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Your Name</Label>
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a strong password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
{submitError && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
{submitError}
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Account...
</>
) : (
'Create Account'
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') || '/'
const error = searchParams.get('error')
const setupSuccess = searchParams.get('setup') === 'success'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [userType, setUserType] = useState<'customer' | 'staff'>('staff')
const [isLoading, setIsLoading] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
// 2FA state
const [show2FA, setShow2FA] = useState(false)
const [pendingToken, setPendingToken] = useState<string | null>(null)
const [twoFactorCode, setTwoFactorCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setLoginError(null)
try {
const result = await signIn('credentials', {
email,
password,
userType,
redirect: false,
callbackUrl,
})
if (result?.error) {
// Check if 2FA is required
if (result.error.startsWith('2FA_REQUIRED:')) {
const token = result.error.replace('2FA_REQUIRED:', '')
setPendingToken(token)
setShow2FA(true)
setLoginError(null)
} else {
setLoginError(result.error)
}
} else if (result?.ok) {
router.push(userType === 'staff' ? '/admin' : '/')
router.refresh()
}
} catch {
setLoginError('An unexpected error occurred')
} finally {
setIsLoading(false)
}
}
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setLoginError(null)
try {
const result = await signIn('credentials', {
pendingToken,
twoFactorToken: twoFactorCode.replace(/[\s-]/g, ''), // Remove spaces and dashes
redirect: false,
callbackUrl,
})
if (result?.error) {
setLoginError(result.error)
} else if (result?.ok) {
router.push(userType === 'staff' ? '/admin' : '/')
router.refresh()
}
} catch {
setLoginError('An unexpected error occurred')
} finally {
setIsLoading(false)
}
}
const handleBack = () => {
setShow2FA(false)
setPendingToken(null)
setTwoFactorCode('')
setUseBackupCode(false)
setLoginError(null)
}
// 2FA verification form
if (show2FA) {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
Two-Factor Authentication
</CardTitle>
<CardDescription className="text-center">
{useBackupCode
? 'Enter one of your backup codes'
: 'Enter the code from your authenticator app'}
</CardDescription>
</CardHeader>
<form onSubmit={handle2FASubmit}>
<CardContent className="space-y-4">
{loginError && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
{loginError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="twoFactorCode">
{useBackupCode ? 'Backup Code' : 'Authentication Code'}
</Label>
<Input
id="twoFactorCode"
type="text"
inputMode="numeric"
pattern={useBackupCode ? '[A-Za-z0-9\\s-]*' : '[0-9]*'}
placeholder={useBackupCode ? 'XXXX-XXXX' : '123456'}
value={twoFactorCode}
onChange={(e) => setTwoFactorCode(e.target.value)}
required
autoComplete="one-time-code"
autoFocus
/>
</div>
<Button
type="button"
variant="link"
className="px-0 text-sm"
onClick={() => {
setUseBackupCode(!useBackupCode)
setTwoFactorCode('')
}}
>
{useBackupCode
? 'Use authenticator app instead'
: 'Use a backup code'}
</Button>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Verifying...' : 'Verify'}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleBack}
>
Back to login
</Button>
</CardFooter>
</form>
</Card>
)
}
// Regular login form
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
LetsBe Hub
</CardTitle>
<CardDescription className="text-center">
Sign in to your account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{setupSuccess && (
<div className="p-3 text-sm text-green-700 bg-green-50 rounded-md">
Account created successfully! Please sign in.
</div>
)}
{(error || loginError) && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
{error === 'CredentialsSignin'
? 'Invalid email or password'
: loginError || error}
</div>
)}
<div className="flex gap-2">
<Button
type="button"
variant={userType === 'staff' ? 'default' : 'outline'}
className="flex-1"
onClick={() => setUserType('staff')}
>
Staff Login
</Button>
<Button
type="button"
variant={userType === 'customer' ? 'default' : 'outline'}
className="flex-1"
onClick={() => setUserType('customer')}
>
Customer Login
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</CardFooter>
</form>
</Card>
)
}
export function LoginFormSkeleton() {
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
LetsBe Hub
</CardTitle>
<CardDescription className="text-center">
Loading...
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="h-10 bg-gray-200 rounded animate-pulse" />
<div className="h-10 bg-gray-200 rounded animate-pulse" />
<div className="h-10 bg-gray-200 rounded animate-pulse" />
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,23 @@
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import { isSetupRequired } from '@/lib/setup'
import { LoginForm, LoginFormSkeleton } from './login-form'
// Prevent static generation - requires database check at runtime
export const dynamic = 'force-dynamic'
export default async function LoginPage() {
// Check if initial setup is required
const setupRequired = await isSetupRequired()
if (setupRequired) {
redirect('/setup')
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Suspense fallback={<LoginFormSkeleton />}>
<LoginForm />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,215 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Shield, Loader2 } from 'lucide-react'
export default function SetupPage() {
const router = useRouter()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isCheckingSetup, setIsCheckingSetup] = useState(true)
const [error, setError] = useState<string | null>(null)
// Check if setup is still required on mount
useEffect(() => {
async function checkSetup() {
try {
const response = await fetch('/api/v1/setup')
const data = await response.json()
if (!data.setupRequired) {
// Setup already complete, redirect to login
router.replace('/login')
}
} catch (err) {
console.error('Failed to check setup status:', err)
} finally {
setIsCheckingSetup(false)
}
}
checkSetup()
}, [router])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
// Validate passwords match
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
// Validate password length
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setIsLoading(true)
try {
const response = await fetch('/api/v1/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, email, password }),
})
const data = await response.json()
if (!response.ok) {
if (data.details?.fieldErrors) {
// Get first field error
const fieldErrors = data.details.fieldErrors
const firstError = Object.values(fieldErrors).flat()[0]
setError(firstError as string)
} else {
setError(data.error || 'Failed to create account')
}
return
}
// Success - redirect to login
router.push('/login?setup=success')
} catch (err) {
console.error('Setup error:', err)
setError('An unexpected error occurred')
} finally {
setIsLoading(false)
}
}
// Show loading while checking setup status
if (isCheckingSetup) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
LetsBe Hub
</CardTitle>
<CardDescription className="text-center">
Loading...
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex justify-center mb-2">
<Shield className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl font-bold text-center">
Welcome to LetsBe Hub
</CardTitle>
<CardDescription className="text-center">
Create your administrator account to get started
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Enter your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoComplete="name"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a password (min 8 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Account...
</>
) : (
'Create Account'
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,328 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useAnalytics, TimeRange } from '@/hooks/use-analytics'
import { StatCard } from '@/components/analytics/stat-card'
import { LineChart } from '@/components/analytics/line-chart'
import { BarChart } from '@/components/analytics/bar-chart'
import { DonutChart } from '@/components/analytics/donut-chart'
import { AnalyticsSection } from '@/components/analytics/analytics-section'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
ShoppingCart,
Users,
CreditCard,
CheckCircle,
RefreshCw,
AlertCircle,
TrendingUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '7d', label: '7 Days' },
{ value: '30d', label: '30 Days' },
{ value: '90d', label: '90 Days' },
]
// Status colors
const STATUS_COLORS: Record<string, string> = {
PENDING_PAYMENT: 'hsl(45, 90%, 50%)',
AWAITING_DNS: 'hsl(30, 80%, 55%)',
DNS_VERIFIED: 'hsl(190, 70%, 45%)',
PROVISIONING: 'hsl(220, 70%, 50%)',
FULFILLED: 'hsl(160, 60%, 45%)',
EMAIL_CONFIGURED: 'hsl(140, 70%, 40%)',
FAILED: 'hsl(350, 70%, 50%)',
CANCELLED: 'hsl(0, 0%, 50%)',
}
// Plan colors
const PLAN_COLORS: Record<string, string> = {
TRIAL: 'hsl(45, 90%, 50%)',
STARTER: 'hsl(220, 70%, 50%)',
PRO: 'hsl(160, 60%, 45%)',
ENTERPRISE: 'hsl(280, 60%, 50%)',
}
export default function AnalyticsPage() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d')
const { data, isLoading, error, refetch, isFetching } = useAnalytics(timeRange)
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<AlertCircle className="h-12 w-12 text-destructive" />
<p className="text-lg text-muted-foreground">Failed to load analytics</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">
Platform performance and insights
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border p-1">
{TIME_RANGES.map((range) => (
<Button
key={range.value}
variant={timeRange === range.value ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setTimeRange(range.value)}
className="px-3"
>
{range.label}
</Button>
))}
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
</Button>
</div>
</div>
{/* Overview Stats */}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-[120px]" />
))}
</div>
) : data ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Orders"
value={data.overview.totalOrders.toLocaleString()}
trend={data.overview.ordersTrend}
icon={<ShoppingCart className="h-5 w-5" />}
/>
<StatCard
title="Active Customers"
value={data.overview.activeCustomers.toLocaleString()}
trend={data.overview.customersTrend}
icon={<Users className="h-5 w-5" />}
/>
<StatCard
title="Active Subscriptions"
value={data.overview.activeSubscriptions.toLocaleString()}
icon={<CreditCard className="h-5 w-5" />}
/>
<StatCard
title="Success Rate"
value={`${data.overview.successRate}%`}
icon={<CheckCircle className="h-5 w-5" />}
/>
</div>
) : null}
{/* Order Analytics */}
<AnalyticsSection
title="Order Analytics"
description="Order volume and status distribution"
>
{isLoading ? (
<>
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</>
) : data ? (
<>
<LineChart
title="Orders Over Time"
data={data.orders.byDay.map((d) => ({
date: d.date,
orders: d.count,
}))}
lines={[{ dataKey: 'orders', color: 'hsl(var(--primary))' }]}
/>
<DonutChart
title="Orders by Status"
data={Object.entries(data.orders.byStatus)
.filter(([, count]) => count > 0)
.map(([status, count]) => ({
name: formatStatus(status),
value: count,
color: STATUS_COLORS[status],
}))}
centerValue={data.overview.totalOrders}
centerLabel="Total"
/>
</>
) : null}
</AnalyticsSection>
{/* Customer Insights */}
<AnalyticsSection
title="Customer Insights"
description="Customer growth and subscription distribution"
>
{isLoading ? (
<>
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</>
) : data ? (
<>
<LineChart
title="Customer Growth"
data={data.customers.growthByDay.map((d) => ({
date: d.date,
customers: d.count,
}))}
lines={[{ dataKey: 'customers', color: 'hsl(160, 60%, 45%)' }]}
/>
<BarChart
title="Subscriptions by Plan"
data={Object.entries(data.customers.byPlan)
.filter(([, count]) => count > 0)
.map(([plan, count]) => ({
name: formatPlan(plan),
value: count,
color: PLAN_COLORS[plan],
}))}
/>
</>
) : null}
</AnalyticsSection>
{/* Token Usage */}
<AnalyticsSection
title="Token Usage"
description="AI token consumption and top consumers"
>
{isLoading ? (
<>
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</>
) : data ? (
<>
<LineChart
title="Token Usage Over Time"
data={data.tokens.usageByDay.map((d) => ({
date: d.date,
tokens: d.tokens,
}))}
lines={[{ dataKey: 'tokens', color: 'hsl(280, 60%, 50%)' }]}
/>
<BarChart
title="Top Token Consumers"
data={data.tokens.topConsumers.slice(0, 5).map((c) => ({
name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name,
value: c.tokens,
}))}
horizontal
/>
</>
) : null}
</AnalyticsSection>
{/* Provisioning Performance */}
<AnalyticsSection
title="Provisioning Performance"
description="Automation mode and recent failures"
>
{isLoading ? (
<>
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</>
) : data ? (
<>
<DonutChart
title="Orders by Automation Mode"
data={Object.entries(data.provisioning.byAutomation)
.filter(([, count]) => count > 0)
.map(([mode, count]) => ({
name: formatMode(mode),
value: count,
}))}
centerValue={`${data.provisioning.successRate}%`}
centerLabel="Success Rate"
/>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive" />
Recent Failures
</CardTitle>
</CardHeader>
<CardContent>
{data.provisioning.recentFailures.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<CheckCircle className="h-8 w-8 mb-2 text-green-500" />
<p>No recent failures</p>
</div>
) : (
<div className="space-y-3">
{data.provisioning.recentFailures.slice(0, 5).map((failure) => (
<div
key={failure.orderId}
className="flex items-start justify-between p-3 rounded-lg bg-muted/50"
>
<div className="space-y-1">
<p className="font-medium text-sm">{failure.domain}</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{failure.reason}
</p>
</div>
<p className="text-xs text-muted-foreground whitespace-nowrap ml-4">
{formatDate(failure.date)}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
) : null}
</AnalyticsSection>
</div>
)
}
function formatStatus(status: string): string {
return status
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase())
}
function formatPlan(plan: string): string {
return plan.charAt(0) + plan.slice(1).toLowerCase()
}
function formatMode(mode: string): string {
return mode.charAt(0) + mode.slice(1).toLowerCase()
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffHours < 1) return 'Just now'
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}

View File

@@ -0,0 +1,825 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useCustomer, useDeleteCustomer } from '@/hooks/use-customers'
import { useQueryClient } from '@tanstack/react-query'
import { customerKeys } from '@/hooks/use-customers'
import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog'
import {
ArrowLeft,
User,
Mail,
Building2,
Calendar,
Server,
Loader2,
AlertCircle,
RefreshCw,
Edit,
Ban,
CheckCircle,
ExternalLink,
CreditCard,
Activity,
Package,
X,
Save,
ShoppingCart,
Sparkles,
TrendingUp,
Trash2,
Plus,
} from 'lucide-react'
import { CreateOrderDialog } from '@/components/admin/create-order-dialog'
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
type OrderStatus = 'PAYMENT_CONFIRMED' | 'AWAITING_SERVER' | 'SERVER_READY' | 'DNS_PENDING' | 'DNS_READY' | 'PROVISIONING' | 'FULFILLED' | 'EMAIL_CONFIGURED' | 'FAILED'
// Status badge components with enhanced styling
function UserStatusBadge({ status }: { status: UserStatus }) {
const statusConfig: Record<UserStatus, { label: string; bgColor: string; textColor: string; borderColor: string; dotColor: string }> = {
ACTIVE: {
label: 'Active',
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800',
dotColor: 'bg-emerald-500'
},
SUSPENDED: {
label: 'Suspended',
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800',
dotColor: 'bg-red-500'
},
PENDING_VERIFICATION: {
label: 'Pending',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
textColor: 'text-amber-700 dark:text-amber-400',
borderColor: 'border-amber-200 dark:border-amber-800',
dotColor: 'bg-amber-500'
},
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
<span className={`h-2 w-2 rounded-full ${config.dotColor} ${status === 'ACTIVE' ? 'animate-pulse' : ''}`} />
{config.label}
</span>
)
}
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
const statusConfig: Record<SubscriptionStatus, { label: string; bgColor: string; textColor: string; borderColor: string }> = {
TRIAL: {
label: 'Trial',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
textColor: 'text-blue-700 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-800'
},
ACTIVE: {
label: 'Active',
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800'
},
CANCELED: {
label: 'Canceled',
bgColor: 'bg-slate-50 dark:bg-slate-900/20',
textColor: 'text-slate-600 dark:text-slate-400',
borderColor: 'border-slate-200 dark:border-slate-700'
},
PAST_DUE: {
label: 'Past Due',
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800'
},
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
{config.label}
</span>
)
}
function OrderStatusBadge({ status }: { status: OrderStatus }) {
const statusConfig: Record<OrderStatus, { label: string; bgColor: string; textColor: string; borderColor: string }> = {
PAYMENT_CONFIRMED: {
label: 'Payment Confirmed',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
textColor: 'text-blue-700 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-800'
},
AWAITING_SERVER: {
label: 'Awaiting Server',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
textColor: 'text-amber-700 dark:text-amber-400',
borderColor: 'border-amber-200 dark:border-amber-800'
},
SERVER_READY: {
label: 'Server Ready',
bgColor: 'bg-cyan-50 dark:bg-cyan-900/20',
textColor: 'text-cyan-700 dark:text-cyan-400',
borderColor: 'border-cyan-200 dark:border-cyan-800'
},
DNS_PENDING: {
label: 'DNS Pending',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
textColor: 'text-orange-700 dark:text-orange-400',
borderColor: 'border-orange-200 dark:border-orange-800'
},
DNS_READY: {
label: 'DNS Ready',
bgColor: 'bg-teal-50 dark:bg-teal-900/20',
textColor: 'text-teal-700 dark:text-teal-400',
borderColor: 'border-teal-200 dark:border-teal-800'
},
PROVISIONING: {
label: 'Provisioning',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
textColor: 'text-purple-700 dark:text-purple-400',
borderColor: 'border-purple-200 dark:border-purple-800'
},
FULFILLED: {
label: 'Fulfilled',
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800'
},
EMAIL_CONFIGURED: {
label: 'Email Configured',
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800'
},
FAILED: {
label: 'Failed',
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800'
},
}
const config = statusConfig[status] || {
label: status,
bgColor: 'bg-slate-50 dark:bg-slate-900/20',
textColor: 'text-slate-600 dark:text-slate-400',
borderColor: 'border-slate-200 dark:border-slate-700'
}
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
{config.label}
</span>
)
}
// Token usage progress bar with threshold colors
function TokenUsageBar({ used, limit }: { used: number; limit: number }) {
const percentage = Math.min((used / limit) * 100, 100)
const getBarColor = () => {
if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600'
if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500'
if (percentage > 50) return 'bg-gradient-to-r from-yellow-400 to-amber-500'
return 'bg-gradient-to-r from-emerald-500 to-emerald-600'
}
const getTextColor = () => {
if (percentage > 90) return 'text-red-600 dark:text-red-400'
if (percentage > 75) return 'text-amber-600 dark:text-amber-400'
return 'text-emerald-600 dark:text-emerald-400'
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Token Usage</span>
<span className={`text-sm font-semibold tabular-nums ${getTextColor()}`}>
{used.toLocaleString()} / {limit.toLocaleString()}
</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-muted/60 ring-1 ring-inset ring-black/5">
<div
className={`h-full ${getBarColor()} transition-all duration-500 ease-out rounded-full`}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span className={getTextColor()}>{percentage.toFixed(1)}% used</span>
<span>{(100 - percentage).toFixed(1)}% remaining</span>
</div>
</div>
)
}
export default function CustomerDetailPage() {
const params = useParams()
const router = useRouter()
const queryClient = useQueryClient()
const customerId = params.id as string
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState({ name: '', company: '' })
const [isUpdating, setIsUpdating] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showCreateOrderDialog, setShowCreateOrderDialog] = useState(false)
const { data: customer, isLoading, isError, error, refetch, isFetching } = useCustomer(customerId)
const deleteCustomer = useDeleteCustomer()
const handleEdit = () => {
if (customer) {
setEditForm({
name: customer.name || '',
company: customer.company || '',
})
setIsEditing(true)
}
}
const handleSave = async () => {
setIsUpdating(true)
try {
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
if (!response.ok) {
throw new Error('Failed to update customer')
}
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
setIsEditing(false)
} catch (err) {
console.error('Error updating customer:', err)
} finally {
setIsUpdating(false)
}
}
const handleStatusChange = async (newStatus: UserStatus) => {
setIsUpdating(true)
try {
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
} catch (err) {
console.error('Error updating status:', err)
} finally {
setIsUpdating(false)
}
}
const handleDeleteCustomer = async () => {
await deleteCustomer.mutateAsync(customerId)
router.push('/admin/customers')
}
// Loading state with enhanced styling
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm text-muted-foreground">Loading customer details...</p>
</div>
)
}
// Error state with enhanced styling
if (isError) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" onClick={() => router.back()} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Button>
<div className="rounded-xl border-2 border-dashed border-destructive/20 bg-destructive/5 py-16 text-center">
<div className="mx-auto w-fit p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-10 w-10 text-destructive/60" />
</div>
<h3 className="font-semibold text-lg text-destructive">Failed to load customer</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
<div className="flex justify-center gap-3 mt-6">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
</div>
)
}
if (!customer) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" onClick={() => router.back()} className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Button>
<div className="rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/20 py-16 text-center">
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
<User className="h-10 w-10 text-muted-foreground/60" />
</div>
<h3 className="font-semibold text-lg">Customer not found</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
The customer you are looking for does not exist or you do not have access.
</p>
<Link href="/admin/customers">
<Button variant="outline" className="mt-6">
View All Customers
</Button>
</Link>
</div>
</div>
)
}
const currentSubscription = customer.subscriptions?.[0]
const totalTokensUsed = customer.totalTokensUsed || 0
const tokenUsagePercent = currentSubscription
? Math.min((totalTokensUsed / currentSubscription.tokenLimit) * 100, 100)
: 0
return (
<div className="space-y-8">
{/* Hero Header Section */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decoration */}
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
<div className="relative">
{/* Back link */}
<Link href="/admin/customers" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4">
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Link>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
{/* Customer identity */}
<div className="flex items-center gap-4">
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20">
<User className="h-8 w-8 text-primary" />
</div>
<div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">{customer.name || customer.email}</h1>
<UserStatusBadge status={customer.status as UserStatus} />
</div>
<p className="text-muted-foreground mt-1">{customer.email}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setShowCreateOrderDialog(true)}
className="gap-2 bg-primary shadow-lg shadow-primary/20"
>
<Plus className="h-4 w-4" />
Add Order
</Button>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="shrink-0 gap-2"
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Refresh
</Button>
{customer.status === 'ACTIVE' ? (
<Button
variant="destructive"
size="sm"
onClick={() => handleStatusChange('SUSPENDED')}
disabled={isUpdating}
className="gap-2 shadow-lg shadow-red-500/20"
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Ban className="h-4 w-4" />}
Suspend
</Button>
) : (
<Button
size="sm"
onClick={() => handleStatusChange('ACTIVE')}
disabled={isUpdating}
className="gap-2 bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/20"
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
Activate
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteDialog(true)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
</div>
{/* Delete confirmation dialog */}
<SliderConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title="Delete Customer"
description={`This will permanently delete customer "${customer.name || customer.email}" and ALL related records including orders, subscriptions, and token usage. This action cannot be undone. Actual servers will NOT be affected.`}
confirmText="Delete Customer"
variant="destructive"
onConfirm={handleDeleteCustomer}
isLoading={deleteCustomer.isPending}
/>
{/* Create Order dialog */}
<CreateOrderDialog
open={showCreateOrderDialog}
onOpenChange={setShowCreateOrderDialog}
preselectedCustomer={{
id: customer.id,
name: customer.name,
email: customer.email,
company: customer.company,
}}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
}}
/>
{/* Stats Row with colored icon backgrounds */}
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-xl border bg-gradient-to-br from-card to-blue-50/30 dark:to-blue-950/10 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-blue-100 dark:bg-blue-900/30">
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="text-2xl font-bold tabular-nums">{customer._count?.orders || 0}</div>
<p className="text-sm text-muted-foreground">Total Orders</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-gradient-to-br from-card to-emerald-50/30 dark:to-emerald-950/10 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-emerald-100 dark:bg-emerald-900/30">
<Server className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<div className="text-2xl font-bold tabular-nums">
{customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0}
</div>
<p className="text-sm text-muted-foreground">Active Servers</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-gradient-to-br from-card to-violet-50/30 dark:to-violet-950/10 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-violet-100 dark:bg-violet-900/30">
<Activity className="h-5 w-5 text-violet-600 dark:text-violet-400" />
</div>
<div>
<div className="text-2xl font-bold tabular-nums">{totalTokensUsed.toLocaleString()}</div>
<p className="text-sm text-muted-foreground">Tokens Used</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-gradient-to-br from-card to-amber-50/30 dark:to-amber-950/10 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-amber-100 dark:bg-amber-900/30">
<CreditCard className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<div className="text-2xl font-bold capitalize">
{currentSubscription?.plan.toLowerCase() || 'None'}
</div>
<p className="text-sm text-muted-foreground">Current Plan</p>
</div>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Customer Profile Card */}
<div className="lg:col-span-1">
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<User className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<h2 className="font-semibold">Profile</h2>
<p className="text-sm text-muted-foreground">Customer information</p>
</div>
</div>
{!isEditing && (
<Button variant="ghost" size="sm" onClick={handleEdit} className="gap-2">
<Edit className="h-4 w-4" />
Edit
</Button>
)}
</div>
<div className="rounded-xl border bg-card p-5">
{isEditing ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="company">Company</Label>
<Input
id="company"
value={editForm.company}
onChange={(e) => setEditForm({ ...editForm, company: e.target.value })}
className="bg-background"
/>
</div>
<div className="flex gap-2 pt-2">
<Button size="sm" onClick={handleSave} disabled={isUpdating} className="gap-2">
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)} className="gap-2">
<X className="h-4 w-4" />
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{customer.name || 'No name'}</p>
<p className="text-xs text-muted-foreground">Name</p>
</div>
</div>
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900/30">
<Mail className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{customer.email}</p>
<p className="text-xs text-muted-foreground">Email</p>
</div>
</div>
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30">
<Building2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{customer.company || 'Not set'}</p>
<p className="text-xs text-muted-foreground">Company</p>
</div>
</div>
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100 dark:bg-emerald-900/30">
<Calendar className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">
{new Date(customer.createdAt).toLocaleDateString()}
</p>
<p className="text-xs text-muted-foreground">Member Since</p>
</div>
</div>
</div>
)}
</div>
</section>
</div>
{/* Subscription Card */}
<div className="lg:col-span-2">
<section className="space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<h2 className="font-semibold">Subscription</h2>
<p className="text-sm text-muted-foreground">Current plan and token usage</p>
</div>
</div>
<div className="rounded-xl border bg-gradient-to-br from-card via-card to-primary/5 p-6">
{currentSubscription ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
<TrendingUp className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-xl font-bold capitalize">
{currentSubscription.plan.toLowerCase()} Plan
</p>
<p className="text-sm text-muted-foreground capitalize">
{currentSubscription.tier.replace('_', ' ').toLowerCase()} tier
</p>
</div>
</div>
<SubscriptionBadge status={currentSubscription.status as SubscriptionStatus} />
</div>
{currentSubscription.trialEndsAt && (
<div className="rounded-xl bg-gradient-to-r from-blue-50 to-blue-100/50 dark:from-blue-900/20 dark:to-blue-800/10 p-4 border border-blue-200/50 dark:border-blue-800/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/50">
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">Trial Period Active</p>
<p className="text-xs text-blue-600/80 dark:text-blue-400/80">
Ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
)}
<div className="rounded-xl bg-muted/30 p-4">
<TokenUsageBar used={totalTokensUsed} limit={currentSubscription.tokenLimit} />
</div>
</div>
) : (
<div className="text-center py-12">
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
<CreditCard className="h-10 w-10 text-muted-foreground/50" />
</div>
<h3 className="font-semibold text-lg">No Active Subscription</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
This customer does not have an active subscription plan
</p>
</div>
)}
</div>
</section>
</div>
</div>
{/* Orders History */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<h2 className="font-semibold">Orders History</h2>
<p className="text-sm text-muted-foreground">
{customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''} total
</p>
</div>
</div>
<Link href={`/admin/orders?customer=${customerId}`}>
<Button variant="outline" size="sm" className="gap-2">
View All
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</div>
<div className="rounded-xl border bg-card overflow-hidden">
{customer.orders && customer.orders.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/30">
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Domain</th>
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Tier</th>
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Status</th>
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Server IP</th>
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Created</th>
<th className="px-5 py-4 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{customer.orders.map((order: {
id: string
domain: string
tier: string
status: OrderStatus
serverIp: string | null
createdAt: Date | string
}) => (
<tr
key={order.id}
className="group hover:bg-muted/30 transition-colors"
>
<td className="px-5 py-4">
<span className="font-medium">{order.domain}</span>
</td>
<td className="px-5 py-4">
<span className="capitalize text-sm text-muted-foreground">
{order.tier.replace('_', ' ').toLowerCase()}
</span>
</td>
<td className="px-5 py-4">
<OrderStatusBadge status={order.status} />
</td>
<td className="px-5 py-4">
<code className="font-mono text-sm text-muted-foreground">
{order.serverIp || '-'}
</code>
</td>
<td className="px-5 py-4">
<span className="text-sm text-muted-foreground">
{new Date(order.createdAt).toLocaleDateString()}
</span>
</td>
<td className="px-5 py-4">
<Link href={`/admin/orders/${order.id}`}>
<Button
variant="ghost"
size="sm"
className="gap-2 opacity-70 group-hover:opacity-100 transition-opacity"
>
<ExternalLink className="h-4 w-4" />
View
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-16">
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
<ShoppingCart className="h-10 w-10 text-muted-foreground/50" />
</div>
<h3 className="font-semibold text-lg">No Orders Yet</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
This customer has not placed any orders
</p>
</div>
)}
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,696 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCustomers } from '@/hooks/use-customers'
import { AddCustomerDialog } from '@/components/admin/AddCustomerDialog'
import { UserStatus as ApiUserStatus } from '@/types/api'
import {
Search,
Plus,
MoreHorizontal,
User,
Mail,
Building2,
Calendar,
Server,
ExternalLink,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
RefreshCw,
Users,
UserCheck,
Clock,
Zap,
UserX,
} from 'lucide-react'
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
interface Customer {
id: string
name: string
email: string
company: string | null
status: UserStatus
subscription: {
plan: string
tier: string
status: SubscriptionStatus
tokensUsed: number
tokenLimit: number
} | null
activeServers: number
createdAt: string
}
// Status badge component with dot indicator and animations
function UserStatusBadge({ status }: { status: UserStatus }) {
const statusConfig: Record<UserStatus, {
label: string
bgColor: string
textColor: string
borderColor: string
dotColor: string
animate: boolean
}> = {
ACTIVE: {
label: 'Active',
bgColor: 'bg-emerald-50 dark:bg-emerald-950/30',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800',
dotColor: 'bg-emerald-500',
animate: true
},
SUSPENDED: {
label: 'Suspended',
bgColor: 'bg-red-50 dark:bg-red-950/30',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800',
dotColor: 'bg-red-500',
animate: false
},
PENDING_VERIFICATION: {
label: 'Pending',
bgColor: 'bg-amber-50 dark:bg-amber-950/30',
textColor: 'text-amber-700 dark:text-amber-400',
borderColor: 'border-amber-200 dark:border-amber-800',
dotColor: 'bg-amber-500',
animate: true
},
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border ${config.bgColor} ${config.textColor} ${config.borderColor}`}>
<span className={`h-2 w-2 rounded-full ${config.dotColor} ${config.animate ? 'animate-pulse' : ''}`} />
{config.label}
</span>
)
}
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
const statusConfig: Record<SubscriptionStatus, {
label: string
bgColor: string
textColor: string
borderColor: string
dotColor: string
}> = {
TRIAL: {
label: 'Trial',
bgColor: 'bg-blue-50 dark:bg-blue-950/30',
textColor: 'text-blue-700 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-800',
dotColor: 'bg-blue-500'
},
ACTIVE: {
label: 'Active',
bgColor: 'bg-emerald-50 dark:bg-emerald-950/30',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800',
dotColor: 'bg-emerald-500'
},
CANCELED: {
label: 'Canceled',
bgColor: 'bg-slate-50 dark:bg-slate-950/30',
textColor: 'text-slate-600 dark:text-slate-400',
borderColor: 'border-slate-200 dark:border-slate-700',
dotColor: 'bg-slate-400'
},
PAST_DUE: {
label: 'Past Due',
bgColor: 'bg-red-50 dark:bg-red-950/30',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800',
dotColor: 'bg-red-500'
},
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border ${config.bgColor} ${config.textColor} ${config.borderColor}`}>
<span className={`h-1.5 w-1.5 rounded-full ${config.dotColor}`} />
{config.label}
</span>
)
}
// Token usage progress bar with gradient colors based on threshold
function TokenUsageBar({ used, limit }: { used: number; limit: number }) {
const percentage = limit > 0 ? Math.min((used / limit) * 100, 100) : 0
// Determine gradient based on usage threshold
const getGradientClass = () => {
if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600'
if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500'
if (percentage > 50) return 'bg-gradient-to-r from-blue-500 to-blue-600'
return 'bg-gradient-to-r from-emerald-500 to-emerald-600'
}
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{used.toLocaleString()}</span>
<span className="font-medium tabular-nums">{percentage.toFixed(0)}%</span>
</div>
<div className="h-2 bg-muted/60 rounded-full overflow-hidden ring-1 ring-inset ring-black/5">
<div
className={`h-full ${getGradientClass()} transition-all duration-500 ease-out rounded-full`}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="text-xs text-muted-foreground text-right">
of {limit.toLocaleString()}
</div>
</div>
)
}
// Customer card-style row component
function CustomerRow({ customer }: { customer: Customer }) {
return (
<div className="group relative flex items-center justify-between p-4 rounded-xl border bg-card hover:bg-muted/30 hover:border-muted-foreground/20 hover:shadow-md transition-all">
{/* Customer Info */}
<div className="flex items-center gap-4 min-w-0 flex-1">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/10 to-primary/20 group-hover:from-primary/20 group-hover:to-primary/30 transition-colors">
<User className="h-6 w-6 text-primary" />
</div>
<div className="min-w-0">
<Link
href={`/admin/customers/${customer.id}`}
className="font-semibold hover:text-primary transition-colors block truncate"
>
{customer.name}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-0.5">
<Mail className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">{customer.email}</span>
</div>
{customer.company && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<Building2 className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{customer.company}</span>
</div>
)}
</div>
</div>
{/* Status */}
<div className="hidden md:flex items-center justify-center px-4">
<UserStatusBadge status={customer.status} />
</div>
{/* Subscription */}
<div className="hidden lg:block px-4 min-w-[140px]">
{customer.subscription ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium capitalize text-sm">{customer.subscription.plan.toLowerCase()}</span>
<SubscriptionBadge status={customer.subscription.status} />
</div>
<span className="text-xs text-muted-foreground capitalize">
{customer.subscription.tier.replace('_', ' ').toLowerCase()}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">No subscription</span>
)}
</div>
{/* Token Usage */}
<div className="hidden xl:block px-4 w-36">
{customer.subscription && customer.subscription.tokenLimit > 0 ? (
<TokenUsageBar
used={customer.subscription.tokensUsed}
limit={customer.subscription.tokenLimit}
/>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</div>
{/* Servers & Date */}
<div className="hidden sm:flex items-center gap-6 px-4">
<div className="flex items-center gap-2 text-sm">
<div className="p-1.5 rounded-lg bg-muted">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<span className="font-medium tabular-nums">{customer.activeServers}</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Calendar className="h-3.5 w-3.5" />
{new Date(customer.createdAt).toLocaleDateString()}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 pl-4 opacity-70 group-hover:opacity-100 transition-opacity">
<Link href={`/admin/customers/${customer.id}`}>
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
)
}
// Stats card component with colored icon backgrounds
function StatsCard({
title,
value,
icon: Icon,
iconBg,
iconColor,
subtitle
}: {
title: string
value: number | string
icon: typeof Users
iconBg: string
iconColor: string
subtitle?: string
}) {
return (
<Card className="relative overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold tabular-nums">{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
<div className={`p-3 rounded-xl ${iconBg}`}>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
</div>
</CardContent>
{/* Decorative gradient */}
<div className={`absolute bottom-0 left-0 right-0 h-1 ${iconBg.replace('bg-', 'bg-gradient-to-r from-').replace('/10', '-500/50').replace('/30', '-600/50')} opacity-50`} />
</Card>
)
}
// Filter pill toggle component
function FilterPill({
label,
isActive,
onClick,
count
}: {
label: string
isActive: boolean
onClick: () => void
count?: number
}) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${
isActive
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{label}
{count !== undefined && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
isActive
? 'bg-primary-foreground/20'
: 'bg-muted-foreground/20'
}`}>
{count}
</span>
)}
</button>
)
}
// Empty state component
function EmptyState({
hasFilters,
onClearFilters
}: {
hasFilters: boolean
onClearFilters: () => void
}) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="relative">
<div className="absolute inset-0 bg-muted/30 rounded-full blur-2xl" />
<div className="relative p-6 rounded-full bg-muted/50">
<UserX className="h-12 w-12 text-muted-foreground/60" />
</div>
</div>
<h3 className="mt-6 font-semibold text-lg">No customers found</h3>
<p className="mt-2 text-sm text-muted-foreground text-center max-w-sm">
{hasFilters
? "We couldn't find any customers matching your current filters. Try adjusting your search criteria."
: "You haven't added any customers yet. Get started by adding your first customer."
}
</p>
{hasFilters && (
<Button
variant="outline"
onClick={onClearFilters}
className="mt-4"
>
Clear all filters
</Button>
)}
</div>
)
}
export default function CustomersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [currentPage, setCurrentPage] = useState(1)
const [showAddDialog, setShowAddDialog] = useState(false)
const itemsPerPage = 10
// Fetch customers from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useCustomers({
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter as ApiUserStatus : undefined,
page: currentPage,
limit: itemsPerPage,
})
// Map API customers to component format
const customers = useMemo<Customer[]>(() => {
if (!data?.customers) return []
return data.customers.map((c) => ({
id: c.id,
name: c.name || c.email,
email: c.email,
company: c.company,
status: c.status as UserStatus,
subscription: c.subscriptions?.[0] ? {
plan: c.subscriptions[0].plan,
tier: c.subscriptions[0].tier,
status: c.subscriptions[0].status as SubscriptionStatus,
tokensUsed: 0, // Not included in list response
tokenLimit: 0, // Not included in list response
} : null,
activeServers: c._count?.orders || 0,
createdAt: String(c.createdAt),
}))
}, [data?.customers])
// Calculate stats from API data
const stats = useMemo(() => ({
total: data?.pagination?.total || 0,
active: customers.filter((c) => c.status === 'ACTIVE').length,
trial: customers.filter((c) => c.subscription?.status === 'TRIAL').length,
totalServers: customers.reduce((acc, c) => acc + c.activeServers, 0),
}), [customers, data?.pagination?.total])
const totalPages = data?.pagination?.totalPages || 1
const hasFilters = search !== '' || statusFilter !== 'all'
const clearFilters = () => {
setSearch('')
setStatusFilter('all')
setCurrentPage(1)
}
// Loading state
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm text-muted-foreground">Loading customers...</p>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
<div className="relative">
<div className="absolute inset-0 bg-destructive/20 rounded-full blur-xl" />
<div className="relative p-4 rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
</div>
<div className="text-center">
<p className="font-medium text-destructive">Failed to load customers</p>
<p className="text-sm text-muted-foreground mt-1">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Retry'}
</Button>
</div>
)
}
return (
<div className="space-y-8">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decoration */}
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/20 border-2 border-primary/10">
<Users className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Customers</h1>
<p className="text-muted-foreground mt-1">
Manage customer accounts and subscriptions
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="gap-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddDialog(true)} className="gap-2 shadow-md">
<Plus className="h-4 w-4" />
Add Customer
</Button>
</div>
</div>
</div>
{/* Stats cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Customers"
value={stats.total}
icon={Users}
iconBg="bg-blue-100 dark:bg-blue-900/30"
iconColor="text-blue-600 dark:text-blue-400"
subtitle="All registered users"
/>
<StatsCard
title="Active"
value={stats.active}
icon={UserCheck}
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
iconColor="text-emerald-600 dark:text-emerald-400"
subtitle="Currently active"
/>
<StatsCard
title="On Trial"
value={stats.trial}
icon={Clock}
iconBg="bg-amber-100 dark:bg-amber-900/30"
iconColor="text-amber-600 dark:text-amber-400"
subtitle="Trial subscriptions"
/>
<StatsCard
title="Total Servers"
value={stats.totalServers}
icon={Server}
iconBg="bg-violet-100 dark:bg-violet-900/30"
iconColor="text-violet-600 dark:text-violet-400"
subtitle="Across all customers"
/>
</div>
{/* Filters and list */}
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-lg">All Customers</CardTitle>
<CardDescription>
{data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
</CardDescription>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search customers..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setCurrentPage(1)
}}
className="pl-10 w-full sm:w-72 bg-background"
/>
</div>
{/* Status filter pills */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0">
<FilterPill
label="All"
isActive={statusFilter === 'all'}
onClick={() => { setStatusFilter('all'); setCurrentPage(1) }}
/>
<FilterPill
label="Active"
isActive={statusFilter === 'ACTIVE'}
onClick={() => { setStatusFilter('ACTIVE'); setCurrentPage(1) }}
/>
<FilterPill
label="Suspended"
isActive={statusFilter === 'SUSPENDED'}
onClick={() => { setStatusFilter('SUSPENDED'); setCurrentPage(1) }}
/>
<FilterPill
label="Pending"
isActive={statusFilter === 'PENDING_VERIFICATION'}
onClick={() => { setStatusFilter('PENDING_VERIFICATION'); setCurrentPage(1) }}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-4 md:p-6">
{customers.length === 0 ? (
<EmptyState hasFilters={hasFilters} onClearFilters={clearFilters} />
) : (
<>
{/* Customer list */}
<div className="space-y-3">
{customers.map((customer) => (
<CustomerRow key={customer.id} customer={customer} />
))}
</div>
{/* Enhanced Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4 pt-6 border-t">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to{' '}
<span className="font-medium">
{Math.min(currentPage * itemsPerPage, data?.pagination?.total || 0)}
</span>{' '}
of <span className="font-medium">{data?.pagination?.total || 0}</span> customers
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1.5 h-9 px-4"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
{/* Page numbers */}
<div className="hidden sm:flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (currentPage <= 3) {
pageNum = i + 1
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = currentPage - 2 + i
}
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? 'default' : 'ghost'}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className={`h-9 w-9 p-0 ${currentPage === pageNum ? 'shadow-md' : ''}`}
>
{pageNum}
</Button>
)
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="gap-1.5 h-9 px-4"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Add Customer Dialog */}
<AddCustomerDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
/>
</div>
)
}

View File

@@ -0,0 +1,908 @@
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
useEnterpriseClient,
useUpdateEnterpriseClient,
useDeleteEnterpriseClient,
useClientServers,
useServerAction,
useErrorRules,
useCreateErrorRule,
useDetectedErrors,
useAcknowledgeError,
useAddServerToClient,
} from '@/hooks/use-enterprise-clients'
import { useNetcupServers } from '@/hooks/use-netcup'
import {
ArrowLeft,
Building2,
Server,
AlertTriangle,
Mail,
Phone,
FileText,
Edit,
Trash2,
Power,
RotateCcw,
Plus,
Check,
X,
Loader2,
AlertCircle,
RefreshCw,
Activity,
HardDrive,
Cpu,
MemoryStick,
Clock,
ShieldAlert,
Eye,
} from 'lucide-react'
import type { EnterpriseServerWithStatus, ErrorDetectionRule, DetectedError, ErrorSeverity } from '@/types/api'
// Overview stat card
function OverviewCard({
title,
value,
icon: Icon,
className = ''
}: {
title: string
value: string | number
icon: typeof Cpu
className?: string
}) {
return (
<Card className={className}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-xs text-muted-foreground">{title}</p>
<p className="text-lg font-semibold">{value}</p>
</div>
</div>
</CardContent>
</Card>
)
}
// Server card component
function ServerCard({
server,
clientId,
onPowerAction
}: {
server: EnterpriseServerWithStatus
clientId: string
onPowerAction: (serverId: string, command: 'ON' | 'OFF' | 'POWERCYCLE') => void
}) {
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
running: { bg: 'bg-emerald-50 dark:bg-emerald-950/30', text: 'text-emerald-700 dark:text-emerald-400', dot: 'bg-emerald-500 animate-pulse' },
stopped: { bg: 'bg-slate-50 dark:bg-slate-950/30', text: 'text-slate-600 dark:text-slate-400', dot: 'bg-slate-400' },
error: { bg: 'bg-red-50 dark:bg-red-950/30', text: 'text-red-700 dark:text-red-400', dot: 'bg-red-500' },
unknown: { bg: 'bg-amber-50 dark:bg-amber-950/30', text: 'text-amber-700 dark:text-amber-400', dot: 'bg-amber-500' }
}
const status = server.netcupStatus?.toLowerCase() || 'unknown'
const colors = statusColors[status] || statusColors.unknown
return (
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<Link
href={`/admin/enterprise-clients/${clientId}/servers/${server.id}`}
className="font-medium hover:text-primary hover:underline"
>
{server.nickname || server.netcupServerId}
</Link>
{server.purpose && (
<p className="text-xs text-muted-foreground">{server.purpose}</p>
)}
<div className="flex items-center gap-2 mt-1">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${colors.bg} ${colors.text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${colors.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</div>
{server.netcupIps?.length > 0 && (
<p className="text-xs text-muted-foreground mt-1 font-mono">
{server.netcupIps[0]}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/admin/enterprise-clients/${clientId}/servers/${server.id}`}>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="View Server"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
{status === 'running' ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => onPowerAction(server.id, 'POWERCYCLE')}
className="h-8 w-8 p-0"
title="Restart"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onPowerAction(server.id, 'OFF')}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
title="Power Off"
>
<Power className="h-4 w-4" />
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onPowerAction(server.id, 'ON')}
className="h-8 w-8 p-0 text-emerald-600"
title="Power On"
>
<Power className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}
// Error rule row
function ErrorRuleRow({ rule }: { rule: ErrorDetectionRule }) {
const severityColors: Record<string, string> = {
INFO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
WARNING: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
ERROR: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
CRITICAL: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
return (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<div className={`p-1.5 rounded ${rule.isActive ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-slate-100 dark:bg-slate-900/30'}`}>
{rule.isActive ? (
<Check className="h-3.5 w-3.5 text-emerald-600" />
) : (
<X className="h-3.5 w-3.5 text-slate-400" />
)}
</div>
<div>
<p className="font-medium text-sm">{rule.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate max-w-xs">
{rule.pattern}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${severityColors[rule.severity]}`}>
{rule.severity}
</span>
<span className="text-xs text-muted-foreground">
{rule._count?.detectedErrors || 0} matches
</span>
</div>
</div>
)
}
// Detected error row
function DetectedErrorRow({
error,
onAcknowledge
}: {
error: DetectedError
onAcknowledge: () => void
}) {
const severityColors: Record<string, string> = {
INFO: 'border-l-blue-500',
WARNING: 'border-l-amber-500',
ERROR: 'border-l-red-500',
CRITICAL: 'border-l-purple-500'
}
const isAcknowledged = !!error.acknowledgedAt
return (
<div className={`p-3 border rounded-lg border-l-4 ${severityColors[error.rule?.severity || 'INFO']} ${isAcknowledged ? 'opacity-60' : ''}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{error.rule?.name}</span>
{error.containerName && (
<span className="text-xs text-muted-foreground">
in {error.containerName}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
{error.logLine}
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(error.timestamp).toLocaleString()}
{error.server && `${error.server.nickname || error.server.netcupServerId}`}
</p>
</div>
{!isAcknowledged && (
<Button
variant="ghost"
size="sm"
onClick={onAcknowledge}
className="h-8 px-2 text-xs"
>
<Check className="h-3.5 w-3.5 mr-1" />
Ack
</Button>
)}
</div>
</div>
)
}
// Add rule dialog
function AddRuleDialog({
open,
onOpenChange,
clientId
}: {
open: boolean
onOpenChange: (open: boolean) => void
clientId: string
}) {
const [formData, setFormData] = useState({
name: '',
pattern: '',
severity: 'WARNING' as ErrorSeverity,
description: ''
})
const createRule = useCreateErrorRule()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await createRule.mutateAsync({
clientId,
data: {
name: formData.name,
pattern: formData.pattern,
severity: formData.severity,
description: formData.description || undefined
}
})
setFormData({ name: '', pattern: '', severity: 'WARNING', description: '' })
onOpenChange(false)
} catch {
// Error handled by mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Error Detection Rule</DialogTitle>
<DialogDescription>
Create a regex pattern to detect errors in container logs.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="ruleName">Rule Name *</Label>
<Input
id="ruleName"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Database Connection Failed"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pattern">Regex Pattern *</Label>
<Input
id="pattern"
value={formData.pattern}
onChange={(e) => setFormData({ ...formData, pattern: e.target.value })}
placeholder="error|ERROR|failed|FAILED"
className="font-mono text-sm"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="severity">Severity</Label>
<Select
value={formData.severity}
onValueChange={(value) => setFormData({ ...formData, severity: value as ErrorSeverity })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="INFO">Info</SelectItem>
<SelectItem value="WARNING">Warning</SelectItem>
<SelectItem value="ERROR">Error</SelectItem>
<SelectItem value="CRITICAL">Critical</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Detects database connection errors"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={createRule.isPending}>
{createRule.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Rule
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// Add server dialog
function AddServerDialog({
open,
onOpenChange,
clientId,
existingServerIds
}: {
open: boolean
onOpenChange: (open: boolean) => void
clientId: string
existingServerIds: string[]
}) {
const [formData, setFormData] = useState({
netcupServerId: '',
nickname: '',
purpose: '',
portainerUrl: '',
portainerUsername: '',
portainerPassword: ''
})
const { data: netcupServers, isLoading: loadingServers } = useNetcupServers(false)
const addServer = useAddServerToClient()
// Filter out servers that are already linked
const availableServers = netcupServers?.servers?.filter(
(s: { id: string }) => !existingServerIds.includes(s.id)
) || []
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.netcupServerId) return
try {
await addServer.mutateAsync({
clientId,
data: {
netcupServerId: formData.netcupServerId,
nickname: formData.nickname || undefined,
purpose: formData.purpose || undefined,
portainerUrl: formData.portainerUrl || undefined,
portainerUsername: formData.portainerUsername || undefined,
portainerPassword: formData.portainerPassword || undefined
}
})
setFormData({
netcupServerId: '',
nickname: '',
purpose: '',
portainerUrl: '',
portainerUsername: '',
portainerPassword: ''
})
onOpenChange(false)
} catch {
// Error handled by mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Add Server to Client</DialogTitle>
<DialogDescription>
Link a Netcup server to this enterprise client.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="netcupServer">Netcup Server *</Label>
{loadingServers ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-2 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin" />
Loading servers...
</div>
) : availableServers.length === 0 ? (
<div className="text-sm text-muted-foreground p-2 border rounded-md">
No available servers. All Netcup servers are already linked.
</div>
) : (
<Select
value={formData.netcupServerId}
onValueChange={(value) => setFormData({ ...formData, netcupServerId: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a server..." />
</SelectTrigger>
<SelectContent>
{availableServers.map((server: { id: string; name: string; nickname?: string; hostname?: string; ips?: string[] }) => (
<SelectItem key={server.id} value={server.id}>
<div className="flex flex-col">
<span className="font-medium">{server.nickname || server.name}</span>
<span className="text-xs text-muted-foreground">
{server.ips?.[0] || 'No IP'}
{server.hostname && `${server.hostname}`}
{server.nickname && `${server.name}`}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="nickname">Nickname</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
placeholder="Production Server"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="purpose">Purpose</Label>
<Input
id="purpose"
value={formData.purpose}
onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
placeholder="Web hosting"
/>
</div>
</div>
<div className="border-t pt-4 mt-2">
<p className="text-sm font-medium mb-3">Portainer Credentials (Optional)</p>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="portainerUrl">Portainer URL</Label>
<Input
id="portainerUrl"
value={formData.portainerUrl}
onChange={(e) => setFormData({ ...formData, portainerUrl: e.target.value })}
placeholder="https://portainer.example.com"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="portainerUsername">Username</Label>
<Input
id="portainerUsername"
value={formData.portainerUsername}
onChange={(e) => setFormData({ ...formData, portainerUsername: e.target.value })}
placeholder="admin"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="portainerPassword">Password</Label>
<Input
id="portainerPassword"
type="password"
value={formData.portainerPassword}
onChange={(e) => setFormData({ ...formData, portainerPassword: e.target.value })}
placeholder="••••••••"
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={addServer.isPending || !formData.netcupServerId}>
{addServer.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Server
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export default function EnterpriseClientDetailPage({
params
}: {
params: Promise<{ id: string }>
}) {
const { id: clientId } = use(params)
const router = useRouter()
const [showAddRuleDialog, setShowAddRuleDialog] = useState(false)
const [showAddServerDialog, setShowAddServerDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const { data: client, isLoading, isError, error, refetch } = useEnterpriseClient(clientId)
const { data: servers } = useClientServers(clientId)
const { data: errorRules } = useErrorRules(clientId)
const { data: detectedErrors } = useDetectedErrors(clientId, { acknowledged: false, limit: 50 })
const deleteClient = useDeleteEnterpriseClient()
const serverAction = useServerAction()
const acknowledgeError = useAcknowledgeError()
const handlePowerAction = async (serverId: string, command: 'ON' | 'OFF' | 'POWERCYCLE') => {
try {
await serverAction.mutateAsync({
clientId,
serverId,
action: { action: 'power', command }
})
} catch {
// Error handled by mutation
}
}
const handleAcknowledgeError = async (errorId: string) => {
try {
await acknowledgeError.mutateAsync({ clientId, errorId })
} catch {
// Error handled by mutation
}
}
const handleDeleteClient = async () => {
try {
await deleteClient.mutateAsync(clientId)
router.push('/admin/enterprise-clients')
} catch {
// Error handled by mutation
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading client details...</p>
</div>
)
}
if (isError || !client) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="font-medium text-destructive">Failed to load client</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'Client not found'}
</p>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/enterprise-clients">
<Button variant="ghost" size="sm" className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl bg-gradient-to-br from-primary/10 to-primary/20">
<Building2 className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold">{client.name}</h1>
{client.companyName && (
<p className="text-muted-foreground">{client.companyName}</p>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2">
<Edit className="h-4 w-4" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="gap-2 text-destructive hover:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* Client info cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Mail className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Contact Email</p>
<p className="font-medium">{client.contactEmail}</p>
</div>
</div>
</CardContent>
</Card>
{client.contactPhone && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Phone className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Contact Phone</p>
<p className="font-medium">{client.contactPhone}</p>
</div>
</div>
</CardContent>
</Card>
)}
{client.notes && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<FileText className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-xs text-muted-foreground">Notes</p>
<p className="font-medium text-sm">{client.notes}</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Overview stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<OverviewCard
title="Servers"
value={client.statsOverview?.totalServers || 0}
icon={Server}
/>
<OverviewCard
title="Avg CPU"
value={client.statsOverview?.avgCpuPercent != null ? `${client.statsOverview.avgCpuPercent}%` : '-'}
icon={Cpu}
/>
<OverviewCard
title="Containers"
value={`${client.statsOverview?.runningContainers || 0}/${client.statsOverview?.totalContainers || 0}`}
icon={HardDrive}
/>
<OverviewCard
title="Open Errors"
value={client.statsOverview?.unacknowledgedErrors || 0}
icon={AlertTriangle}
className={client.statsOverview?.unacknowledgedErrors ? 'border-red-200 dark:border-red-900' : ''}
/>
</div>
{/* Tabs */}
<Tabs defaultValue="servers" className="space-y-4">
<TabsList>
<TabsTrigger value="servers" className="gap-2">
<Server className="h-4 w-4" />
Servers ({servers?.length || 0})
</TabsTrigger>
<TabsTrigger value="errors" className="gap-2">
<AlertTriangle className="h-4 w-4" />
Errors ({detectedErrors?.length || 0})
</TabsTrigger>
<TabsTrigger value="rules" className="gap-2">
<ShieldAlert className="h-4 w-4" />
Rules ({errorRules?.length || 0})
</TabsTrigger>
</TabsList>
<TabsContent value="servers" className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{servers?.length || 0} server{servers?.length !== 1 ? 's' : ''} linked
</p>
<Button size="sm" className="gap-2" onClick={() => setShowAddServerDialog(true)}>
<Plus className="h-4 w-4" />
Add Server
</Button>
</div>
{servers && servers.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{servers.map((server) => (
<ServerCard
key={server.id}
server={server}
clientId={clientId}
onPowerAction={handlePowerAction}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<Server className="h-12 w-12 text-muted-foreground/40 mx-auto mb-4" />
<p className="text-muted-foreground">No servers linked to this client yet.</p>
<Button className="mt-4 gap-2" onClick={() => setShowAddServerDialog(true)}>
<Plus className="h-4 w-4" />
Add Server
</Button>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="errors" className="space-y-4">
{detectedErrors && detectedErrors.length > 0 ? (
<div className="space-y-2">
{detectedErrors.map((err) => (
<DetectedErrorRow
key={err.id}
error={err}
onAcknowledge={() => handleAcknowledgeError(err.id)}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<Check className="h-12 w-12 text-emerald-500/40 mx-auto mb-4" />
<p className="text-muted-foreground">No unacknowledged errors.</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="rules" className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Define regex patterns to detect errors in container logs.
</p>
<Button size="sm" className="gap-2" onClick={() => setShowAddRuleDialog(true)}>
<Plus className="h-4 w-4" />
Add Rule
</Button>
</div>
{errorRules && errorRules.length > 0 ? (
<div className="space-y-2">
{errorRules.map((rule) => (
<ErrorRuleRow key={rule.id} rule={rule} />
))}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-4" />
<p className="text-muted-foreground">No error detection rules configured.</p>
<Button className="mt-4 gap-2" onClick={() => setShowAddRuleDialog(true)}>
<Plus className="h-4 w-4" />
Add Rule
</Button>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* Add Rule Dialog */}
<AddRuleDialog
open={showAddRuleDialog}
onOpenChange={setShowAddRuleDialog}
clientId={clientId}
/>
{/* Add Server Dialog */}
<AddServerDialog
open={showAddServerDialog}
onOpenChange={setShowAddServerDialog}
clientId={clientId}
existingServerIds={servers?.map(s => s.netcupServerId) || []}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Enterprise Client</DialogTitle>
<DialogDescription>
Are you sure you want to delete {client.name}? This will remove all associated servers, error rules, and detected errors. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteClient}
disabled={deleteClient.isPending}
>
{deleteClient.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete Client
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,403 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
ArrowLeft,
Box,
Play,
Square,
RefreshCw,
Trash2,
Loader2,
Clock,
HardDrive,
Cpu,
MemoryStick,
Network,
Download,
AlertTriangle
} from 'lucide-react'
import {
useEnterpriseClient,
useClientServer,
useContainer,
useContainerLogs,
useContainerAction,
useRemoveContainer
} from '@/hooks/use-enterprise-clients'
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString()
}
export default function ContainerDetailPage() {
const params = useParams()
const router = useRouter()
const clientId = params.id as string
const serverId = params.serverId as string
const containerId = params.containerId as string
const [tail, setTail] = useState(500)
const [autoScroll, setAutoScroll] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const logsEndRef = useRef<HTMLDivElement>(null)
const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId)
const { data: server, isLoading: serverLoading } = useClientServer(clientId, serverId)
const { data: container, isLoading: containerLoading, refetch: refetchContainer } = useContainer(clientId, serverId, containerId)
const { data: logsData, isLoading: logsLoading, refetch: refetchLogs } = useContainerLogs(clientId, serverId, containerId, tail)
const containerAction = useContainerAction()
const removeContainer = useRemoveContainer()
const isLoading = clientLoading || serverLoading || containerLoading
// Auto-scroll to bottom when logs update
useEffect(() => {
if (autoScroll && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [logsData, autoScroll])
// Auto-refresh logs every 5 seconds
useEffect(() => {
const interval = setInterval(() => {
refetchLogs()
refetchContainer()
}, 5000)
return () => clearInterval(interval)
}, [refetchLogs, refetchContainer])
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
setActionLoading(action)
try {
await containerAction.mutateAsync({
clientId,
serverId,
containerId,
action,
})
// Refetch container data after action
setTimeout(() => refetchContainer(), 1000)
} catch (err) {
console.error(`Failed to ${action} container:`, err)
} finally {
setActionLoading(null)
}
}
const handleRemove = async () => {
if (!confirm('Are you sure you want to remove this container? This action cannot be undone.')) return
setActionLoading('remove')
try {
await removeContainer.mutateAsync({
clientId,
serverId,
containerId,
force: true,
})
router.push(`/admin/enterprise-clients/${clientId}/servers/${serverId}`)
} catch (err) {
console.error('Failed to remove container:', err)
} finally {
setActionLoading(null)
}
}
const handleDownloadLogs = () => {
if (!logsData?.logs || !container) return
const blob = new Blob([logsData.logs], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${container.name || container.id.slice(0, 12)}-logs.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
)
}
if (!client || !server || !container) {
return (
<div className="text-center py-12">
<h2 className="text-lg font-medium text-gray-900">Container not found</h2>
<p className="mt-1 text-sm text-gray-500">
The container you&apos;re looking for doesn&apos;t exist or has been removed.
</p>
<Link
href={`/admin/enterprise-clients/${clientId}/servers/${serverId}`}
className="mt-4 inline-flex items-center text-blue-600 hover:underline"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to server
</Link>
</div>
)
}
const isRunning = container.state.Running
const name = container.name || container.id.slice(0, 12)
const stateStatus = container.state.Status
const statusColor = isRunning
? 'bg-green-100 text-green-800'
: stateStatus === 'exited'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href={`/admin/enterprise-clients/${clientId}/servers/${serverId}`}
className="text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<Box className={`h-5 w-5 ${isRunning ? 'text-green-500' : 'text-gray-400'}`} />
<h1 className="text-2xl font-bold text-gray-900">{name}</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}`}>
{stateStatus}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
{server.nickname || server.netcupServerId} &bull; {container.image}
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{isRunning ? (
<button
onClick={() => handleAction('stop')}
disabled={actionLoading !== null}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{actionLoading === 'stop' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Square className="h-4 w-4 mr-1 text-red-600" />
)}
Stop
</button>
) : (
<button
onClick={() => handleAction('start')}
disabled={actionLoading !== null}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{actionLoading === 'start' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Play className="h-4 w-4 mr-1 text-green-600" />
)}
Start
</button>
)}
<button
onClick={() => handleAction('restart')}
disabled={actionLoading !== null || !isRunning}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{actionLoading === 'restart' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
Restart
</button>
<button
onClick={handleRemove}
disabled={actionLoading !== null}
className="inline-flex items-center px-3 py-1.5 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50 disabled:opacity-50"
>
{actionLoading === 'remove' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-1" />
)}
Remove
</button>
</div>
</div>
{/* Container Info */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Container Information</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
<HardDrive className="h-4 w-4" />
Container ID
</div>
<code className="text-sm bg-gray-100 px-2 py-0.5 rounded">{container.id.slice(0, 12)}</code>
</div>
<div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
<Clock className="h-4 w-4" />
Created
</div>
<span className="text-sm font-medium">{formatDate(container.created)}</span>
</div>
{container.stats && (
<>
<div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
<Cpu className="h-4 w-4" />
CPU Usage
</div>
<span className="text-sm font-medium">{container.stats.cpuPercent.toFixed(2)}%</span>
</div>
<div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
<MemoryStick className="h-4 w-4" />
Memory
</div>
<span className="text-sm font-medium">
{formatBytes(container.stats.memoryUsage)} / {formatBytes(container.stats.memoryLimit)}
</span>
</div>
</>
)}
</div>
{/* Ports */}
{container.networkSettings?.ports && Object.keys(container.networkSettings.ports).length > 0 && (
<div className="mt-6">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
<Network className="h-4 w-4" />
Ports
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(container.networkSettings.ports).map(([containerPort, hostBindings]) => (
<span
key={containerPort}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{containerPort}
{hostBindings && hostBindings.length > 0 && ` -> ${hostBindings[0].HostPort}`}
</span>
))}
</div>
</div>
)}
</div>
{/* Logs */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Logs</h2>
<div className="flex items-center gap-4">
{/* Tail selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Lines:</span>
<select
value={tail}
onChange={(e) => setTail(Number(e.target.value))}
className="text-sm border border-gray-300 rounded-md px-2 py-1"
>
<option value={100}>100</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
<option value={5000}>5000</option>
</select>
</div>
{/* Auto-scroll toggle */}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded border-gray-300"
/>
Auto-scroll
</label>
{/* Refresh button */}
<button
onClick={() => refetchLogs()}
disabled={logsLoading}
className="inline-flex items-center text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
{logsLoading ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
Refresh
</button>
{/* Download button */}
<button
onClick={handleDownloadLogs}
disabled={!logsData?.logs}
className="inline-flex items-center text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
<Download className="h-4 w-4 mr-1" />
Download
</button>
</div>
</div>
{/* Log output */}
<div className="bg-gray-900 rounded-lg p-4 h-[500px] overflow-auto font-mono text-sm">
{logsLoading && !logsData ? (
<div className="flex items-center justify-center h-full text-gray-400">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading logs...
</div>
) : logsData?.logs ? (
<pre className="text-gray-100 whitespace-pre-wrap break-words">
{logsData.logs}
</pre>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No logs available
</div>
)}
<div ref={logsEndRef} />
</div>
</div>
{/* Warning for stopped containers */}
{!isRunning && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Container is not running</h3>
<p className="text-sm text-yellow-700 mt-1">
This container is currently stopped. Start it to see live logs and resource usage.
</p>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,251 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import {
ArrowLeft,
AlertTriangle,
Trash2,
RotateCcw,
Server,
Loader2,
ShieldAlert
} from 'lucide-react'
import {
useEnterpriseClient,
useClientServer
} from '@/hooks/use-enterprise-clients'
import { useNetcupImageFlavours, type ImageFlavour } from '@/hooks/use-netcup'
import { SecurityVerificationDialog } from '@/components/admin/security-verification-dialog'
type VerificationAction = 'WIPE' | 'REINSTALL'
export default function DangerZonePage() {
const params = useParams()
const clientId = params.id as string
const serverId = params.serverId as string
const [verificationOpen, setVerificationOpen] = useState(false)
const [selectedAction, setSelectedAction] = useState<VerificationAction>('WIPE')
const [selectedImageId, setSelectedImageId] = useState<string>('')
const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId)
const { data: server, isLoading: serverLoading } = useClientServer(clientId, serverId)
// Get image flavours using the Netcup server ID
const { data: flavoursData, isLoading: flavoursLoading } = useNetcupImageFlavours(
server?.netcupServerId || ''
)
const isLoading = clientLoading || serverLoading
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
)
}
if (!client || !server) {
return (
<div className="text-center py-12">
<h2 className="text-lg font-medium text-gray-900">Server not found</h2>
<p className="mt-1 text-sm text-gray-500">
The server you&apos;re looking for doesn&apos;t exist or has been removed.
</p>
<Link
href={`/admin/enterprise-clients/${clientId}`}
className="mt-4 inline-flex items-center text-blue-600 hover:underline"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to client
</Link>
</div>
)
}
const handleWipeClick = () => {
setSelectedAction('WIPE')
setVerificationOpen(true)
}
const handleReinstallClick = () => {
if (!selectedImageId) {
return
}
setSelectedAction('REINSTALL')
setVerificationOpen(true)
}
const handleSuccess = () => {
// Redirect back to server detail page after successful action
// The server will be in a different state (wiping/reinstalling)
setVerificationOpen(false)
}
const flavours: ImageFlavour[] = flavoursData?.flavours || []
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link
href={`/admin/enterprise-clients/${clientId}/servers/${serverId}`}
className="text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-red-500" />
<h1 className="text-2xl font-bold text-red-600">Danger Zone</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
<Server className="inline h-4 w-4 mr-1" />
{server.nickname || server.netcupServerId} {client.name}
</p>
</div>
</div>
{/* Warning Banner */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-red-800">Warning: Destructive Actions</h3>
<p className="text-sm text-red-700 mt-1">
The actions on this page will result in permanent data loss.
A verification code will be sent to the client&apos;s email address
({client.contactEmail}) before any action is executed.
</p>
</div>
</div>
</div>
{/* Wipe Server */}
<div className="bg-white border border-red-200 rounded-lg p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-100 rounded-lg">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="font-medium text-gray-900">Wipe Server</h3>
<p className="text-sm text-gray-500 mt-1">
Completely erase all data on this server. The server will be wiped
and returned to a clean state. All files, configurations, and
containers will be permanently deleted.
</p>
<ul className="mt-3 text-sm text-gray-600 space-y-1">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 bg-red-500 rounded-full" />
All data will be permanently lost
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 bg-red-500 rounded-full" />
Cannot be undone
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 bg-red-500 rounded-full" />
Server will be offline during wipe
</li>
</ul>
</div>
</div>
<button
onClick={handleWipeClick}
className="inline-flex items-center px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50 whitespace-nowrap"
>
<Trash2 className="h-4 w-4 mr-2" />
Wipe Server
</button>
</div>
</div>
{/* Reinstall Server */}
<div className="bg-white border border-red-200 rounded-lg p-6">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="p-3 bg-orange-100 rounded-lg">
<RotateCcw className="h-6 w-6 text-orange-600" />
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900">Reinstall Operating System</h3>
<p className="text-sm text-gray-500 mt-1">
Reinstall the operating system from scratch. Select an image below
and the server will be reinstalled with a fresh OS installation.
</p>
<ul className="mt-3 text-sm text-gray-600 space-y-1">
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 bg-orange-500 rounded-full" />
All existing data will be lost
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 bg-orange-500 rounded-full" />
Server will be offline during reinstall
</li>
<li className="flex items-center gap-2">
<span className="h-1.5 w-1.5 bg-orange-500 rounded-full" />
New root password will be generated
</li>
</ul>
</div>
</div>
{/* Image Selection */}
<div className="pt-4 border-t border-gray-200">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Operating System Image
</label>
{flavoursLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading available images...
</div>
) : flavours.length === 0 ? (
<p className="text-sm text-gray-500">No images available for this server.</p>
) : (
<select
value={selectedImageId}
onChange={(e) => setSelectedImageId(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select an image...</option>
{flavours.map((flavour) => (
<option key={flavour.id} value={flavour.id}>
{flavour.name}
</option>
))}
</select>
)}
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleReinstallClick}
disabled={!selectedImageId}
className="inline-flex items-center px-4 py-2 border border-orange-300 rounded-md text-sm font-medium text-orange-700 bg-white hover:bg-orange-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reinstall Server
</button>
</div>
</div>
</div>
{/* Security Verification Dialog */}
<SecurityVerificationDialog
open={verificationOpen}
onOpenChange={setVerificationOpen}
clientId={clientId}
serverId={serverId}
serverName={server.nickname || server.netcupServerId}
action={selectedAction}
imageId={selectedAction === 'REINSTALL' ? selectedImageId : undefined}
onSuccess={handleSuccess}
/>
</div>
)
}

View File

@@ -0,0 +1,349 @@
'use client'
import { useState, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
ArrowLeft,
Server,
Power,
RefreshCw,
AlertTriangle,
Cpu,
HardDrive,
Network,
MemoryStick,
Activity,
Loader2,
Box,
Unlink,
Settings
} from 'lucide-react'
import {
useEnterpriseClient,
useClientServer,
useServerStatsHistory,
useCollectServerStats,
useServerAction,
useRemoveServerFromClient
} from '@/hooks/use-enterprise-clients'
import {
RangeSelector,
StatsCard,
CpuUsageChart,
MemoryUsageChart,
DiskIOChart,
NetworkChart
} from '@/components/admin/enterprise-stats-charts'
import { LiveStatsPanel } from '@/components/admin/live-stats-panel'
import { EnterpriseContainerList } from '@/components/admin/enterprise-container-list'
import type { StatsRange } from '@/lib/api/admin'
import type { StatsDataPoint } from '@/lib/services/stats-collection-service'
export default function ServerDetailPage() {
const params = useParams()
const router = useRouter()
const clientId = params.id as string
const serverId = params.serverId as string
const [range, setRange] = useState<StatsRange>('24h')
const [actionLoading, setActionLoading] = useState<string | null>(null)
const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId)
const { data: server, isLoading: serverLoading, refetch: refetchServer } = useClientServer(clientId, serverId)
const { data: statsData, isLoading: statsLoading } = useServerStatsHistory(clientId, serverId, range)
const collectStats = useCollectServerStats()
const serverAction = useServerAction()
const removeServer = useRemoveServerFromClient()
// Stable callback for refreshing stats (used by LiveStatsPanel)
const handleRefreshStats = useCallback(() => {
collectStats.mutate({ clientId, serverId })
}, [collectStats, clientId, serverId])
const isLoading = clientLoading || serverLoading
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
)
}
if (!client || !server) {
return (
<div className="text-center py-12">
<h2 className="text-lg font-medium text-gray-900">Server not found</h2>
<p className="mt-1 text-sm text-gray-500">
The server you&apos;re looking for doesn&apos;t exist or has been removed.
</p>
<Link
href={`/admin/enterprise-clients/${clientId}`}
className="mt-4 inline-flex items-center text-blue-600 hover:underline"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to client
</Link>
</div>
)
}
// Convert API response to StatsDataPoint[] format for charts
const history: StatsDataPoint[] = statsData?.history?.map(h => ({
...h,
timestamp: new Date(h.timestamp)
})) || []
const latest = statsData?.latest
const handlePowerAction = async (command: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET') => {
setActionLoading(command)
try {
await serverAction.mutateAsync({
clientId,
serverId,
action: { action: 'power', command }
})
// Refresh server data after action
setTimeout(() => refetchServer(), 2000)
} catch (error) {
console.error('Power action failed:', error)
} finally {
setActionLoading(null)
}
}
const handleUnlinkServer = async () => {
if (!confirm(`Are you sure you want to unlink "${server?.nickname || server?.netcupServerId}" from this client?\n\nThis will remove the server from this enterprise client. The server itself will not be affected.`)) {
return
}
try {
await removeServer.mutateAsync({ clientId, serverId })
router.push(`/admin/enterprise-clients/${clientId}`)
} catch (error) {
console.error('Failed to unlink server:', error)
}
}
const statusColor = server.netcupStatus === 'RUNNING' || server.netcupStatus === 'running'
? 'bg-green-100 text-green-800'
: server.netcupStatus === 'SHUTOFF' || server.netcupStatus === 'stopped'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href={`/admin/enterprise-clients/${clientId}`}
className="text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-gray-400" />
<h1 className="text-2xl font-bold text-gray-900">
{server.nickname || server.netcupServerId}
</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}`}>
{server.netcupStatus}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
{server.purpose && `${server.purpose}`}
{client.name} Netcup ID: {server.netcupServerId}
</p>
</div>
</div>
{/* Power Controls */}
<div className="flex items-center gap-2">
<button
onClick={() => handlePowerAction('ON')}
disabled={actionLoading !== null}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{actionLoading === 'ON' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Power className="h-4 w-4 mr-1 text-green-600" />
)}
Start
</button>
<button
onClick={() => handlePowerAction('OFF')}
disabled={actionLoading !== null}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{actionLoading === 'OFF' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Power className="h-4 w-4 mr-1 text-red-600" />
)}
Stop
</button>
<button
onClick={() => handlePowerAction('POWERCYCLE')}
disabled={actionLoading !== null}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{actionLoading === 'POWERCYCLE' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
Restart
</button>
<button
onClick={() => router.push(`/admin/enterprise-clients/${clientId}/servers/${serverId}/settings`)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<Settings className="h-4 w-4 mr-1" />
Settings
</button>
<button
onClick={() => router.push(`/admin/enterprise-clients/${clientId}/servers/${serverId}/danger`)}
className="inline-flex items-center px-3 py-1.5 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50"
>
<AlertTriangle className="h-4 w-4 mr-1" />
Danger Zone
</button>
<button
onClick={handleUnlinkServer}
disabled={removeServer.isPending}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{removeServer.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Unlink className="h-4 w-4 mr-1" />
)}
Unlink
</button>
</div>
</div>
{/* Server Info */}
{server.netcupIps && server.netcupIps.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500">IP Address:</span>{' '}
<code className="bg-gray-200 px-2 py-0.5 rounded text-gray-800">
{server.netcupIps[0]}
</code>
</div>
{server.netcupHostname && (
<div>
<span className="text-gray-500">Hostname:</span>{' '}
<span className="font-medium">{server.netcupHostname}</span>
</div>
)}
</div>
</div>
)}
{/* Live Stats Panel with Auto-Refresh */}
<LiveStatsPanel
data={latest}
isRefreshing={collectStats.isPending}
onRefresh={handleRefreshStats}
autoRefreshInterval={15000}
/>
{/* Historical Charts */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Historical Metrics</h2>
<RangeSelector value={range} onChange={setRange} />
</div>
{statsLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<div className="space-y-8">
{/* CPU Chart */}
<div>
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-blue-500" />
<h3 className="text-sm font-medium text-gray-700">CPU Usage</h3>
</div>
<CpuUsageChart data={history} height={200} />
</div>
{/* Memory Chart */}
<div>
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-green-500" />
<h3 className="text-sm font-medium text-gray-700">Memory Usage</h3>
</div>
<MemoryUsageChart data={history} height={200} />
</div>
{/* Disk I/O Chart */}
<div>
<div className="flex items-center gap-2 mb-2">
<HardDrive className="h-4 w-4 text-purple-500" />
<h3 className="text-sm font-medium text-gray-700">Disk I/O</h3>
</div>
<DiskIOChart data={history} height={200} />
</div>
{/* Network Chart */}
<div>
<div className="flex items-center gap-2 mb-2">
<Network className="h-4 w-4 text-cyan-500" />
<h3 className="text-sm font-medium text-gray-700">Network Traffic</h3>
</div>
<NetworkChart data={history} height={200} />
</div>
</div>
)}
</div>
{/* Quick Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCard
title="CPU Average"
value={latest?.cpuPercent?.toFixed(1) ?? null}
unit="%"
icon={<Cpu className="h-5 w-5" />}
/>
<StatsCard
title="Memory Used"
value={latest?.memoryUsedMb?.toFixed(0) ?? null}
unit="MB"
icon={<MemoryStick className="h-5 w-5" />}
/>
<StatsCard
title="Disk Read"
value={latest?.diskReadMbps?.toFixed(2) ?? null}
unit="MB/s"
icon={<HardDrive className="h-5 w-5" />}
/>
<StatsCard
title="Network In"
value={latest?.networkInMbps?.toFixed(2) ?? null}
unit="Mbps"
icon={<Activity className="h-5 w-5" />}
/>
</div>
{/* Containers */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<Box className="h-5 w-5 text-gray-400" />
<h2 className="text-lg font-medium text-gray-900">Containers</h2>
</div>
<EnterpriseContainerList clientId={clientId} serverId={serverId} />
</div>
</div>
)
}

View File

@@ -0,0 +1,406 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import {
ArrowLeft,
Settings,
Server,
Loader2,
Save,
Eye,
EyeOff,
ExternalLink,
Plug,
CheckCircle,
XCircle
} from 'lucide-react'
import {
useEnterpriseClient,
useClientServer,
useUpdateClientServer,
useTestPortainerConnection
} from '@/hooks/use-enterprise-clients'
export default function ServerSettingsPage() {
const params = useParams()
const clientId = params.id as string
const serverId = params.serverId as string
const [showPassword, setShowPassword] = useState(false)
const [formData, setFormData] = useState({
nickname: '',
purpose: '',
portainerUrl: '',
portainerUsername: '',
portainerPassword: ''
})
const [hasChanges, setHasChanges] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId)
const { data: server, isLoading: serverLoading } = useClientServer(clientId, serverId)
const updateServer = useUpdateClientServer()
const testConnection = useTestPortainerConnection()
const isLoading = clientLoading || serverLoading
// Initialize form data when server data loads
useEffect(() => {
if (server) {
setFormData({
nickname: server.nickname || '',
purpose: server.purpose || '',
portainerUrl: server.portainerUrl || '',
portainerUsername: server.portainerUsername || '',
portainerPassword: '' // Don't load password, let user enter new one
})
setHasChanges(false)
setTestResult(null)
}
}, [server])
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }))
setHasChanges(true)
// Clear test result when credentials change
if (field.startsWith('portainer')) {
setTestResult(null)
}
}
const handleTestConnection = async () => {
// Need URL, username, and password to test
const password = formData.portainerPassword || (server?.portainerUsername ? '__EXISTING__' : '')
if (!formData.portainerUrl || !formData.portainerUsername) {
setTestResult({
success: false,
message: 'Please enter Portainer URL and username'
})
return
}
if (!formData.portainerPassword && !server?.portainerUsername) {
setTestResult({
success: false,
message: 'Please enter a password'
})
return
}
try {
// If we have a new password, use it; otherwise the API will use the saved one
// But we need to send the password for testing, so require it if no saved password
if (!formData.portainerPassword) {
setTestResult({
success: false,
message: 'Please enter the password to test the connection'
})
return
}
const result = await testConnection.mutateAsync({
clientId,
serverId,
credentials: {
portainerUrl: formData.portainerUrl,
portainerUsername: formData.portainerUsername,
portainerPassword: formData.portainerPassword
}
})
setTestResult(result)
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed'
})
}
}
const handleSave = async () => {
try {
// Only send password if it was changed
const payload: Record<string, string | null> = {
nickname: formData.nickname || null,
purpose: formData.purpose || null,
portainerUrl: formData.portainerUrl || null,
portainerUsername: formData.portainerUsername || null,
}
if (formData.portainerPassword) {
payload.portainerPassword = formData.portainerPassword
}
await updateServer.mutateAsync({
clientId,
serverId,
data: payload
})
setHasChanges(false)
// Clear password field after save
setFormData(prev => ({ ...prev, portainerPassword: '' }))
} catch (error) {
console.error('Failed to save settings:', error)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
)
}
if (!client || !server) {
return (
<div className="text-center py-12">
<h2 className="text-lg font-medium text-gray-900">Server not found</h2>
<p className="mt-1 text-sm text-gray-500">
The server you&apos;re looking for doesn&apos;t exist or has been removed.
</p>
<Link
href={`/admin/enterprise-clients/${clientId}`}
className="mt-4 inline-flex items-center text-blue-600 hover:underline"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to client
</Link>
</div>
)
}
const canTestConnection = formData.portainerUrl && formData.portainerUsername && formData.portainerPassword
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href={`/admin/enterprise-clients/${clientId}/servers/${serverId}`}
className="text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-400" />
<h1 className="text-2xl font-bold text-gray-900">Server Settings</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
<Server className="inline h-4 w-4 mr-1" />
{server.nickname || server.netcupServerId} {client.name}
</p>
</div>
</div>
<button
onClick={handleSave}
disabled={!hasChanges || updateServer.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateServer.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</button>
</div>
{/* General Settings */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">General Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Server Nickname
</label>
<input
type="text"
value={formData.nickname}
onChange={(e) => handleInputChange('nickname', e.target.value)}
placeholder="e.g., Production Server"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
A friendly name to identify this server
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Purpose
</label>
<input
type="text"
value={formData.purpose}
onChange={(e) => handleInputChange('purpose', e.target.value)}
placeholder="e.g., Web hosting, Database, Staging"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
What this server is used for
</p>
</div>
</div>
</div>
{/* Portainer Settings */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-medium text-gray-900">Portainer Configuration</h2>
<p className="text-sm text-gray-500">
Connect to Portainer for container management
</p>
</div>
{server.portainerUrl && (
<a
href={server.portainerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-blue-600 hover:text-blue-800"
>
<ExternalLink className="h-4 w-4 mr-1" />
Open Portainer
</a>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Portainer URL
</label>
<input
type="url"
value={formData.portainerUrl}
onChange={(e) => handleInputChange('portainerUrl', e.target.value)}
placeholder="https://portainer.example.com:9443"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
The URL to your Portainer instance (e.g., https://IP:9443)
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={formData.portainerUsername}
onChange={(e) => handleInputChange('portainerUsername', e.target.value)}
placeholder="admin"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.portainerPassword}
onChange={(e) => handleInputChange('portainerPassword', e.target.value)}
placeholder={server.portainerUsername ? '••••••••' : 'Enter password'}
className="w-full border border-gray-300 rounded-md px-3 py-2 pr-10 text-sm focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{server.portainerUsername && (
<p className="mt-1 text-xs text-gray-500">
Leave blank to keep existing password
</p>
)}
</div>
</div>
{/* Test Connection Button */}
<div className="pt-2">
<button
onClick={handleTestConnection}
disabled={!canTestConnection || testConnection.isPending}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{testConnection.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plug className="h-4 w-4 mr-2" />
)}
Test Connection
</button>
{/* Test Result */}
{testResult && (
<div className={`mt-3 flex items-center gap-2 text-sm ${testResult.success ? 'text-green-600' : 'text-red-600'}`}>
{testResult.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
<span>{testResult.message}</span>
</div>
)}
</div>
</div>
{/* Connection Status */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${server.portainerUrl ? 'bg-green-500' : 'bg-gray-300'}`} />
<span className="text-sm text-gray-600">
{server.portainerUrl
? 'Portainer configured'
: 'Portainer not configured'}
</span>
</div>
</div>
</div>
{/* Server Info (Read-only) */}
<div className="bg-gray-50 rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Server Information</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Netcup Server ID:</span>
<p className="font-mono text-gray-900">{server.netcupServerId}</p>
</div>
<div>
<span className="text-gray-500">IP Address:</span>
<p className="font-mono text-gray-900">{server.netcupIps?.[0] || 'N/A'}</p>
</div>
<div>
<span className="text-gray-500">Hostname:</span>
<p className="font-mono text-gray-900">{server.netcupHostname || 'N/A'}</p>
</div>
<div>
<span className="text-gray-500">Status:</span>
<p className="font-mono text-gray-900">{server.netcupStatus || 'Unknown'}</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,532 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { useEnterpriseClients, useCreateEnterpriseClient, useAllClientsErrorSummary } from '@/hooks/use-enterprise-clients'
import {
Search,
Plus,
Building2,
Server,
AlertTriangle,
Mail,
Phone,
ExternalLink,
Loader2,
AlertCircle,
RefreshCw,
Users,
Activity,
CheckCircle2,
Zap,
} from 'lucide-react'
import type { ClientErrorSummary } from '@/lib/api/admin'
import type { EnterpriseClientWithDetails } from '@/types/api'
import { EnterpriseErrorSummaryWidget } from '@/components/admin/enterprise-error-summary-widget'
// Stats card component
function StatsCard({
title,
value,
icon: Icon,
iconBg,
iconColor,
subtitle
}: {
title: string
value: number | string
icon: typeof Users
iconBg: string
iconColor: string
subtitle?: string
}) {
return (
<Card className="relative overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold tabular-nums">{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
<div className={`p-3 rounded-xl ${iconBg}`}>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
</div>
</CardContent>
</Card>
)
}
// Client card component
function ClientCard({
client,
errorSummary,
}: {
client: EnterpriseClientWithDetails
errorSummary?: ClientErrorSummary
}) {
const activeServers = client.servers?.filter(s => s.isActive).length || 0
const errorCount = client.statsOverview?.unacknowledgedErrors || 0
const hasCrashes = (errorSummary?.crashes24h ?? 0) > 0
const hasCritical = (errorSummary?.criticalErrors24h ?? 0) > 0
const hasIssues = hasCrashes || hasCritical
return (
<Link href={`/admin/enterprise-clients/${client.id}`}>
<Card className={`group hover:shadow-lg hover:border-primary/50 transition-all cursor-pointer ${hasIssues ? 'border-red-200 dark:border-red-900' : ''}`}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl transition-colors ${hasIssues ? 'bg-gradient-to-br from-red-100 to-red-200 dark:from-red-900/30 dark:to-red-800/30' : 'bg-gradient-to-br from-primary/10 to-primary/20 group-hover:from-primary/20 group-hover:to-primary/30'}`}>
<Building2 className={`h-6 w-6 ${hasIssues ? 'text-red-600 dark:text-red-400' : 'text-primary'}`} />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold group-hover:text-primary transition-colors">
{client.name}
</h3>
{/* Pulsing indicator for critical issues */}
{hasIssues && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
)}
</div>
{client.companyName && (
<p className="text-sm text-muted-foreground">{client.companyName}</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
{client.contactEmail}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
{!client.isActive && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400">
Inactive
</span>
)}
<ExternalLink className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Crash and Critical Error Badges */}
{hasIssues && (
<div className="flex flex-wrap gap-1 justify-end">
{hasCrashes && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 animate-pulse">
<AlertCircle className="h-2.5 w-2.5" />
{errorSummary?.crashes24h} crashed
</span>
)}
{hasCritical && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<Zap className="h-2.5 w-2.5" />
{errorSummary?.criticalErrors24h} critical
</span>
)}
</div>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-4 pt-4 border-t">
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Server className="h-3.5 w-3.5" />
<span className="text-xs">Servers</span>
</div>
<p className="text-lg font-semibold tabular-nums">
{activeServers}
{client._count?.servers !== activeServers && (
<span className="text-sm font-normal text-muted-foreground">
/{client._count?.servers}
</span>
)}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Activity className="h-3.5 w-3.5" />
<span className="text-xs">CPU Avg</span>
</div>
<p className="text-lg font-semibold tabular-nums">
{client.statsOverview?.avgCpuPercent != null
? `${client.statsOverview.avgCpuPercent}%`
: '-'}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5" />
<span className="text-xs">Errors</span>
</div>
<p className={`text-lg font-semibold tabular-nums ${errorCount > 0 ? 'text-red-600' : ''}`}>
{errorCount}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
)
}
// Add client dialog
function AddClientDialog({
open,
onOpenChange
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [formData, setFormData] = useState({
name: '',
companyName: '',
contactEmail: '',
contactPhone: '',
notes: ''
})
const createClient = useCreateEnterpriseClient()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await createClient.mutateAsync({
name: formData.name,
companyName: formData.companyName || undefined,
contactEmail: formData.contactEmail,
contactPhone: formData.contactPhone || undefined,
notes: formData.notes || undefined
})
setFormData({ name: '', companyName: '', contactEmail: '', contactPhone: '', notes: '' })
onOpenChange(false)
} catch {
// Error handled by mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add Enterprise Client</DialogTitle>
<DialogDescription>
Add a new enterprise client to manage their infrastructure.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Client Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Acme Corp"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="companyName">Company Name</Label>
<Input
id="companyName"
value={formData.companyName}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
placeholder="Acme Corporation Ltd."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="contactEmail">Contact Email *</Label>
<Input
id="contactEmail"
type="email"
value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="admin@acme.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="contactPhone">Contact Phone</Label>
<Input
id="contactPhone"
type="tel"
value={formData.contactPhone}
onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
placeholder="+1 555-1234"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notes</Label>
<Input
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Additional notes..."
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={createClient.isPending}>
{createClient.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Client
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// Empty state
function EmptyState({ onAddClient }: { onAddClient: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="relative">
<div className="absolute inset-0 bg-muted/30 rounded-full blur-2xl" />
<div className="relative p-6 rounded-full bg-muted/50">
<Building2 className="h-12 w-12 text-muted-foreground/60" />
</div>
</div>
<h3 className="mt-6 font-semibold text-lg">No enterprise clients yet</h3>
<p className="mt-2 text-sm text-muted-foreground text-center max-w-sm">
Get started by adding your first enterprise client to manage their infrastructure.
</p>
<Button onClick={onAddClient} className="mt-4 gap-2">
<Plus className="h-4 w-4" />
Add Enterprise Client
</Button>
</div>
)
}
export default function EnterpriseClientsPage() {
const [search, setSearch] = useState('')
const [showAddDialog, setShowAddDialog] = useState(false)
const { data: clients, isLoading, isError, error, refetch, isFetching } = useEnterpriseClients()
const { data: errorSummary } = useAllClientsErrorSummary()
// Create a map of client error summaries for quick lookup
const errorSummaryMap = new Map(
errorSummary?.clients.map((c) => [c.clientId, c]) ?? []
)
// Filter clients by search
const filteredClients = clients?.filter((client) => {
if (!search) return true
const searchLower = search.toLowerCase()
return (
client.name.toLowerCase().includes(searchLower) ||
client.companyName?.toLowerCase().includes(searchLower) ||
client.contactEmail.toLowerCase().includes(searchLower)
)
}) || []
// Calculate stats
const stats = {
total: clients?.length || 0,
active: clients?.filter(c => c.isActive).length || 0,
totalServers: clients?.reduce((acc, c) => acc + (c._count?.servers || 0), 0) || 0,
totalErrors: clients?.reduce((acc, c) => acc + (c.statsOverview?.unacknowledgedErrors || 0), 0) || 0
}
// Loading state
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm text-muted-foreground">Loading enterprise clients...</p>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
<div className="relative">
<div className="absolute inset-0 bg-destructive/20 rounded-full blur-xl" />
<div className="relative p-4 rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
</div>
<div className="text-center">
<p className="font-medium text-destructive">Failed to load enterprise clients</p>
<p className="text-sm text-muted-foreground mt-1">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Retry'}
</Button>
</div>
)
}
return (
<div className="space-y-8">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/20 border-2 border-primary/10">
<Building2 className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Enterprise Clients</h1>
<p className="text-muted-foreground mt-1">
Manage enterprise infrastructure and server monitoring
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="gap-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddDialog(true)} className="gap-2 shadow-md">
<Plus className="h-4 w-4" />
Add Client
</Button>
</div>
</div>
</div>
{/* Stats cards and Error Summary Widget */}
<div className="grid gap-4 lg:grid-cols-3">
{/* Stats cards - spans 2 columns on large screens */}
<div className="lg:col-span-2 grid gap-4 sm:grid-cols-2">
<StatsCard
title="Total Clients"
value={stats.total}
icon={Building2}
iconBg="bg-blue-100 dark:bg-blue-900/30"
iconColor="text-blue-600 dark:text-blue-400"
subtitle="Enterprise clients"
/>
<StatsCard
title="Active"
value={stats.active}
icon={CheckCircle2}
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
iconColor="text-emerald-600 dark:text-emerald-400"
subtitle="Currently active"
/>
<StatsCard
title="Total Servers"
value={stats.totalServers}
icon={Server}
iconBg="bg-violet-100 dark:bg-violet-900/30"
iconColor="text-violet-600 dark:text-violet-400"
subtitle="Across all clients"
/>
<StatsCard
title="Open Errors"
value={stats.totalErrors}
icon={AlertTriangle}
iconBg={stats.totalErrors > 0 ? "bg-red-100 dark:bg-red-900/30" : "bg-slate-100 dark:bg-slate-900/30"}
iconColor={stats.totalErrors > 0 ? "text-red-600 dark:text-red-400" : "text-slate-600 dark:text-slate-400"}
subtitle="Unacknowledged"
/>
</div>
{/* System Health Widget */}
<div className="lg:col-span-1">
<EnterpriseErrorSummaryWidget />
</div>
</div>
{/* Client list */}
<Card>
<CardHeader className="border-b bg-muted/30">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg">All Clients</CardTitle>
<CardDescription>
{filteredClients.length} client{filteredClients.length !== 1 ? 's' : ''} found
</CardDescription>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search clients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 w-full sm:w-72 bg-background"
/>
</div>
</div>
</CardHeader>
<CardContent className="p-4 md:p-6">
{filteredClients.length === 0 ? (
search ? (
<div className="text-center py-12">
<p className="text-muted-foreground">No clients match your search.</p>
</div>
) : (
<EmptyState onAddClient={() => setShowAddDialog(true)} />
)
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredClients.map((client) => (
<ClientCard
key={client.id}
client={client}
errorSummary={errorSummaryMap.get(client.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
{/* Add Client Dialog */}
<AddClientDialog open={showAddDialog} onOpenChange={setShowAddDialog} />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { AdminSidebar } from '@/components/admin/sidebar'
import { AdminHeader } from '@/components/admin/header'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
// Redirect to login if not authenticated
if (!session) {
redirect('/login')
}
// Redirect if not a staff member
if (session.user.userType !== 'staff') {
redirect('/')
}
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<AdminSidebar />
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
<AdminHeader />
<main className="flex-1 overflow-y-auto bg-gray-50 p-6">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,772 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
RadialBarChart,
RadialBar,
AreaChart,
Area,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
ArrowLeft,
Play,
Square,
RefreshCw,
Trash2,
Cpu,
MemoryStick,
Network,
Clock,
Container,
Loader2,
AlertCircle,
Terminal,
Info,
Activity,
Gauge,
ArrowDownToLine,
ArrowUpFromLine,
Server,
Globe,
Key,
Layers,
RotateCcw,
Copy,
Check,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
useContainerDetails,
useSingleContainerStats,
useContainerLogs,
useContainerAction,
useRemoveContainer,
type ContainerStats,
} from '@/hooks/use-portainer'
const MAX_HISTORY_POINTS = 60
interface StatsHistory {
timestamp: number
cpu: number
memory: number
memoryPercent: number
networkRx: number
networkTx: number
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function formatDate(dateStr: string | number): string {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Gauge Chart Component using Recharts
function GaugeChart({
value,
color = '#3b82f6',
bgColor = '#e2e8f0',
size = 100,
}: {
value: number
color?: string
bgColor?: string
size?: number
}) {
const data = [
{ name: 'value', value: Math.min(value, 100), fill: color },
{ name: 'background', value: 100 - Math.min(value, 100), fill: bgColor },
]
return (
<div style={{ width: size, height: size }}>
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="70%"
outerRadius="100%"
barSize={10}
data={data}
startAngle={90}
endAngle={-270}
>
<RadialBar
background={false}
dataKey="value"
cornerRadius={10}
/>
</RadialBarChart>
</ResponsiveContainer>
</div>
)
}
// Sparkline Area Chart Component using Recharts
function SparklineChart({
data,
color = '#3b82f6',
gradientId,
}: {
data: number[]
color?: string
gradientId: string
}) {
if (data.length < 2) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-xs">
Collecting data...
</div>
)
}
// Convert number array to chart data format
const chartData = data.map((value, index) => ({
index,
value,
}))
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.4} />
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)
}
// Status indicator dot
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
running: 'bg-emerald-500',
exited: 'bg-red-500',
paused: 'bg-amber-500',
restarting: 'bg-blue-500',
created: 'bg-slate-400',
}
const pulseColors: Record<string, string> = {
running: 'bg-emerald-400',
restarting: 'bg-blue-400',
}
const shouldPulse = status === 'running' || status === 'restarting'
return (
<span className="relative flex h-3 w-3">
{shouldPulse && (
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${pulseColors[status] || ''} opacity-75`} />
)}
<span className={`relative inline-flex rounded-full h-3 w-3 ${colors[status.toLowerCase()] || 'bg-slate-400'}`} />
</span>
)
}
// Copy button component
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title="Copy to clipboard"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
)
}
export default function ContainerDetailPage() {
const params = useParams()
const router = useRouter()
const orderId = params.id as string
const containerId = params.containerId as string
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [actionInProgress, setActionInProgress] = useState(false)
const [activeTab, setActiveTab] = useState<'overview' | 'logs' | 'env'>('overview')
const [logTail, setLogTail] = useState(500)
const [statsHistory, setStatsHistory] = useState<StatsHistory[]>([])
const lastStatsRef = useRef<ContainerStats | null>(null)
const { data: container, isLoading, error } = useContainerDetails(orderId, containerId)
const { data: stats } = useSingleContainerStats(orderId, containerId, container?.state === 'running')
const { data: logs, refetch: refetchLogs } = useContainerLogs(orderId, containerId, logTail)
const containerAction = useContainerAction()
const removeContainer = useRemoveContainer()
// Track stats history
useEffect(() => {
if (stats && stats !== lastStatsRef.current) {
lastStatsRef.current = stats
setStatsHistory(prev => {
const newHistory = [...prev, {
timestamp: Date.now(),
cpu: stats.cpuPercent,
memory: stats.memoryUsage,
memoryPercent: stats.memoryPercent,
networkRx: stats.networkRx,
networkTx: stats.networkTx,
}]
if (newHistory.length > MAX_HISTORY_POINTS) {
return newHistory.slice(-MAX_HISTORY_POINTS)
}
return newHistory
})
}
}, [stats])
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
setActionInProgress(true)
try {
await containerAction.mutateAsync({ orderId, containerId, action })
} catch (err) {
console.error(`Failed to ${action} container:`, err)
} finally {
setActionInProgress(false)
}
}
const handleDelete = async () => {
setActionInProgress(true)
try {
await removeContainer.mutateAsync({ orderId, containerId, force: true })
router.push(`/admin/orders/${orderId}`)
} catch (err) {
console.error('Failed to remove container:', err)
} finally {
setActionInProgress(false)
setShowDeleteConfirm(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="text-center">
<Loader2 className="h-10 w-10 animate-spin text-blue-500 mx-auto" />
<p className="mt-4 text-muted-foreground">Loading container...</p>
</div>
</div>
)
}
if (error || !container) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-6">
<div className="max-w-lg mx-auto mt-20">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-8 text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto">
<AlertCircle className="h-8 w-8 text-red-500" />
</div>
<h2 className="mt-4 text-xl font-semibold">Failed to load container</h2>
<p className="mt-2 text-muted-foreground">{error?.message || 'Container not found'}</p>
<Button variant="outline" className="mt-6" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
</div>
)
}
const cpuHistory = statsHistory.map(s => s.cpu)
const memoryHistory = statsHistory.map(s => s.memoryPercent)
const isRunning = container.state === 'running'
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
{/* Header */}
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-4">
<Link href={`/admin/orders/${orderId}`} className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Order
</Link>
</div>
<div className="pb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/25">
<Container className="h-7 w-7 text-white" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{container.name}</h1>
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100 dark:bg-slate-700">
<StatusDot status={container.state} />
<span className="text-sm font-medium capitalize">{container.state}</span>
</div>
</div>
<p className="mt-1 text-sm text-muted-foreground font-mono">{container.shortId}</p>
</div>
</div>
<div className="flex items-center gap-2">
{actionInProgress ? (
<div className="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 rounded-lg">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Processing...</span>
</div>
) : (
<>
{isRunning ? (
<Button variant="outline" size="sm" onClick={() => handleAction('stop')} className="gap-2">
<Square className="h-4 w-4" />
Stop
</Button>
) : (
<Button size="sm" onClick={() => handleAction('start')} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
<Play className="h-4 w-4" />
Start
</Button>
)}
<Button variant="outline" size="sm" onClick={() => handleAction('restart')} className="gap-2">
<RotateCcw className="h-4 w-4" />
Restart
</Button>
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(true)} className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950">
<Trash2 className="h-4 w-4" />
Remove
</Button>
</>
)}
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
{isRunning && stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* CPU Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Cpu className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">CPU Usage</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">
{stats.cpuPercent < 0.1 && stats.cpuPercent > 0
? stats.cpuPercent.toFixed(2)
: stats.cpuPercent.toFixed(1)}%
</p>
</div>
</div>
</div>
<div className="relative">
<div className="flex items-center justify-center mb-4">
<GaugeChart value={stats.cpuPercent} color="#3b82f6" bgColor="#e2e8f0" size={100} />
</div>
<div className="h-16">
<SparklineChart data={cpuHistory} color="#3b82f6" gradientId="cpu-gradient-order" />
</div>
</div>
</div>
{/* Memory Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<MemoryStick className="h-5 w-5 text-emerald-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Memory</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{stats.memoryPercent.toFixed(1)}%</p>
</div>
</div>
</div>
<div className="relative">
<div className="flex items-center justify-center mb-4">
<GaugeChart value={stats.memoryPercent} color="#10b981" bgColor="#e2e8f0" size={100} />
</div>
<p className="text-xs text-center text-muted-foreground mb-2">
{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}
</p>
<div className="h-16">
<SparklineChart data={memoryHistory} color="#10b981" gradientId="memory-gradient-order" />
</div>
</div>
</div>
{/* Network RX Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<ArrowDownToLine className="h-5 w-5 text-violet-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Network In</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{formatBytes(stats.networkRx)}</p>
</div>
</div>
<div className="flex items-center justify-center py-6">
<div className="relative">
<div className="w-20 h-20 rounded-full border-4 border-violet-100 dark:border-violet-900/30 flex items-center justify-center">
<ArrowDownToLine className="h-8 w-8 text-violet-500 animate-pulse" />
</div>
<div className="absolute -right-1 -bottom-1 w-8 h-8 rounded-full bg-violet-500 flex items-center justify-center">
<Activity className="h-4 w-4 text-white" />
</div>
</div>
</div>
</div>
{/* Network TX Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<ArrowUpFromLine className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Network Out</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{formatBytes(stats.networkTx)}</p>
</div>
</div>
<div className="flex items-center justify-center py-6">
<div className="relative">
<div className="w-20 h-20 rounded-full border-4 border-amber-100 dark:border-amber-900/30 flex items-center justify-center">
<ArrowUpFromLine className="h-8 w-8 text-amber-500 animate-pulse" />
</div>
<div className="absolute -right-1 -bottom-1 w-8 h-8 rounded-full bg-amber-500 flex items-center justify-center">
<Activity className="h-4 w-4 text-white" />
</div>
</div>
</div>
</div>
</div>
)}
{/* Not Running State */}
{!isRunning && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-2xl p-6 mb-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<AlertCircle className="h-6 w-6 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-amber-800 dark:text-amber-200">Container is not running</h3>
<p className="text-sm text-amber-600 dark:text-amber-400">Start the container to view live resource metrics</p>
</div>
<Button size="sm" onClick={() => handleAction('start')} className="ml-auto gap-2 bg-amber-600 hover:bg-amber-700">
<Play className="h-4 w-4" />
Start Container
</Button>
</div>
</div>
)}
{/* Tabs */}
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="border-b border-slate-200 dark:border-slate-700">
<nav className="flex">
{[
{ id: 'overview', label: 'Overview', icon: Info },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'env', label: 'Environment', icon: Key },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-slate-300'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Container Info */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
Container Details
</h3>
<div className="space-y-4">
<div className="flex items-start justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Image</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-right max-w-xs truncate">{container.image}</span>
<CopyButton text={container.image} />
</div>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Container ID</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono">{container.id.substring(0, 24)}...</span>
<CopyButton text={container.id} />
</div>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">{formatDate(container.created)}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Hostname</span>
<span className="text-sm font-mono">{container.config?.hostname || '-'}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Working Dir</span>
<span className="text-sm font-mono">{container.config?.workingDir || '/'}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Restart Policy</span>
<Badge variant="secondary" className="font-mono">
{container.hostConfig?.restartPolicy?.Name || 'no'}
</Badge>
</div>
</div>
</div>
</div>
{/* Networking */}
<div className="space-y-6">
{/* Ports */}
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Globe className="h-5 w-5 text-muted-foreground" />
Port Mappings
</h3>
{container.ports && container.ports.length > 0 ? (
<div className="space-y-2">
{container.ports.map((port, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Network className="h-4 w-4 text-blue-600" />
</div>
<div className="flex items-center gap-2 font-mono text-sm">
{port.public ? (
<>
<span className="text-emerald-600 font-semibold">{port.public}</span>
<span className="text-muted-foreground">:</span>
<span>{port.private}</span>
</>
) : (
<span className="text-muted-foreground">{port.private} (not published)</span>
)}
<Badge variant="outline" className="ml-2 text-xs">
{port.type}
</Badge>
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg text-center">
No ports exposed
</div>
)}
</div>
{/* Networks */}
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Layers className="h-5 w-5 text-muted-foreground" />
Networks
</h3>
{container.networks && Object.keys(container.networks).length > 0 ? (
<div className="space-y-2">
{Object.entries(container.networks).map(([name, network]) => (
<div key={name} className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
<span className="font-medium text-sm">{name}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-muted-foreground">
{network.IPAddress || 'No IP assigned'}
</span>
{network.IPAddress && <CopyButton text={network.IPAddress} />}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg text-center">
No networks attached
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Logs Tab */}
{activeTab === 'logs' && (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Terminal className="h-5 w-5 text-muted-foreground" />
Container Logs
</h3>
<div className="flex items-center gap-3">
<select
value={logTail}
onChange={(e) => setLogTail(parseInt(e.target.value, 10))}
className="h-9 px-3 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={100}>Last 100 lines</option>
<option value={500}>Last 500 lines</option>
<option value={1000}>Last 1000 lines</option>
<option value={2000}>Last 2000 lines</option>
</select>
<Button variant="outline" size="sm" onClick={() => refetchLogs()} className="gap-2">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</div>
<div className="relative rounded-xl overflow-hidden border border-slate-200 dark:border-slate-700">
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-slate-900 to-transparent z-10 pointer-events-none" />
<div className="h-[600px] overflow-auto bg-slate-900">
<pre className="p-6 text-sm font-mono text-slate-300 whitespace-pre-wrap break-all leading-relaxed">
{logs || 'No logs available'}
</pre>
</div>
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-slate-900 to-transparent pointer-events-none" />
</div>
</div>
)}
{/* Environment Tab */}
{activeTab === 'env' && (
<div className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Key className="h-5 w-5 text-muted-foreground" />
Environment Variables
<Badge variant="secondary" className="ml-2">
{container.config?.env?.length || 0}
</Badge>
</h3>
{container.config?.env && container.config.env.length > 0 ? (
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="max-h-[600px] overflow-auto">
<table className="w-full">
<thead className="bg-slate-50 dark:bg-slate-700/50 sticky top-0">
<tr>
<th className="text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider px-4 py-3">Key</th>
<th className="text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider px-4 py-3">Value</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{container.config.env.map((env, idx) => {
const [key, ...valueParts] = env.split('=')
const value = valueParts.join('=')
const isSecret = key.toLowerCase().includes('password') ||
key.toLowerCase().includes('secret') ||
key.toLowerCase().includes('key') ||
key.toLowerCase().includes('token')
return (
<tr key={idx} className="hover:bg-slate-50 dark:hover:bg-slate-700/30">
<td className="px-4 py-3">
<code className="text-sm text-blue-600 dark:text-blue-400">{key}</code>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<code className="text-sm text-slate-600 dark:text-slate-300 break-all">
{isSecret ? '••••••••' : value || <span className="text-muted-foreground italic">empty</span>}
</code>
{!isSecret && value && <CopyButton text={value} />}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground p-8 bg-slate-50 dark:bg-slate-700/50 rounded-xl text-center">
No environment variables configured
</div>
)}
</div>
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mx-auto mb-4">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<DialogTitle className="text-center">Remove Container</DialogTitle>
<DialogDescription className="text-center">
Are you sure you want to remove <span className="font-semibold">{container.name}</span>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-center gap-2 mt-4">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={actionInProgress}>
{actionInProgress && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Remove Container
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,519 @@
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
OrderKanban,
OrderPipelineCompact,
} from '@/components/admin/order-kanban'
import { CreateOrderDialog } from '@/components/admin/create-order-dialog'
import type { Order as OrderCardType, OrderStatus, OrderTier } from '@/components/admin/order-card'
import { useOrders } from '@/hooks/use-orders'
import { OrderStatus as ApiOrderStatus, SubscriptionTier } from '@/types/api'
import {
Search,
Filter,
RefreshCw,
LayoutGrid,
List,
Download,
Plus,
Loader2,
AlertCircle,
Package,
X,
Sparkles,
} from 'lucide-react'
import { exportOrdersToCsv } from '@/lib/csv-export'
// View modes
type ViewMode = 'kanban' | 'list'
// Filter options
interface FilterOptions {
search: string
tier: OrderTier | 'all'
status: OrderStatus | 'all'
}
// Map API tier to component tier
function mapTier(tier: SubscriptionTier): OrderTier {
return tier === 'HUB_DASHBOARD' ? 'hub-dashboard' : 'control-panel'
}
// Map API tier back for filtering
function mapTierToApi(tier: OrderTier | 'all'): SubscriptionTier | undefined {
if (tier === 'all') return undefined
return tier === 'hub-dashboard' ? SubscriptionTier.HUB_DASHBOARD : SubscriptionTier.ADVANCED
}
// Map API order to component order format
function mapApiOrderToCardOrder(apiOrder: {
id: string
domain: string
tier: SubscriptionTier
status: ApiOrderStatus
serverIp: string | null
failureReason: string | null
createdAt: Date | string
updatedAt: Date | string
user: {
id: string
name: string | null
email: string
company: string | null
}
}): OrderCardType {
return {
id: apiOrder.id,
domain: apiOrder.domain,
customerName: apiOrder.user.name || apiOrder.user.company || apiOrder.user.email,
customerEmail: apiOrder.user.email,
tier: mapTier(apiOrder.tier),
status: apiOrder.status as OrderStatus,
createdAt: new Date(apiOrder.createdAt),
updatedAt: new Date(apiOrder.updatedAt),
serverIp: apiOrder.serverIp || undefined,
failureReason: apiOrder.failureReason || undefined,
}
}
// Tier display config
const tierConfig: Record<OrderTier | 'all', { label: string; color: string }> = {
'all': { label: 'All Tiers', color: '' },
'hub-dashboard': { label: 'Hub Dashboard', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
'control-panel': { label: 'Control Panel', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
}
// Status display config
const statusConfig: Record<OrderStatus | 'all', { label: string; color: string }> = {
'all': { label: 'All Statuses', color: '' },
'PAYMENT_CONFIRMED': { label: 'Payment Confirmed', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
'AWAITING_SERVER': { label: 'Awaiting Server', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
'SERVER_READY': { label: 'Server Ready', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
'DNS_PENDING': { label: 'DNS Pending', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' },
'DNS_READY': { label: 'DNS Ready', color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' },
'PROVISIONING': { label: 'Provisioning', color: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
'FULFILLED': { label: 'Fulfilled', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
'EMAIL_CONFIGURED': { label: 'Complete', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
'FAILED': { label: 'Failed', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
}
export default function OrdersPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('kanban')
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [filters, setFilters] = useState<FilterOptions>({
search: '',
tier: 'all',
status: 'all',
})
// Fetch orders from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useOrders({
search: filters.search || undefined,
tier: mapTierToApi(filters.tier),
status: filters.status !== 'all' ? filters.status as ApiOrderStatus : undefined,
limit: 100,
})
// Map API orders to component format
const orders = useMemo(() => {
if (!data?.orders) return []
return data.orders.map(mapApiOrderToCardOrder)
}, [data?.orders])
// Check if any filters are active
const hasActiveFilters = filters.search || filters.tier !== 'all' || filters.status !== 'all'
// Handle order action
const handleOrderAction = (order: OrderCardType, action: string) => {
console.log(`Action "${action}" triggered for order:`, order)
// Navigate to order detail page for actions
router.push(`/admin/orders/${order.id}`)
}
// Handle view order details
const handleViewDetails = (order: OrderCardType) => {
router.push(`/admin/orders/${order.id}`)
}
// Handle refresh
const handleRefresh = async () => {
await refetch()
}
// Handle export
const handleExport = () => {
if (!data?.orders || data.orders.length === 0) {
alert('No orders to export')
return
}
exportOrdersToCsv(data.orders)
}
// Clear all filters
const clearFilters = () => {
setFilters({ search: '', tier: 'all', status: 'all' })
}
// Loading state
if (isLoading) {
return (
<div className="flex flex-col h-full">
{/* Hero header skeleton */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8 mb-6">
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
<div className="relative">
<div className="h-8 w-48 bg-muted/60 rounded animate-pulse mb-2" />
<div className="h-4 w-64 bg-muted/40 rounded animate-pulse" />
</div>
</div>
{/* Loading content */}
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<div className="text-center">
<p className="font-medium text-foreground">Loading orders...</p>
<p className="text-sm text-muted-foreground">Fetching your order pipeline</p>
</div>
</div>
</div>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex flex-col h-full">
{/* Hero header */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8 mb-6">
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-destructive/5 to-destructive/10 blur-3xl" />
<div className="relative">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Order Pipeline</h1>
<p className="text-muted-foreground mt-1">Manage and track customer provisioning orders</p>
</div>
</div>
{/* Error content */}
<div className="flex-1 flex items-center justify-center">
<div className="rounded-xl border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 max-w-md text-center">
<div className="mx-auto w-fit p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h3 className="font-semibold text-lg text-destructive">Failed to load orders</h3>
<p className="text-sm text-muted-foreground mt-2 mb-6">
{error instanceof Error ? error.message : 'An unexpected error occurred while fetching orders.'}
</p>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Try Again'}
</Button>
</div>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full space-y-6">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decoration */}
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Title section */}
<div className="flex items-center gap-4">
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
<Package className="h-7 w-7 text-primary" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Order Pipeline</h1>
<p className="text-muted-foreground mt-0.5">Manage and track customer provisioning orders</p>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleExport}
className="gap-2 bg-background/50 hover:bg-background"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Export</span>
</Button>
<Button
size="sm"
onClick={() => setIsCreateDialogOpen(true)}
className="gap-2 shadow-lg shadow-primary/20"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New Order</span>
</Button>
</div>
</div>
</div>
{/* Toolbar */}
<div className="rounded-xl border bg-card/50 p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* Search and filters */}
<div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
{/* Search input */}
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by domain, customer..."
value={filters.search}
onChange={(e) =>
setFilters((prev) => ({ ...prev, search: e.target.value }))
}
className="pl-9 bg-background border-muted-foreground/20 focus:border-primary"
/>
</div>
{/* Filter dropdowns */}
<div className="flex items-center gap-2">
{/* Tier filter */}
<div className="relative">
<select
value={filters.tier}
onChange={(e) =>
setFilters((prev) => ({
...prev,
tier: e.target.value as OrderTier | 'all',
}))
}
className="h-10 appearance-none rounded-lg border border-muted-foreground/20 bg-background pl-3 pr-8 text-sm font-medium ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 cursor-pointer hover:border-muted-foreground/40 transition-colors"
>
<option value="all">All Tiers</option>
<option value="hub-dashboard">Hub Dashboard</option>
<option value="control-panel">Control Panel</option>
</select>
<Filter className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
</div>
{/* Status filter */}
<div className="relative">
<select
value={filters.status}
onChange={(e) =>
setFilters((prev) => ({
...prev,
status: e.target.value as OrderStatus | 'all',
}))
}
className="h-10 appearance-none rounded-lg border border-muted-foreground/20 bg-background pl-3 pr-8 text-sm font-medium ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 cursor-pointer hover:border-muted-foreground/40 transition-colors"
>
<option value="all">All Statuses</option>
<option value="PAYMENT_CONFIRMED">Payment Confirmed</option>
<option value="AWAITING_SERVER">Awaiting Server</option>
<option value="SERVER_READY">Server Ready</option>
<option value="DNS_PENDING">DNS Pending</option>
<option value="DNS_READY">DNS Ready</option>
<option value="PROVISIONING">Provisioning</option>
<option value="FULFILLED">Fulfilled</option>
<option value="EMAIL_CONFIGURED">Complete</option>
<option value="FAILED">Failed</option>
</select>
<Filter className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
</div>
</div>
</div>
{/* View controls */}
<div className="flex items-center gap-3">
{/* Refresh button */}
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isFetching}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
/>
<span className="hidden sm:inline">Refresh</span>
</Button>
{/* View toggle */}
<div className="flex items-center p-1 bg-muted/50 rounded-lg">
<button
onClick={() => setViewMode('kanban')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
viewMode === 'kanban'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<LayoutGrid className="h-4 w-4" />
<span className="hidden sm:inline">Kanban</span>
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
viewMode === 'list'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<List className="h-4 w-4" />
<span className="hidden sm:inline">List</span>
</button>
</div>
</div>
</div>
</div>
{/* Active filters indicator */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4" />
<span>
Showing <span className="font-semibold text-foreground">{orders.length}</span> orders
{data?.pagination && (
<span> of <span className="font-semibold text-foreground">{data.pagination.total}</span></span>
)}
</span>
</div>
{/* Filter badges */}
{filters.search && (
<Badge variant="secondary" className="gap-1.5 pl-2 pr-1 py-1">
<span>Search: {filters.search}</span>
<button
onClick={() => setFilters(prev => ({ ...prev, search: '' }))}
className="ml-1 rounded-full p-0.5 hover:bg-muted-foreground/20"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
{filters.tier !== 'all' && (
<Badge
variant="secondary"
className={`gap-1.5 pl-2 pr-1 py-1 ${tierConfig[filters.tier].color}`}
>
<span>{tierConfig[filters.tier].label}</span>
<button
onClick={() => setFilters(prev => ({ ...prev, tier: 'all' }))}
className="ml-1 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
{filters.status !== 'all' && (
<Badge
variant="secondary"
className={`gap-1.5 pl-2 pr-1 py-1 ${statusConfig[filters.status].color}`}
>
<span>{statusConfig[filters.status].label}</span>
<button
onClick={() => setFilters(prev => ({ ...prev, status: 'all' }))}
className="ml-1 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
{/* Clear all button */}
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</Button>
</div>
)}
{/* Empty state */}
{orders.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/20 p-12 max-w-md text-center">
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
<Package className="h-10 w-10 text-muted-foreground/60" />
</div>
<h3 className="font-semibold text-lg">No orders found</h3>
<p className="text-sm text-muted-foreground mt-2">
{hasActiveFilters
? "No orders match your current filters. Try adjusting your search criteria."
: "Get started by creating your first order."
}
</p>
{hasActiveFilters ? (
<Button
variant="outline"
onClick={clearFilters}
className="mt-6 gap-2"
>
<X className="h-4 w-4" />
Clear filters
</Button>
) : (
<Button
onClick={() => setIsCreateDialogOpen(true)}
className="mt-6 gap-2"
>
<Plus className="h-4 w-4" />
Create your first order
</Button>
)}
</div>
</div>
)}
{/* Main content */}
{orders.length > 0 && (
<div className="flex-1 min-h-0">
{viewMode === 'kanban' ? (
<OrderKanban
orders={orders}
onAction={handleOrderAction}
onViewDetails={handleViewDetails}
/>
) : (
<OrderPipelineCompact
orders={orders}
onViewDetails={handleViewDetails}
/>
)}
</div>
)}
{/* Create Order Dialog */}
<CreateOrderDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onSuccess={() => refetch()}
/>
</div>
)
}

View File

@@ -0,0 +1,478 @@
'use client'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useDashboardStats } from '@/hooks/use-stats'
import {
ShoppingCart,
Users,
Server,
TrendingUp,
Clock,
CheckCircle,
AlertCircle,
ArrowRight,
Loader2,
RefreshCw,
LayoutDashboard,
Activity,
} from 'lucide-react'
// Enhanced stats card with icon backgrounds and hover effects
function StatsCard({
title,
value,
description,
icon: Icon,
isLoading,
iconBgColor = 'bg-blue-100 dark:bg-blue-900/30',
iconColor = 'text-blue-600 dark:text-blue-400',
}: {
title: string
value: string | number
description: string
icon: React.ElementType
isLoading?: boolean
iconBgColor?: string
iconColor?: string
}) {
return (
<Card className="rounded-xl border bg-gradient-to-br from-card to-muted/20 hover:shadow-lg hover:border-muted-foreground/20 transition-all duration-200">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<div className={`p-2.5 rounded-lg ${iconBgColor}`}>
<Icon className={`h-4 w-4 ${iconColor}`} />
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
) : (
<div className="text-3xl font-bold tracking-tight tabular-nums">{value}</div>
)}
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</CardContent>
</Card>
)
}
// Enhanced order status badge with dot indicator
function OrderStatusBadge({ status }: { status: string }) {
const statusConfig: Record<string, {
label: string
bgColor: string
textColor: string
borderColor: string
dotColor: string
pulse?: boolean
}> = {
PAYMENT_CONFIRMED: {
label: 'Payment Confirmed',
bgColor: 'bg-blue-50 dark:bg-blue-950/30',
textColor: 'text-blue-700 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-800',
dotColor: 'bg-blue-500'
},
AWAITING_SERVER: {
label: 'Awaiting Server',
bgColor: 'bg-amber-50 dark:bg-amber-950/30',
textColor: 'text-amber-700 dark:text-amber-400',
borderColor: 'border-amber-200 dark:border-amber-800',
dotColor: 'bg-amber-500',
pulse: true
},
SERVER_READY: {
label: 'Server Ready',
bgColor: 'bg-purple-50 dark:bg-purple-950/30',
textColor: 'text-purple-700 dark:text-purple-400',
borderColor: 'border-purple-200 dark:border-purple-800',
dotColor: 'bg-purple-500'
},
DNS_PENDING: {
label: 'DNS Pending',
bgColor: 'bg-orange-50 dark:bg-orange-950/30',
textColor: 'text-orange-700 dark:text-orange-400',
borderColor: 'border-orange-200 dark:border-orange-800',
dotColor: 'bg-orange-500',
pulse: true
},
DNS_READY: {
label: 'DNS Ready',
bgColor: 'bg-cyan-50 dark:bg-cyan-950/30',
textColor: 'text-cyan-700 dark:text-cyan-400',
borderColor: 'border-cyan-200 dark:border-cyan-800',
dotColor: 'bg-cyan-500'
},
PROVISIONING: {
label: 'Provisioning',
bgColor: 'bg-indigo-50 dark:bg-indigo-950/30',
textColor: 'text-indigo-700 dark:text-indigo-400',
borderColor: 'border-indigo-200 dark:border-indigo-800',
dotColor: 'bg-indigo-500',
pulse: true
},
FULFILLED: {
label: 'Fulfilled',
bgColor: 'bg-emerald-50 dark:bg-emerald-950/30',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800',
dotColor: 'bg-emerald-500'
},
EMAIL_CONFIGURED: {
label: 'Complete',
bgColor: 'bg-emerald-50 dark:bg-emerald-950/30',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800',
dotColor: 'bg-emerald-500'
},
FAILED: {
label: 'Failed',
bgColor: 'bg-red-50 dark:bg-red-950/30',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800',
dotColor: 'bg-red-500'
},
}
const config = statusConfig[status] || {
label: status,
bgColor: 'bg-slate-50 dark:bg-slate-950/30',
textColor: 'text-slate-700 dark:text-slate-400',
borderColor: 'border-slate-200 dark:border-slate-800',
dotColor: 'bg-slate-500'
}
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border ${config.bgColor} ${config.textColor} ${config.borderColor}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${config.dotColor} ${config.pulse ? 'animate-pulse' : ''}`} />
{config.label}
</span>
)
}
// Format time since
function formatTimeSince(date: Date | string): string {
const now = new Date()
const then = new Date(date)
const diffMs = now.getTime() - then.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return then.toLocaleDateString()
}
// Recent orders component with enhanced styling
function RecentOrders({ orders, isLoading }: {
orders: Array<{
id: string
domain: string
status: string
createdAt: Date | string
user: { name: string | null; email: string; company: string | null }
}>
isLoading: boolean
}) {
return (
<Card className="col-span-2 rounded-xl border bg-gradient-to-br from-card to-muted/10">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<CardTitle className="text-lg tracking-tight">Recent Orders</CardTitle>
<CardDescription>Latest customer provisioning orders</CardDescription>
</div>
</div>
<Link href="/admin/orders">
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground hover:text-foreground">
View All <ArrowRight className="h-4 w-4" />
</Button>
</Link>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center justify-between rounded-xl border bg-card p-4">
<div className="space-y-2">
<div className="h-4 w-36 bg-muted animate-pulse rounded" />
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
</div>
<div className="h-6 w-24 bg-muted animate-pulse rounded-full" />
</div>
))}
</div>
) : orders.length === 0 ? (
<div className="text-center py-12 rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/20">
<ShoppingCart className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground font-medium">No orders yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Orders will appear here when customers place them
</p>
</div>
) : (
<div className="space-y-3">
{orders.map((order) => (
<Link
key={order.id}
href={`/admin/orders/${order.id}`}
className="group flex items-center justify-between rounded-xl border bg-card p-4 hover:bg-muted/30 hover:border-muted-foreground/20 hover:shadow-md transition-all"
>
<div className="space-y-1">
<p className="font-medium group-hover:text-primary transition-colors">{order.domain}</p>
<p className="text-sm text-muted-foreground">
{order.user.name || order.user.company || order.user.email}
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground tabular-nums">
{formatTimeSince(order.createdAt)}
</span>
<OrderStatusBadge status={order.status} />
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
)
}
// Enhanced pipeline overview component
function PipelineOverview({ stats, isLoading }: {
stats: {
pending: number
inProgress: number
completed: number
failed: number
} | null
isLoading: boolean
}) {
const stages = [
{
name: 'Payment & Server',
count: stats?.pending || 0,
icon: Clock,
iconBgColor: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
barColor: 'bg-amber-500'
},
{
name: 'Provisioning',
count: stats?.inProgress || 0,
icon: TrendingUp,
iconBgColor: 'bg-indigo-100 dark:bg-indigo-900/30',
iconColor: 'text-indigo-600 dark:text-indigo-400',
barColor: 'bg-indigo-500'
},
{
name: 'Completed',
count: stats?.completed || 0,
icon: CheckCircle,
iconBgColor: 'bg-emerald-100 dark:bg-emerald-900/30',
iconColor: 'text-emerald-600 dark:text-emerald-400',
barColor: 'bg-emerald-500'
},
{
name: 'Failed',
count: stats?.failed || 0,
icon: AlertCircle,
iconBgColor: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
barColor: 'bg-red-500'
},
]
const total = stages.reduce((acc, stage) => acc + stage.count, 0)
return (
<Card className="rounded-xl border bg-gradient-to-br from-card to-muted/10">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
<Activity className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<CardTitle className="text-lg tracking-tight">Order Pipeline</CardTitle>
<CardDescription>Orders by current stage</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stages.map((stage) => {
const percentage = total > 0 ? (stage.count / total) * 100 : 0
return (
<div key={stage.name} className="group">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${stage.iconBgColor} transition-transform group-hover:scale-110`}>
<stage.icon className={`h-4 w-4 ${stage.iconColor}`} />
</div>
<span className="text-sm font-medium">{stage.name}</span>
</div>
{isLoading ? (
<div className="h-5 w-10 bg-muted animate-pulse rounded" />
) : (
<span className="font-bold tabular-nums text-lg">{stage.count}</span>
)}
</div>
{!isLoading && total > 0 && (
<div className="h-1.5 bg-muted/60 rounded-full overflow-hidden ml-11">
<div
className={`h-full ${stage.barColor} transition-all duration-500 ease-out rounded-full`}
style={{ width: `${percentage}%` }}
/>
</div>
)}
</div>
)
})}
</div>
<div className="mt-6 pt-4 border-t">
<Link href="/admin/orders">
<Button variant="outline" className="w-full gap-2 hover:bg-muted/50">
<TrendingUp className="h-4 w-4" />
View Pipeline
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
export default function AdminDashboard() {
const { data: stats, isLoading, isError, refetch, isFetching } = useDashboardStats()
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center p-8 rounded-xl border-2 border-dashed border-destructive/20 bg-destructive/5">
<div className="p-4 rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<div>
<p className="font-semibold text-destructive text-lg">Failed to load dashboard</p>
<p className="text-sm text-muted-foreground mt-1">Could not fetch statistics</p>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Retry'}
</Button>
</div>
</div>
)
}
return (
<div className="space-y-8">
{/* Hero Header Section */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decorative elements */}
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-primary/5 to-transparent blur-2xl" />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
<LayoutDashboard className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-1">
Overview of your LetsBe Hub platform
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="shrink-0 gap-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
{/* Stats grid with enhanced cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Orders"
value={stats?.orders.total || 0}
description="All time orders"
icon={ShoppingCart}
isLoading={isLoading}
iconBgColor="bg-blue-100 dark:bg-blue-900/30"
iconColor="text-blue-600 dark:text-blue-400"
/>
<StatsCard
title="Active Customers"
value={stats?.customers.active || 0}
description="Verified customers"
icon={Users}
isLoading={isLoading}
iconBgColor="bg-violet-100 dark:bg-violet-900/30"
iconColor="text-violet-600 dark:text-violet-400"
/>
<StatsCard
title="Completed Deployments"
value={stats?.orders.completed || 0}
description="Successfully provisioned"
icon={Server}
isLoading={isLoading}
iconBgColor="bg-emerald-100 dark:bg-emerald-900/30"
iconColor="text-emerald-600 dark:text-emerald-400"
/>
<StatsCard
title="Pending Actions"
value={stats?.orders.pending || 0}
description="Orders needing attention"
icon={Clock}
isLoading={isLoading}
iconBgColor="bg-amber-100 dark:bg-amber-900/30"
iconColor="text-amber-600 dark:text-amber-400"
/>
</div>
{/* Main content grid */}
<div className="grid gap-6 lg:grid-cols-3">
<RecentOrders
orders={stats?.recentOrders || []}
isLoading={isLoading}
/>
<PipelineOverview
stats={stats?.orders ? {
pending: stats.orders.pending,
inProgress: stats.orders.inProgress,
completed: stats.orders.completed,
failed: stats.orders.failed,
} : null}
isLoading={isLoading}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,256 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { useProfile, useUpdateProfile } from '@/hooks/use-profile'
import { ProfilePhotoUpload } from '@/components/profile/profile-photo-upload'
import { PasswordChangeDialog } from '@/components/profile/password-change-dialog'
import { TwoFactorSettings } from '@/components/settings/two-factor-settings'
import {
User,
Mail,
Shield,
Key,
Loader2,
Save,
AlertCircle,
CheckCircle,
ShieldCheck,
} from 'lucide-react'
export default function ProfilePage() {
const { data: profile, isLoading, error } = useProfile()
const updateProfileMutation = useUpdateProfile()
const [name, setName] = useState('')
const [isNameDirty, setIsNameDirty] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Initialize name when profile loads
if (profile && !isNameDirty && name !== (profile.name || '')) {
setName(profile.name || '')
}
const handleNameChange = (value: string) => {
setName(value)
setIsNameDirty(true)
setSaveSuccess(false)
setSaveError(null)
}
const handleSaveName = async () => {
setSaveError(null)
setSaveSuccess(false)
try {
await updateProfileMutation.mutateAsync({ name: name.trim() })
setIsNameDirty(false)
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 3000)
} catch (err) {
if (err && typeof err === 'object' && 'data' in err) {
const apiError = err as { data?: { error?: string } }
setSaveError(apiError.data?.error || 'Failed to save profile')
} else {
setSaveError(err instanceof Error ? err.message : 'Failed to save profile')
}
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (error || !profile) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<AlertCircle className="h-12 w-12 text-destructive" />
<p className="text-lg text-muted-foreground">Failed to load profile</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">My Profile</h1>
<p className="text-muted-foreground">
Manage your account settings and security preferences
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Profile Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
Profile Information
</CardTitle>
<CardDescription>
Your basic profile details and photo
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Photo Upload */}
<ProfilePhotoUpload
currentPhotoUrl={profile.profilePhotoUrl}
name={profile.name}
email={profile.email}
/>
<Separator />
{/* Name Field */}
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
Display Name
{isNameDirty && (
<Badge variant="secondary" className="text-xs">Modified</Badge>
)}
</Label>
<div className="flex gap-2">
<Input
id="name"
placeholder="Enter your name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
disabled={updateProfileMutation.isPending}
/>
<Button
onClick={handleSaveName}
disabled={!isNameDirty || updateProfileMutation.isPending}
className="gap-2"
>
{updateProfileMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save
</Button>
</div>
{saveSuccess && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle className="h-4 w-4" />
Name saved successfully
</p>
)}
{saveError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{saveError}
</p>
)}
</div>
<Separator />
{/* Email (Read-only) */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
Email Address
</Label>
<Input
value={profile.email}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Email cannot be changed
</p>
</div>
{/* Role (Read-only) */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
Role
</Label>
<div>
<Badge variant="outline" className="text-sm">
{profile.role}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Security */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
Security
</CardTitle>
<CardDescription>
Manage your password and two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Password */}
<div className="space-y-3">
<Label className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
Password
</Label>
<p className="text-sm text-muted-foreground">
Keep your account secure with a strong password
</p>
<Button
variant="outline"
onClick={() => setShowPasswordDialog(true)}
className="w-full sm:w-auto"
>
Change Password
</Button>
</div>
<Separator />
{/* Two-Factor Authentication */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
Two-Factor Authentication
</Label>
{profile.twoFactorEnabled ? (
<Badge variant="default" className="bg-green-600 hover:bg-green-600">
Enabled
</Badge>
) : (
<Badge variant="secondary">
Disabled
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
Add an extra layer of security to your account using an authenticator app
</p>
<TwoFactorSettings />
</div>
</CardContent>
</Card>
</div>
{/* Password Change Dialog */}
<PasswordChangeDialog
open={showPasswordDialog}
onOpenChange={setShowPasswordDialog}
/>
</div>
)
}

View File

@@ -0,0 +1,772 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
RadialBarChart,
RadialBar,
AreaChart,
Area,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
ArrowLeft,
Play,
Square,
RefreshCw,
Trash2,
Cpu,
MemoryStick,
Network,
Clock,
Container,
Loader2,
AlertCircle,
Terminal,
Info,
Activity,
Gauge,
ArrowDownToLine,
ArrowUpFromLine,
Server,
Globe,
Key,
Layers,
RotateCcw,
Copy,
Check,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
useContainerDetails,
useSingleContainerStats,
useContainerLogs,
useContainerAction,
useRemoveContainer,
type ContainerStats,
} from '@/hooks/use-portainer'
const MAX_HISTORY_POINTS = 60
interface StatsHistory {
timestamp: number
cpu: number
memory: number
memoryPercent: number
networkRx: number
networkTx: number
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function formatDate(dateStr: string | number): string {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Gauge Chart Component using Recharts
function GaugeChart({
value,
color = '#3b82f6',
bgColor = '#e2e8f0',
size = 100,
}: {
value: number
color?: string
bgColor?: string
size?: number
}) {
const data = [
{ name: 'value', value: Math.min(value, 100), fill: color },
{ name: 'background', value: 100 - Math.min(value, 100), fill: bgColor },
]
return (
<div style={{ width: size, height: size }}>
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="70%"
outerRadius="100%"
barSize={10}
data={data}
startAngle={90}
endAngle={-270}
>
<RadialBar
background={false}
dataKey="value"
cornerRadius={10}
/>
</RadialBarChart>
</ResponsiveContainer>
</div>
)
}
// Sparkline Area Chart Component using Recharts
function SparklineChart({
data,
color = '#3b82f6',
gradientId,
}: {
data: number[]
color?: string
gradientId: string
}) {
if (data.length < 2) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-xs">
Collecting data...
</div>
)
}
// Convert number array to chart data format
const chartData = data.map((value, index) => ({
index,
value,
}))
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.4} />
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)
}
// Status indicator dot
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
running: 'bg-emerald-500',
exited: 'bg-red-500',
paused: 'bg-amber-500',
restarting: 'bg-blue-500',
created: 'bg-slate-400',
}
const pulseColors: Record<string, string> = {
running: 'bg-emerald-400',
restarting: 'bg-blue-400',
}
const shouldPulse = status === 'running' || status === 'restarting'
return (
<span className="relative flex h-3 w-3">
{shouldPulse && (
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${pulseColors[status] || ''} opacity-75`} />
)}
<span className={`relative inline-flex rounded-full h-3 w-3 ${colors[status.toLowerCase()] || 'bg-slate-400'}`} />
</span>
)
}
// Copy button component
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title="Copy to clipboard"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
)
}
export default function ContainerDetailPage() {
const params = useParams()
const router = useRouter()
const serverId = params.id as string
const containerId = params.containerId as string
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [actionInProgress, setActionInProgress] = useState(false)
const [activeTab, setActiveTab] = useState<'overview' | 'logs' | 'env'>('overview')
const [logTail, setLogTail] = useState(500)
const [statsHistory, setStatsHistory] = useState<StatsHistory[]>([])
const lastStatsRef = useRef<ContainerStats | null>(null)
const { data: container, isLoading, error } = useContainerDetails(serverId, containerId)
const { data: stats } = useSingleContainerStats(serverId, containerId, container?.state === 'running')
const { data: logs, refetch: refetchLogs } = useContainerLogs(serverId, containerId, logTail)
const containerAction = useContainerAction()
const removeContainer = useRemoveContainer()
// Track stats history
useEffect(() => {
if (stats && stats !== lastStatsRef.current) {
lastStatsRef.current = stats
setStatsHistory(prev => {
const newHistory = [...prev, {
timestamp: Date.now(),
cpu: stats.cpuPercent,
memory: stats.memoryUsage,
memoryPercent: stats.memoryPercent,
networkRx: stats.networkRx,
networkTx: stats.networkTx,
}]
if (newHistory.length > MAX_HISTORY_POINTS) {
return newHistory.slice(-MAX_HISTORY_POINTS)
}
return newHistory
})
}
}, [stats])
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
setActionInProgress(true)
try {
await containerAction.mutateAsync({ orderId: serverId, containerId, action })
} catch (err) {
console.error(`Failed to ${action} container:`, err)
} finally {
setActionInProgress(false)
}
}
const handleDelete = async () => {
setActionInProgress(true)
try {
await removeContainer.mutateAsync({ orderId: serverId, containerId, force: true })
router.push(`/admin/servers/${serverId}`)
} catch (err) {
console.error('Failed to remove container:', err)
} finally {
setActionInProgress(false)
setShowDeleteConfirm(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="text-center">
<Loader2 className="h-10 w-10 animate-spin text-blue-500 mx-auto" />
<p className="mt-4 text-muted-foreground">Loading container...</p>
</div>
</div>
)
}
if (error || !container) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-6">
<div className="max-w-lg mx-auto mt-20">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-8 text-center">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto">
<AlertCircle className="h-8 w-8 text-red-500" />
</div>
<h2 className="mt-4 text-xl font-semibold">Failed to load container</h2>
<p className="mt-2 text-muted-foreground">{error?.message || 'Container not found'}</p>
<Button variant="outline" className="mt-6" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
</div>
)
}
const cpuHistory = statsHistory.map(s => s.cpu)
const memoryHistory = statsHistory.map(s => s.memoryPercent)
const isRunning = container.state === 'running'
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
{/* Header */}
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-4">
<Link href={`/admin/servers/${serverId}`} className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Server
</Link>
</div>
<div className="pb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/25">
<Container className="h-7 w-7 text-white" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{container.name}</h1>
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100 dark:bg-slate-700">
<StatusDot status={container.state} />
<span className="text-sm font-medium capitalize">{container.state}</span>
</div>
</div>
<p className="mt-1 text-sm text-muted-foreground font-mono">{container.shortId}</p>
</div>
</div>
<div className="flex items-center gap-2">
{actionInProgress ? (
<div className="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 rounded-lg">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Processing...</span>
</div>
) : (
<>
{isRunning ? (
<Button variant="outline" size="sm" onClick={() => handleAction('stop')} className="gap-2">
<Square className="h-4 w-4" />
Stop
</Button>
) : (
<Button size="sm" onClick={() => handleAction('start')} className="gap-2 bg-emerald-600 hover:bg-emerald-700">
<Play className="h-4 w-4" />
Start
</Button>
)}
<Button variant="outline" size="sm" onClick={() => handleAction('restart')} className="gap-2">
<RotateCcw className="h-4 w-4" />
Restart
</Button>
<Button variant="outline" size="sm" onClick={() => setShowDeleteConfirm(true)} className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950">
<Trash2 className="h-4 w-4" />
Remove
</Button>
</>
)}
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
{isRunning && stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* CPU Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Cpu className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">CPU Usage</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">
{stats.cpuPercent < 0.1 && stats.cpuPercent > 0
? stats.cpuPercent.toFixed(2)
: stats.cpuPercent.toFixed(1)}%
</p>
</div>
</div>
</div>
<div className="relative">
<div className="flex items-center justify-center mb-4">
<GaugeChart value={stats.cpuPercent} color="#3b82f6" bgColor="#e2e8f0" size={100} />
</div>
<div className="h-16">
<SparklineChart data={cpuHistory} color="#3b82f6" gradientId="cpu-gradient" />
</div>
</div>
</div>
{/* Memory Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<MemoryStick className="h-5 w-5 text-emerald-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Memory</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{stats.memoryPercent.toFixed(1)}%</p>
</div>
</div>
</div>
<div className="relative">
<div className="flex items-center justify-center mb-4">
<GaugeChart value={stats.memoryPercent} color="#10b981" bgColor="#e2e8f0" size={100} />
</div>
<p className="text-xs text-center text-muted-foreground mb-2">
{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}
</p>
<div className="h-16">
<SparklineChart data={memoryHistory} color="#10b981" gradientId="memory-gradient" />
</div>
</div>
</div>
{/* Network RX Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<ArrowDownToLine className="h-5 w-5 text-violet-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Network In</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{formatBytes(stats.networkRx)}</p>
</div>
</div>
<div className="flex items-center justify-center py-6">
<div className="relative">
<div className="w-20 h-20 rounded-full border-4 border-violet-100 dark:border-violet-900/30 flex items-center justify-center">
<ArrowDownToLine className="h-8 w-8 text-violet-500 animate-pulse" />
</div>
<div className="absolute -right-1 -bottom-1 w-8 h-8 rounded-full bg-violet-500 flex items-center justify-center">
<Activity className="h-4 w-4 text-white" />
</div>
</div>
</div>
</div>
{/* Network TX Card */}
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<ArrowUpFromLine className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Network Out</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{formatBytes(stats.networkTx)}</p>
</div>
</div>
<div className="flex items-center justify-center py-6">
<div className="relative">
<div className="w-20 h-20 rounded-full border-4 border-amber-100 dark:border-amber-900/30 flex items-center justify-center">
<ArrowUpFromLine className="h-8 w-8 text-amber-500 animate-pulse" />
</div>
<div className="absolute -right-1 -bottom-1 w-8 h-8 rounded-full bg-amber-500 flex items-center justify-center">
<Activity className="h-4 w-4 text-white" />
</div>
</div>
</div>
</div>
</div>
)}
{/* Not Running State */}
{!isRunning && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-2xl p-6 mb-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<AlertCircle className="h-6 w-6 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-amber-800 dark:text-amber-200">Container is not running</h3>
<p className="text-sm text-amber-600 dark:text-amber-400">Start the container to view live resource metrics</p>
</div>
<Button size="sm" onClick={() => handleAction('start')} className="ml-auto gap-2 bg-amber-600 hover:bg-amber-700">
<Play className="h-4 w-4" />
Start Container
</Button>
</div>
</div>
)}
{/* Tabs */}
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="border-b border-slate-200 dark:border-slate-700">
<nav className="flex">
{[
{ id: 'overview', label: 'Overview', icon: Info },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'env', label: 'Environment', icon: Key },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-slate-300'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Container Info */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
Container Details
</h3>
<div className="space-y-4">
<div className="flex items-start justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Image</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-right max-w-xs truncate">{container.image}</span>
<CopyButton text={container.image} />
</div>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Container ID</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono">{container.id.substring(0, 24)}...</span>
<CopyButton text={container.id} />
</div>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">{formatDate(container.created)}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Hostname</span>
<span className="text-sm font-mono">{container.config?.hostname || '-'}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<span className="text-sm text-muted-foreground">Working Dir</span>
<span className="text-sm font-mono">{container.config?.workingDir || '/'}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Restart Policy</span>
<Badge variant="secondary" className="font-mono">
{container.hostConfig?.restartPolicy?.Name || 'no'}
</Badge>
</div>
</div>
</div>
</div>
{/* Networking */}
<div className="space-y-6">
{/* Ports */}
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Globe className="h-5 w-5 text-muted-foreground" />
Port Mappings
</h3>
{container.ports && container.ports.length > 0 ? (
<div className="space-y-2">
{container.ports.map((port, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Network className="h-4 w-4 text-blue-600" />
</div>
<div className="flex items-center gap-2 font-mono text-sm">
{port.public ? (
<>
<span className="text-emerald-600 font-semibold">{port.public}</span>
<span className="text-muted-foreground">:</span>
<span>{port.private}</span>
</>
) : (
<span className="text-muted-foreground">{port.private} (not published)</span>
)}
<Badge variant="outline" className="ml-2 text-xs">
{port.type}
</Badge>
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg text-center">
No ports exposed
</div>
)}
</div>
{/* Networks */}
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Layers className="h-5 w-5 text-muted-foreground" />
Networks
</h3>
{container.networks && Object.keys(container.networks).length > 0 ? (
<div className="space-y-2">
{Object.entries(container.networks).map(([name, network]) => (
<div key={name} className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
<span className="font-medium text-sm">{name}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-muted-foreground">
{network.IPAddress || 'No IP assigned'}
</span>
{network.IPAddress && <CopyButton text={network.IPAddress} />}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg text-center">
No networks attached
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Logs Tab */}
{activeTab === 'logs' && (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Terminal className="h-5 w-5 text-muted-foreground" />
Container Logs
</h3>
<div className="flex items-center gap-3">
<select
value={logTail}
onChange={(e) => setLogTail(parseInt(e.target.value, 10))}
className="h-9 px-3 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={100}>Last 100 lines</option>
<option value={500}>Last 500 lines</option>
<option value={1000}>Last 1000 lines</option>
<option value={2000}>Last 2000 lines</option>
</select>
<Button variant="outline" size="sm" onClick={() => refetchLogs()} className="gap-2">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</div>
<div className="relative rounded-xl overflow-hidden border border-slate-200 dark:border-slate-700">
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-slate-900 to-transparent z-10 pointer-events-none" />
<div className="h-[600px] overflow-auto bg-slate-900">
<pre className="p-6 text-sm font-mono text-slate-300 whitespace-pre-wrap break-all leading-relaxed">
{logs || 'No logs available'}
</pre>
</div>
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-slate-900 to-transparent pointer-events-none" />
</div>
</div>
)}
{/* Environment Tab */}
{activeTab === 'env' && (
<div className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Key className="h-5 w-5 text-muted-foreground" />
Environment Variables
<Badge variant="secondary" className="ml-2">
{container.config?.env?.length || 0}
</Badge>
</h3>
{container.config?.env && container.config.env.length > 0 ? (
<div className="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="max-h-[600px] overflow-auto">
<table className="w-full">
<thead className="bg-slate-50 dark:bg-slate-700/50 sticky top-0">
<tr>
<th className="text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider px-4 py-3">Key</th>
<th className="text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider px-4 py-3">Value</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{container.config.env.map((env, idx) => {
const [key, ...valueParts] = env.split('=')
const value = valueParts.join('=')
const isSecret = key.toLowerCase().includes('password') ||
key.toLowerCase().includes('secret') ||
key.toLowerCase().includes('key') ||
key.toLowerCase().includes('token')
return (
<tr key={idx} className="hover:bg-slate-50 dark:hover:bg-slate-700/30">
<td className="px-4 py-3">
<code className="text-sm text-blue-600 dark:text-blue-400">{key}</code>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<code className="text-sm text-slate-600 dark:text-slate-300 break-all">
{isSecret ? '••••••••' : value || <span className="text-muted-foreground italic">empty</span>}
</code>
{!isSecret && value && <CopyButton text={value} />}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground p-8 bg-slate-50 dark:bg-slate-700/50 rounded-xl text-center">
No environment variables configured
</div>
)}
</div>
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mx-auto mb-4">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<DialogTitle className="text-center">Remove Container</DialogTitle>
<DialogDescription className="text-center">
Are you sure you want to remove <span className="font-semibold">{container.name}</span>? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-center gap-2 mt-4">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={actionInProgress}>
{actionInProgress && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Remove Container
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,370 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useOrder } from '@/hooks/use-orders'
import { PortainerCredentialsPanel } from '@/components/admin/portainer-credentials-panel'
import { ContainerList } from '@/components/admin/container-list'
import { ServerQuickActions } from '@/components/admin/server-quick-actions'
import { NetcupServerLink } from '@/components/admin/netcup-server-link'
import { OrderStatus, SubscriptionTier } from '@/types/api'
import {
ArrowLeft,
Server,
Globe,
User,
Calendar,
ExternalLink,
Loader2,
AlertCircle,
RefreshCw,
FileText,
Package,
CheckCircle,
XCircle,
Clock,
} from 'lucide-react'
// Status badge component
function StatusBadge({ status }: { status: string }) {
const statusConfig: Record<string, { label: string; className: string; icon: typeof CheckCircle }> = {
online: {
label: 'Online',
className: 'bg-emerald-100 text-emerald-800 border-emerald-200',
icon: CheckCircle,
},
provisioning: {
label: 'Provisioning',
className: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Clock,
},
offline: {
label: 'Offline',
className: 'bg-red-100 text-red-800 border-red-200',
icon: XCircle,
},
pending: {
label: 'Pending',
className: 'bg-amber-100 text-amber-800 border-amber-200',
icon: Clock,
},
}
const config = statusConfig[status] || statusConfig.pending
const Icon = config.icon
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium border ${config.className}`}>
<Icon className="h-3.5 w-3.5" />
{config.label}
</span>
)
}
// Derive server status from order status
function getServerStatus(orderStatus: OrderStatus): string {
switch (orderStatus) {
case OrderStatus.FULFILLED:
case OrderStatus.EMAIL_CONFIGURED:
return 'online'
case OrderStatus.PROVISIONING:
return 'provisioning'
case OrderStatus.FAILED:
return 'offline'
default:
return 'pending'
}
}
// Tool chip component
function ToolChip({ tool }: { tool: string }) {
const getToolColor = (toolName: string) => {
const name = toolName.toLowerCase()
if (name.includes('nextcloud')) return 'bg-blue-100 text-blue-700 border-blue-200'
if (name.includes('keycloak')) return 'bg-purple-100 text-purple-700 border-purple-200'
if (name.includes('minio')) return 'bg-rose-100 text-rose-700 border-rose-200'
if (name.includes('poste')) return 'bg-emerald-100 text-emerald-700 border-emerald-200'
if (name.includes('portainer')) return 'bg-cyan-100 text-cyan-700 border-cyan-200'
return 'bg-slate-100 text-slate-700 border-slate-200'
}
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-md border ${getToolColor(tool)}`}>
{tool}
</span>
)
}
export default function ServerDetailPage() {
const params = useParams()
const router = useRouter()
const serverId = params.id as string
// Server data comes from order (servers are orders with serverIp)
const {
data: order,
isLoading,
isError,
error,
refetch,
isFetching,
} = useOrder(serverId)
const tierLabel = useMemo(() => {
if (!order) return ''
return order.tier === SubscriptionTier.HUB_DASHBOARD ? 'Hub Dashboard' : 'Control Panel'
}, [order?.tier])
const serverStatus = order ? getServerStatus(order.status) : 'pending'
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading server details...</p>
</div>
</div>
)
}
// Error state
if (isError || !order) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load server</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'Server not found'}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
</div>
)
}
// Only show for servers (orders with serverIp and provisioned status)
const provisionedStatuses: OrderStatus[] = [
OrderStatus.PROVISIONING,
OrderStatus.FULFILLED,
OrderStatus.EMAIL_CONFIGURED,
OrderStatus.FAILED,
]
const isServer = order.serverIp && provisionedStatuses.includes(order.status)
if (!isServer) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-amber-500" />
<div>
<p className="font-medium">Not a provisioned server</p>
<p className="text-sm text-muted-foreground">
This order has not been provisioned yet. View the order details to continue setup.
</p>
</div>
<Link href={`/admin/orders/${serverId}`}>
<Button>
<FileText className="h-4 w-4 mr-2" />
View Order
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/servers">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{order.domain}</h1>
<StatusBadge status={serverStatus} />
</div>
<p className="text-muted-foreground font-mono">{order.serverIp}</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Link href={`/admin/orders/${serverId}`}>
<Button variant="outline" size="sm">
<FileText className="h-4 w-4 mr-2" />
View Order
</Button>
</Link>
{order.portainerUrl && (
<Button variant="outline" size="sm" asChild>
<a href={order.portainerUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
Portainer
</a>
</Button>
)}
{order.dashboardUrl && (
<Button variant="outline" size="sm" asChild>
<a href={order.dashboardUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
Dashboard
</a>
</Button>
)}
</div>
</div>
{/* Server info cards */}
<div className="grid gap-6 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">IP Address</span>
<span className="font-mono text-sm">{order.serverIp}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">SSH Port</span>
<span className="font-mono text-sm">{order.sshPort || 22}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm">{new Date(order.createdAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Customer
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="font-medium">{order.user.name || order.user.company || 'N/A'}</p>
<p className="text-sm text-muted-foreground">{order.user.email}</p>
{order.user.company && order.user.name && (
<p className="text-sm text-muted-foreground">{order.user.company}</p>
)}
<Link href={`/admin/customers/${order.user.id}`}>
<Button variant="link" className="px-0 h-auto text-sm">
View Customer Profile
</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Domain & Tier
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="font-medium">{order.domain}</p>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary mt-2">
{tierLabel}
</span>
</div>
</CardContent>
</Card>
</div>
{/* Tools */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Installed Tools
</CardTitle>
<CardDescription>
{order.tools.length} tools deployed on this server
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{order.tools.map((tool) => (
<ToolChip key={tool} tool={tool} />
))}
</div>
</CardContent>
</Card>
{/* Netcup Server Linking & Quick Actions */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
<Server className="h-4 w-4 text-violet-600 dark:text-violet-400" />
</div>
<div>
<CardTitle className="text-lg">Netcup Server</CardTitle>
<CardDescription>Link to Netcup server for power management and rescue mode</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<NetcupServerLink
orderId={serverId}
currentNetcupServerId={order.netcupServerId}
serverIp={order.serverIp!}
onLinked={() => refetch()}
/>
{order.netcupServerId && (
<ServerQuickActions
netcupServerId={order.netcupServerId}
serverIp={order.serverIp!}
/>
)}
</CardContent>
</Card>
{/* Portainer Credentials Panel */}
<PortainerCredentialsPanel orderId={serverId} />
{/* Container List */}
<ContainerList orderId={serverId} />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,916 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Loader2,
Server,
Power,
PowerOff,
RefreshCw,
CheckCircle,
Wrench,
HardDrive,
Cpu,
MemoryStick,
ChevronDown,
ChevronUp,
Activity,
Camera,
Trash2,
RotateCcw,
Plus,
Network,
Download,
Upload,
Eye,
Globe,
Copy,
Check,
Clock,
ServerCrash,
} from 'lucide-react'
import {
useNetcupServers,
useNetcupAuth,
useNetcupPowerAction,
useNetcupRescue,
useServerMetrics,
useServerSnapshots,
useCreateSnapshot,
useDeleteSnapshot,
useRevertSnapshot,
isServerReinstalling,
PowerAction,
NetcupServer,
} from '@/hooks/use-netcup'
import { NetcupAuthSetup } from '@/components/admin/netcup-auth-setup'
const stateConfig: Record<
string,
{ label: string; bgColor: string; textColor: string; borderColor: string; dotColor: string; icon: typeof Power; animate?: boolean }
> = {
ON: { label: 'Online', bgColor: 'bg-emerald-50', textColor: 'text-emerald-700', borderColor: 'border-emerald-200', dotColor: 'bg-emerald-500', icon: CheckCircle },
OFF: { label: 'Offline', bgColor: 'bg-slate-50', textColor: 'text-slate-600', borderColor: 'border-slate-200', dotColor: 'bg-slate-400', icon: PowerOff },
POWERCYCLE: { label: 'Restarting', bgColor: 'bg-amber-50', textColor: 'text-amber-700', borderColor: 'border-amber-200', dotColor: 'bg-amber-500', icon: RefreshCw, animate: true },
RESET: { label: 'Hard Resetting', bgColor: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200', dotColor: 'bg-orange-500', icon: RefreshCw, animate: true },
POWEROFF: { label: 'Shutting Down', bgColor: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200', dotColor: 'bg-orange-500', icon: PowerOff, animate: true },
REINSTALLING: { label: 'Reinstalling', bgColor: 'bg-violet-50', textColor: 'text-violet-700', borderColor: 'border-violet-200', dotColor: 'bg-violet-500', icon: RefreshCw, animate: true },
UNKNOWN: { label: 'Unknown', bgColor: 'bg-slate-50', textColor: 'text-slate-500', borderColor: 'border-slate-200', dotColor: 'bg-slate-300', icon: Server },
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function formatBytesPerSec(bps: number): string {
return formatBytes(bps) + '/s'
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className={`inline-flex items-center justify-center h-6 w-6 rounded-md transition-all ${
copied
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
}`}
title={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
)
}
function MetricsBar({ value, max = 100, label, gradient }: {
value: number
max?: number
label: string
gradient?: string
}) {
const percentage = Math.min((value / max) * 100, 100)
const getThresholdColor = () => {
if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600'
if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500'
return gradient || 'bg-gradient-to-r from-blue-500 to-blue-600'
}
return (
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-semibold tabular-nums">{value.toFixed(1)}%</span>
</div>
<div className="h-1.5 bg-muted/60 rounded-full overflow-hidden ring-1 ring-inset ring-black/5">
<div
className={`h-full ${getThresholdColor()} transition-all duration-500 ease-out rounded-full`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
function MetricsPanel({ serverId }: { serverId: string }) {
const [hours, setHours] = useState(24)
const { data: metrics, isLoading, isFetching, error, refetch } = useServerMetrics(serverId, hours)
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="text-center py-4">
<p className="text-xs text-muted-foreground mb-2">Failed to load metrics</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching} className="h-7 text-xs">
<RefreshCw className={`h-3 w-3 mr-1.5 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Retry'}
</Button>
</div>
)
}
if (!metrics) {
return (
<div className="text-center py-4">
<Activity className="h-6 w-6 mx-auto text-muted-foreground/40 mb-2" />
<p className="text-xs text-muted-foreground">No metrics available</p>
</div>
)
}
return (
<div className="space-y-4">
{/* Period selector */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 p-0.5 bg-muted/50 rounded-md">
{[1, 6, 24, 168].map((h) => (
<Button
key={h}
variant={hours === h ? 'default' : 'ghost'}
size="sm"
className={`h-6 px-2 text-xs ${hours === h ? 'shadow-sm' : ''}`}
onClick={() => setHours(h)}
disabled={isFetching}
>
{h === 168 ? '7d' : `${h}h`}
</Button>
))}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className={`space-y-4 transition-opacity duration-200 ${isFetching ? 'opacity-60' : ''}`}>
{/* CPU */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-blue-100 dark:bg-blue-900/30">
<Cpu className="h-3 w-3 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-medium">CPU</span>
</div>
<span className="text-sm font-bold tabular-nums text-blue-600 dark:text-blue-400">
{metrics.cpu.average}%
</span>
</div>
<MetricsBar
value={metrics.cpu.average}
label={`Peak: ${metrics.cpu.max}%`}
gradient="bg-gradient-to-r from-blue-500 to-blue-600"
/>
</div>
{/* Disk I/O */}
{metrics.disk && (metrics.disk.readBps.length > 0 || metrics.disk.writeBps.length > 0) && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-emerald-100 dark:bg-emerald-900/30">
<HardDrive className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
</div>
<span className="text-xs font-medium">Disk I/O</span>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Download className="h-3 w-3" />
<span className="font-medium text-foreground">
{metrics.disk.readBps.length > 0
? formatBytesPerSec(metrics.disk.readBps[metrics.disk.readBps.length - 1]?.value || 0)
: 'N/A'}
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Upload className="h-3 w-3" />
<span className="font-medium text-foreground">
{metrics.disk.writeBps.length > 0
? formatBytesPerSec(metrics.disk.writeBps[metrics.disk.writeBps.length - 1]?.value || 0)
: 'N/A'}
</span>
</div>
</div>
</div>
)}
{/* Network */}
{metrics.network && (metrics.network.rxBps.length > 0 || metrics.network.txBps.length > 0) && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-violet-100 dark:bg-violet-900/30">
<Network className="h-3 w-3 text-violet-600 dark:text-violet-400" />
</div>
<span className="text-xs font-medium">Network</span>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Download className="h-3 w-3" />
<span className="font-medium text-foreground">
{metrics.network.rxBps.length > 0
? formatBytesPerSec(metrics.network.rxBps[metrics.network.rxBps.length - 1]?.value || 0)
: 'N/A'}
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Upload className="h-3 w-3" />
<span className="font-medium text-foreground">
{metrics.network.txBps.length > 0
? formatBytesPerSec(metrics.network.txBps[metrics.network.txBps.length - 1]?.value || 0)
: 'N/A'}
</span>
</div>
</div>
</div>
)}
</div>
</div>
)
}
function SnapshotsPanel({ serverId }: { serverId: string }) {
const [newSnapshotName, setNewSnapshotName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [confirmRevert, setConfirmRevert] = useState<string | null>(null)
const { data: snapshotsData, isLoading, isFetching, error, refetch } = useServerSnapshots(serverId)
const createMutation = useCreateSnapshot()
const deleteMutation = useDeleteSnapshot()
const revertMutation = useRevertSnapshot()
const handleCreateSnapshot = async () => {
try {
await createMutation.mutateAsync({
serverId,
name: newSnapshotName || undefined
})
setNewSnapshotName('')
} catch (error) {
console.error('Failed to create snapshot:', error)
}
}
const handleDeleteSnapshot = async (name: string) => {
try {
await deleteMutation.mutateAsync({ serverId, name })
setConfirmDelete(null)
} catch (error) {
console.error('Failed to delete snapshot:', error)
}
}
const handleRevertSnapshot = async (name: string) => {
try {
await revertMutation.mutateAsync({ serverId, name })
setConfirmRevert(null)
} catch (error) {
console.error('Failed to revert snapshot:', error)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="text-center py-4">
<p className="text-xs text-muted-foreground mb-2">Failed to load snapshots</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching} className="h-7 text-xs">
<RefreshCw className={`h-3 w-3 mr-1.5 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Retry'}
</Button>
</div>
)
}
const snapshots = snapshotsData?.snapshots || []
return (
<div className="space-y-3">
{/* Create snapshot */}
<div className="flex gap-2">
<Input
placeholder="Snapshot name (optional)"
value={newSnapshotName}
onChange={(e) => setNewSnapshotName(e.target.value)}
className="h-8 text-xs bg-background"
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleCreateSnapshot}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
<span className="ml-1.5">Create</span>
</Button>
</div>
{/* Snapshots list */}
{snapshots.length === 0 ? (
<div className="text-center py-4">
<Camera className="h-6 w-6 mx-auto text-muted-foreground/40 mb-2" />
<p className="text-xs text-muted-foreground">No snapshots</p>
</div>
) : (
<div className="space-y-2">
{snapshots.map((snapshot) => (
<div
key={snapshot.name}
className="group flex items-center justify-between p-2.5 bg-muted/30 rounded-lg border border-transparent hover:border-muted-foreground/10 transition-colors"
>
<div className="min-w-0">
<p className="text-xs font-medium truncate">{snapshot.name}</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground mt-0.5">
<span className="flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{new Date(snapshot.createdAt).toLocaleDateString()}
</span>
{snapshot.size && (
<span>{formatBytes(snapshot.size)}</span>
)}
</div>
</div>
{confirmDelete === snapshot.name ? (
<div className="flex gap-1">
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
onClick={() => handleDeleteSnapshot(snapshot.name)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Delete'
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => setConfirmDelete(null)}
>
Cancel
</Button>
</div>
) : confirmRevert === snapshot.name ? (
<div className="flex gap-1">
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
onClick={() => handleRevertSnapshot(snapshot.name)}
disabled={revertMutation.isPending}
>
{revertMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Revert'
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => setConfirmRevert(null)}
>
Cancel
</Button>
</div>
) : (
<div className="flex gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => setConfirmRevert(snapshot.name)}
title="Revert to this snapshot"
>
<RotateCcw className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setConfirmDelete(snapshot.name)}
title="Delete snapshot"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
function ServerCard({ server }: { server: NetcupServer }) {
const [confirmAction, setConfirmAction] = useState<PowerAction | 'rescue' | null>(null)
const [expandedSection, setExpandedSection] = useState<'metrics' | 'snapshots' | null>(null)
const powerMutation = useNetcupPowerAction()
const rescueMutation = useNetcupRescue()
// Check if this server is being reinstalled (centralized tracking)
const isReinstalling = isServerReinstalling(server.id)
const effectiveState = isReinstalling ? 'REINSTALLING' : server.state
const config = stateConfig[effectiveState] || stateConfig.UNKNOWN
const StateIcon = config.icon
const isOn = effectiveState === 'ON'
const isPending = powerMutation.isPending || rescueMutation.isPending
const handlePowerAction = async (action: PowerAction) => {
try {
await powerMutation.mutateAsync({ serverId: server.id, action })
setConfirmAction(null)
} catch (error) {
console.error('Power action failed:', error)
}
}
const handleRescue = async (activate: boolean) => {
try {
await rescueMutation.mutateAsync({ serverId: server.id, activate })
setConfirmAction(null)
} catch (error) {
console.error('Rescue action failed:', error)
}
}
const toggleSection = (section: 'metrics' | 'snapshots') => {
setExpandedSection(expandedSection === section ? null : section)
}
return (
<Card className="group overflow-hidden hover:shadow-lg hover:shadow-primary/5 transition-all duration-300 border-transparent hover:border-primary/10">
{/* Card gradient background */}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-card via-card to-muted/20" />
{/* Decorative gradient blob */}
<div className={`absolute top-0 right-0 -mt-8 -mr-8 h-32 w-32 rounded-full blur-3xl opacity-30 transition-opacity group-hover:opacity-50 ${
isOn ? 'bg-emerald-500' : server.state === 'OFF' ? 'bg-slate-400' : 'bg-amber-500'
}`} />
<Link href={`/admin/servers/netcup/${server.id}`}>
<CardHeader className="relative pb-4 cursor-pointer transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className={`p-3 rounded-xl ${config.bgColor} ${config.borderColor} border shrink-0`}>
<Server className={`h-5 w-5 ${config.textColor}`} />
</div>
<div className="min-w-0">
<CardTitle className="text-base font-semibold truncate group-hover:text-primary transition-colors">
{server.nickname || server.name}
</CardTitle>
<p className="text-sm text-muted-foreground truncate font-mono">
{server.nickname ? server.name : null}
</p>
</div>
</div>
{/* Status badge with dot indicator */}
<div className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full text-xs font-medium shrink-0 ${config.bgColor} ${config.textColor} border ${config.borderColor}`}>
<span className={`h-2 w-2 rounded-full ${config.dotColor} ${isOn ? 'animate-pulse' : ''}`} />
{config.label}
</div>
</div>
</CardHeader>
</Link>
<CardContent className="relative space-y-4">
{/* Server specs - card style with colored icons */}
<div className="grid grid-cols-3 gap-2">
{server.cpuCores && (
<div className="flex items-center gap-2 p-2 rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
<div className="p-1.5 rounded-md bg-blue-100 dark:bg-blue-900/30">
<Cpu className="h-3 w-3 text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold tabular-nums">{server.cpuCores}</p>
<p className="text-[10px] text-muted-foreground">Cores</p>
</div>
</div>
)}
{server.ramGb && (
<div className="flex items-center gap-2 p-2 rounded-lg bg-purple-50/50 dark:bg-purple-950/20">
<div className="p-1.5 rounded-md bg-purple-100 dark:bg-purple-900/30">
<MemoryStick className="h-3 w-3 text-purple-600 dark:text-purple-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold tabular-nums">{server.ramGb}</p>
<p className="text-[10px] text-muted-foreground">GB RAM</p>
</div>
</div>
)}
{server.diskGb && (
<div className="flex items-center gap-2 p-2 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20">
<div className="p-1.5 rounded-md bg-emerald-100 dark:bg-emerald-900/30">
<HardDrive className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold tabular-nums">{server.diskGb}</p>
<p className="text-[10px] text-muted-foreground">GB SSD</p>
</div>
</div>
)}
</div>
{/* Network info - improved styling */}
<div className="rounded-lg bg-muted/30 p-3 space-y-2">
{server.hostname && (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Globe className="h-3 w-3" />
<span>Hostname</span>
</div>
<div className="flex items-center gap-1">
<code className="text-xs font-mono font-medium truncate max-w-[140px]">{server.hostname}</code>
<CopyButton text={server.hostname} />
</div>
</div>
)}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Network className="h-3 w-3" />
<span>IPv4</span>
</div>
<div className="flex items-center gap-1">
{server.primaryIpv4 ? (
<>
<code className="text-xs font-mono font-medium">{server.primaryIpv4}</code>
<CopyButton text={server.primaryIpv4} />
</>
) : (
<span className="text-xs text-muted-foreground italic">Not available</span>
)}
</div>
</div>
</div>
{/* Confirm dialog - improved styling */}
{confirmAction && (
<div className="rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20 border border-amber-200 dark:border-amber-900 p-4 space-y-3">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{confirmAction === 'rescue'
? 'Enable rescue mode?'
: `Confirm ${confirmAction.toLowerCase()} action?`}
</p>
<p className="text-xs text-amber-700/80 dark:text-amber-300/70">
{confirmAction === 'POWEROFF' && 'Server will be forcefully powered off.'}
{confirmAction === 'RESET' && 'Server will be hard reset (may cause data loss).'}
{confirmAction === 'POWERCYCLE' && 'Server will be power cycled.'}
{confirmAction === 'rescue' && 'Server will boot into rescue mode on next restart.'}
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={() =>
confirmAction === 'rescue'
? handleRescue(true)
: handlePowerAction(confirmAction as PowerAction)
}
disabled={isPending}
>
{isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Confirm
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => setConfirmAction(null)}
disabled={isPending}
>
Cancel
</Button>
</div>
</div>
)}
{/* Power control buttons - card style with hover effects */}
{!confirmAction && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{effectiveState === 'UNKNOWN' || effectiveState === 'REINSTALLING' ? (
<>
<button
onClick={() => setConfirmAction('POWERCYCLE')}
disabled={isPending}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border bg-card hover:bg-muted/50 hover:border-muted-foreground/20 transition-all disabled:opacity-50"
>
<RefreshCw className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Restart</span>
</button>
<button
onClick={() => setConfirmAction('POWEROFF')}
disabled={isPending}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border bg-card hover:bg-red-50 hover:border-red-200 dark:hover:bg-red-950/30 dark:hover:border-red-900 transition-all disabled:opacity-50"
>
<PowerOff className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Shutdown</span>
</button>
</>
) : !isOn ? (
<button
onClick={() => handlePowerAction('ON')}
disabled={isPending}
className="col-span-2 flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border-2 border-emerald-200 bg-gradient-to-br from-emerald-50 to-emerald-100/50 hover:border-emerald-300 hover:shadow-lg hover:shadow-emerald-100 dark:border-emerald-900 dark:from-emerald-950/50 dark:to-emerald-900/30 transition-all disabled:opacity-50"
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
) : (
<Power className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
)}
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-400">Power On</span>
</button>
) : (
<>
<button
onClick={() => setConfirmAction('POWERCYCLE')}
disabled={isPending}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border bg-card hover:bg-blue-50 hover:border-blue-200 dark:hover:bg-blue-950/30 dark:hover:border-blue-900 transition-all disabled:opacity-50"
>
<RefreshCw className="h-4 w-4 text-muted-foreground group-hover:text-blue-600" />
<span className="text-xs font-medium">Restart</span>
</button>
<button
onClick={() => setConfirmAction('POWEROFF')}
disabled={isPending}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border bg-card hover:bg-red-50 hover:border-red-200 dark:hover:bg-red-950/30 dark:hover:border-red-900 transition-all disabled:opacity-50"
>
<PowerOff className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Power Off</span>
</button>
</>
)}
<button
onClick={() => setConfirmAction('rescue')}
disabled={isPending}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border bg-card hover:bg-amber-50 hover:border-amber-200 dark:hover:bg-amber-950/30 dark:hover:border-amber-900 transition-all disabled:opacity-50"
>
<Wrench className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Rescue</span>
</button>
<Link href={`/admin/servers/netcup/${server.id}`} className="contents">
<button className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border bg-card hover:bg-primary/5 hover:border-primary/20 transition-all">
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Details</span>
</button>
</Link>
</div>
)}
{/* Expandable sections - improved styling */}
<div className="border-t border-border/50 pt-3 space-y-1">
{/* Metrics toggle */}
<button
onClick={() => toggleSection('metrics')}
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-sm font-medium transition-all ${
expandedSection === 'metrics'
? 'bg-primary/5 text-primary'
: 'hover:bg-muted/50 text-muted-foreground hover:text-foreground'
}`}
>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Performance Metrics
</div>
{expandedSection === 'metrics' ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expandedSection === 'metrics' && (
<div className="px-3 py-3 bg-muted/20 rounded-lg mt-1 mb-2">
<MetricsPanel serverId={server.id} />
</div>
)}
{/* Snapshots toggle */}
<button
onClick={() => toggleSection('snapshots')}
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-sm font-medium transition-all ${
expandedSection === 'snapshots'
? 'bg-primary/5 text-primary'
: 'hover:bg-muted/50 text-muted-foreground hover:text-foreground'
}`}
>
<div className="flex items-center gap-2">
<Camera className="h-4 w-4" />
Snapshots
</div>
{expandedSection === 'snapshots' ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expandedSection === 'snapshots' && (
<div className="px-3 py-3 bg-muted/20 rounded-lg mt-1">
<SnapshotsPanel serverId={server.id} />
</div>
)}
</div>
</CardContent>
</div>
</Card>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-xl bg-muted animate-pulse" />
<div className="space-y-2">
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
</div>
</div>
<div className="h-7 w-20 bg-muted animate-pulse rounded-full" />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{[1, 2, 3].map((j) => (
<div key={j} className="h-16 bg-muted/50 animate-pulse rounded-lg" />
))}
</div>
<div className="h-20 bg-muted/30 animate-pulse rounded-lg" />
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="h-16 bg-muted/50 animate-pulse rounded-lg" />
))}
</div>
</CardContent>
</Card>
))}
</div>
)
}
function EmptyState() {
return (
<div className="rounded-2xl border-2 border-dashed border-muted-foreground/20 bg-gradient-to-br from-muted/20 to-muted/40 py-16 text-center">
<div className="mx-auto w-fit p-5 rounded-2xl bg-muted/60 mb-5">
<ServerCrash className="h-12 w-12 text-muted-foreground/50" />
</div>
<h3 className="font-semibold text-lg">No servers found</h3>
<p className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto">
Your Netcup account does not have any servers yet. Servers will appear here once they are provisioned.
</p>
</div>
)
}
export default function NetcupServersPage() {
const { data: authStatus, isLoading: isLoadingAuth } = useNetcupAuth()
const { data: serversData, isLoading: isLoadingServers, refetch } = useNetcupServers()
const isAuthenticated = authStatus?.authenticated
return (
<div className="space-y-8">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decorations */}
<div className="absolute top-0 right-0 -mt-20 -mr-20 h-72 w-72 rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-20 -ml-20 h-56 w-56 rounded-full bg-gradient-to-tr from-blue-500/10 to-cyan-500/5 blur-2xl" />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-3.5 rounded-2xl bg-gradient-to-br from-violet-100 to-purple-100 dark:from-violet-900/30 dark:to-purple-900/30 border border-violet-200/50 dark:border-violet-800/50">
<Server className="h-7 w-7 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Netcup Servers</h1>
<p className="text-muted-foreground mt-0.5">
Manage your Netcup servers via SCP API
</p>
</div>
</div>
{isAuthenticated && (
<Button
variant="outline"
onClick={() => refetch()}
disabled={isLoadingServers}
className="gap-2 shrink-0"
>
<RefreshCw className={`h-4 w-4 ${isLoadingServers ? 'animate-spin' : ''}`} />
Refresh Servers
</Button>
)}
</div>
</div>
{/* Auth setup card */}
<NetcupAuthSetup />
{/* Loading state - initial auth check */}
{isLoadingAuth && (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm text-muted-foreground">Checking authentication...</p>
</div>
)}
{/* Servers grid - only show if authenticated */}
{isAuthenticated && (
<>
{isLoadingServers ? (
<LoadingSkeleton />
) : serversData?.servers && serversData.servers.length > 0 ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{serversData.servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
) : (
<EmptyState />
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,623 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useServers, ServerStatus } from '@/hooks/use-servers'
import {
Search,
Server,
Globe,
User,
Calendar,
RefreshCw,
ExternalLink,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
CheckCircle,
XCircle,
Clock,
Cpu,
Package,
Filter,
ServerCrash,
Zap,
Activity,
} from 'lucide-react'
// Status configuration with enhanced styling
const statusConfig: Record<ServerStatus, {
label: string
bgColor: string
textColor: string
borderColor: string
dotColor: string
iconBg: string
iconColor: string
cardGradient: string
icon: typeof CheckCircle
}> = {
online: {
label: 'Online',
bgColor: 'bg-emerald-50 dark:bg-emerald-950/30',
textColor: 'text-emerald-700 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800',
dotColor: 'bg-emerald-500',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/50',
iconColor: 'text-emerald-600 dark:text-emerald-400',
cardGradient: 'from-emerald-50/50 via-card to-card dark:from-emerald-950/20',
icon: CheckCircle,
},
provisioning: {
label: 'Provisioning',
bgColor: 'bg-blue-50 dark:bg-blue-950/30',
textColor: 'text-blue-700 dark:text-blue-400',
borderColor: 'border-blue-200 dark:border-blue-800',
dotColor: 'bg-blue-500',
iconBg: 'bg-blue-100 dark:bg-blue-900/50',
iconColor: 'text-blue-600 dark:text-blue-400',
cardGradient: 'from-blue-50/50 via-card to-card dark:from-blue-950/20',
icon: Clock,
},
offline: {
label: 'Offline',
bgColor: 'bg-red-50 dark:bg-red-950/30',
textColor: 'text-red-700 dark:text-red-400',
borderColor: 'border-red-200 dark:border-red-800',
dotColor: 'bg-red-500',
iconBg: 'bg-red-100 dark:bg-red-900/50',
iconColor: 'text-red-600 dark:text-red-400',
cardGradient: 'from-red-50/50 via-card to-card dark:from-red-950/20',
icon: XCircle,
},
pending: {
label: 'Pending',
bgColor: 'bg-amber-50 dark:bg-amber-950/30',
textColor: 'text-amber-700 dark:text-amber-400',
borderColor: 'border-amber-200 dark:border-amber-800',
dotColor: 'bg-amber-500',
iconBg: 'bg-amber-100 dark:bg-amber-900/50',
iconColor: 'text-amber-600 dark:text-amber-400',
cardGradient: 'from-amber-50/50 via-card to-card dark:from-amber-950/20',
icon: Clock,
},
}
// Enhanced status badge component with dot indicator and pulse animation
function ServerStatusBadge({ status }: { status: ServerStatus }) {
const config = statusConfig[status]
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border ${config.bgColor} ${config.textColor} ${config.borderColor}`}>
<span className={`h-1.5 w-1.5 rounded-full ${config.dotColor} ${status === 'online' ? 'animate-pulse' : ''} ${status === 'provisioning' ? 'animate-pulse' : ''}`} />
{config.label}
</span>
)
}
// Tool chip component with improved styling
function ToolChip({ tool }: { tool: string }) {
// Tool-specific colors
const getToolColor = (toolName: string) => {
const name = toolName.toLowerCase()
if (name.includes('nextcloud')) return 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800'
if (name.includes('keycloak')) return 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-800'
if (name.includes('minio')) return 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-900/30 dark:text-rose-300 dark:border-rose-800'
if (name.includes('poste')) return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800'
if (name.includes('portainer')) return 'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300 dark:border-cyan-800'
return 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700'
}
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-md border ${getToolColor(tool)}`}>
{tool}
</span>
)
}
// Enhanced Server card component
function ServerCard({ server }: { server: {
id: string
domain: string
tier: string
serverStatus: ServerStatus
serverIp: string
sshPort: number
tools: string[]
createdAt: Date | string
customer: {
id: string
name: string | null
email: string
company: string | null
}
}}) {
const config = statusConfig[server.serverStatus]
const StatusIcon = config.icon
return (
<Card className={`group relative overflow-hidden border hover:shadow-lg hover:border-muted-foreground/20 transition-all duration-300 bg-gradient-to-br ${config.cardGradient}`}>
{/* Subtle gradient overlay on hover */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/0 to-primary/0 group-hover:from-primary/[0.02] group-hover:to-primary/[0.04] transition-all duration-300" />
<CardContent className="relative pt-6">
{/* Header with icon and status */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${config.iconBg} ring-1 ring-inset ring-black/5 group-hover:scale-105 transition-transform duration-300`}>
<Server className={`h-6 w-6 ${config.iconColor}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/admin/servers/${server.id}`} className="hover:underline">
<h3 className="font-semibold text-base truncate group-hover:text-primary transition-colors">
{server.domain}
</h3>
</Link>
</div>
<p className="text-sm text-muted-foreground font-mono truncate">{server.serverIp}</p>
</div>
</div>
<Link href={`/admin/servers/${server.id}`}>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 opacity-70 group-hover:opacity-100 group-hover:bg-primary/10 transition-all"
>
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</div>
{/* Status badge */}
<div className="mb-4">
<ServerStatusBadge status={server.serverStatus} />
</div>
{/* Server details grid */}
<div className="grid grid-cols-2 gap-3 text-sm mb-4">
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<User className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="font-medium truncate">{server.customer.name || server.customer.email}</p>
{server.customer.company && (
<p className="text-xs text-muted-foreground truncate">{server.customer.company}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="capitalize truncate">{server.tier.replace('_', ' ').toLowerCase()}</span>
</div>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<Cpu className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">SSH: {server.sshPort}</span>
</div>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{new Date(server.createdAt).toLocaleDateString()}</span>
</div>
</div>
{/* Tools section */}
<div className="pt-3 border-t border-border/50">
<div className="flex items-center gap-2 mb-2">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Tools ({server.tools.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{server.tools.slice(0, 4).map((tool) => (
<ToolChip key={tool} tool={tool} />
))}
{server.tools.length > 4 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
+{server.tools.length - 4} more
</span>
)}
</div>
</div>
</CardContent>
</Card>
)
}
// Stats card component
function StatsCard({
icon: Icon,
value,
label,
iconBg,
iconColor,
trend
}: {
icon: typeof Server
value: number
label: string
iconBg: string
iconColor: string
trend?: 'up' | 'down' | 'neutral'
}) {
return (
<Card className="relative overflow-hidden hover:shadow-md transition-all duration-300">
<div className="absolute top-0 right-0 -mt-4 -mr-4 h-24 w-24 rounded-full bg-gradient-to-br from-primary/5 to-transparent blur-2xl" />
<CardContent className="relative pt-6">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${iconBg} ring-1 ring-inset ring-black/5`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div>
<div>
<div className="text-3xl font-bold tabular-nums">{value}</div>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</div>
</CardContent>
</Card>
)
}
// Empty state component with illustration
function EmptyState({
hasFilters,
onClearFilters
}: {
hasFilters: boolean
onClearFilters: () => void
}) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
{/* Illustration */}
<div className="relative mb-6">
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full blur-2xl" />
<div className="relative flex items-center justify-center h-24 w-24 rounded-2xl bg-muted/60 ring-1 ring-inset ring-black/5">
<ServerCrash className="h-12 w-12 text-muted-foreground/60" />
</div>
</div>
<h3 className="text-xl font-semibold text-center mb-2">No servers found</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
{hasFilters
? 'No servers match your current filters. Try adjusting your search criteria or clear the filters to see all servers.'
: 'Servers will appear here once orders are provisioned. Create a new order to get started.'}
</p>
{hasFilters && (
<Button variant="outline" onClick={onClearFilters} className="gap-2">
<XCircle className="h-4 w-4" />
Clear all filters
</Button>
)}
</div>
)
}
// Pagination component
function Pagination({
currentPage,
totalPages,
onPageChange
}: {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}) {
const pages = useMemo(() => {
const items: (number | 'ellipsis')[] = []
const showEllipsisStart = currentPage > 3
const showEllipsisEnd = currentPage < totalPages - 2
if (totalPages <= 7) {
// Show all pages
for (let i = 1; i <= totalPages; i++) {
items.push(i)
}
} else {
// Always show first page
items.push(1)
if (showEllipsisStart) {
items.push('ellipsis')
}
// Show pages around current
const start = Math.max(2, currentPage - 1)
const end = Math.min(totalPages - 1, currentPage + 1)
for (let i = start; i <= end; i++) {
if (!items.includes(i)) items.push(i)
}
if (showEllipsisEnd) {
items.push('ellipsis')
}
// Always show last page
if (!items.includes(totalPages)) items.push(totalPages)
}
return items
}, [currentPage, totalPages])
return (
<div className="flex items-center justify-between pt-6 border-t">
<p className="text-sm text-muted-foreground">
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{totalPages}</span>
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-9 px-3"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<div className="hidden sm:flex items-center gap-1 mx-2">
{pages.map((page, idx) => (
page === 'ellipsis' ? (
<span key={`ellipsis-${idx}`} className="px-2 text-muted-foreground">...</span>
) : (
<Button
key={page}
variant={currentPage === page ? 'default' : 'ghost'}
size="sm"
onClick={() => onPageChange(page)}
className={`h-9 w-9 p-0 ${currentPage === page ? 'pointer-events-none' : ''}`}
>
{page}
</Button>
)
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-9 px-3"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)
}
export default function ServersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<ServerStatus | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 12
// Fetch servers from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useServers({
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
page: currentPage,
limit: itemsPerPage,
})
// Calculate stats from data
const stats = useMemo(() => {
const servers = data?.servers || []
return {
total: data?.pagination?.total || 0,
online: servers.filter((s) => s.serverStatus === 'online').length,
provisioning: servers.filter((s) => s.serverStatus === 'provisioning').length,
offline: servers.filter((s) => s.serverStatus === 'offline').length,
}
}, [data])
const totalPages = data?.pagination?.totalPages || 1
const hasFilters = Boolean(search || statusFilter !== 'all')
const clearFilters = () => {
setSearch('')
setStatusFilter('all')
setCurrentPage(1)
}
// Loading state
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm text-muted-foreground">Loading servers...</p>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div className="relative">
<div className="absolute inset-0 bg-destructive/20 rounded-full blur-xl" />
<div className="relative p-4 rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
</div>
<div className="text-center">
<p className="font-medium text-destructive mb-1">Failed to load servers</p>
<p className="text-sm text-muted-foreground max-w-md">
{error instanceof Error ? error.message : 'An unexpected error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Try again'}
</Button>
</div>
)
}
return (
<div className="space-y-8">
{/* Hero Header Section */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decorations */}
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-emerald-500/5 to-transparent blur-2xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-32 w-32 rounded-full bg-gradient-to-br from-blue-500/5 to-transparent blur-2xl" />
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 ring-1 ring-inset ring-primary/10">
<Server className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Servers</h1>
<p className="text-muted-foreground mt-1">
Manage and monitor your deployed infrastructure
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="shrink-0 gap-2 self-start md:self-auto"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
{/* Stats Cards Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
icon={Server}
value={stats.total}
label="Total Servers"
iconBg="bg-slate-100 dark:bg-slate-800"
iconColor="text-slate-600 dark:text-slate-400"
/>
<StatsCard
icon={CheckCircle}
value={stats.online}
label="Online"
iconBg="bg-emerald-100 dark:bg-emerald-900/50"
iconColor="text-emerald-600 dark:text-emerald-400"
/>
<StatsCard
icon={Activity}
value={stats.provisioning}
label="Provisioning"
iconBg="bg-blue-100 dark:bg-blue-900/50"
iconColor="text-blue-600 dark:text-blue-400"
/>
<StatsCard
icon={XCircle}
value={stats.offline}
label="Offline"
iconBg="bg-red-100 dark:bg-red-900/50"
iconColor="text-red-600 dark:text-red-400"
/>
</div>
{/* Filters Section */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg">All Servers</CardTitle>
<CardDescription className="mt-1">
{data?.pagination?.total || 0} server{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
{hasFilters && ' with current filters'}
</CardDescription>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search by domain, IP..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setCurrentPage(1)
}}
className="pl-10 w-full sm:w-64 bg-muted/30 border-muted-foreground/20 focus:bg-background transition-colors"
/>
</div>
{/* Status Filter */}
<div className="relative">
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as ServerStatus | 'all')
setCurrentPage(1)
}}
className="h-10 w-full sm:w-auto pl-10 pr-8 rounded-md border border-muted-foreground/20 bg-muted/30 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:bg-background transition-colors appearance-none cursor-pointer"
>
<option value="all">All Status</option>
<option value="online">Online</option>
<option value="provisioning">Provisioning</option>
<option value="offline">Offline</option>
<option value="pending">Pending</option>
</select>
<ChevronLeft className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 rotate-[270deg] text-muted-foreground pointer-events-none" />
</div>
</div>
</div>
</CardHeader>
<CardContent>
{data?.servers && data.servers.length > 0 ? (
<>
{/* Server Cards Grid */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</>
) : (
<EmptyState hasFilters={hasFilters} onClearFilters={clearFilters} />
)}
</CardContent>
</Card>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,464 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { StaffRole, StaffStatus } from '@prisma/client'
import {
useStaffList,
useInvitations,
useUpdateStaff,
useDeleteStaff,
useCancelInvitation,
} from '@/hooks/use-staff'
import { hasPermission } from '@/lib/services/permission-service'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
MoreHorizontal,
Search,
UserPlus,
Shield,
ShieldCheck,
ShieldAlert,
Clock,
Trash2,
UserX,
UserCheck,
} from 'lucide-react'
import { InviteStaffDialog } from '@/components/admin/invite-staff-dialog'
const roleColors: Record<StaffRole, string> = {
OWNER: 'bg-purple-100 text-purple-800',
ADMIN: 'bg-blue-100 text-blue-800',
MANAGER: 'bg-green-100 text-green-800',
SUPPORT: 'bg-gray-100 text-gray-800',
}
const roleIcons: Record<StaffRole, typeof Shield> = {
OWNER: ShieldAlert,
ADMIN: ShieldCheck,
MANAGER: Shield,
SUPPORT: Shield,
}
const statusColors: Record<StaffStatus, string> = {
ACTIVE: 'bg-green-100 text-green-800',
SUSPENDED: 'bg-red-100 text-red-800',
}
export default function StaffPage() {
const { data: session } = useSession()
const userRole = (session?.user?.role as StaffRole) || 'SUPPORT'
const [search, setSearch] = useState('')
const [showInviteDialog, setShowInviteDialog] = useState(false)
const [staffToDelete, setStaffToDelete] = useState<string | null>(null)
const [inviteToCancel, setInviteToCancel] = useState<string | null>(null)
const { data: staffData, isLoading: staffLoading } = useStaffList({ search })
const { data: invitesData, isLoading: invitesLoading } = useInvitations()
const updateStaff = useUpdateStaff()
const deleteStaff = useDeleteStaff()
const cancelInvitation = useCancelInvitation()
const canManage = hasPermission(userRole, 'staff:manage')
const canInvite = hasPermission(userRole, 'staff:invite')
const canDelete = hasPermission(userRole, 'staff:delete')
const handleStatusChange = async (id: string, status: StaffStatus) => {
try {
await updateStaff.mutateAsync({ id, data: { status } })
} catch {
// Error handled by mutation
}
}
const handleRoleChange = async (id: string, role: StaffRole) => {
try {
await updateStaff.mutateAsync({ id, data: { role } })
} catch {
// Error handled by mutation
}
}
const handleDeleteStaff = async () => {
if (!staffToDelete) return
try {
await deleteStaff.mutateAsync(staffToDelete)
setStaffToDelete(null)
} catch {
// Error handled by mutation
}
}
const handleCancelInvite = async () => {
if (!inviteToCancel) return
try {
await cancelInvitation.mutateAsync(inviteToCancel)
setInviteToCancel(null)
} catch {
// Error handled by mutation
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Staff Management</h1>
<p className="text-muted-foreground">
Manage team members and their permissions
</p>
</div>
{canInvite && (
<Button onClick={() => setShowInviteDialog(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Invite Staff
</Button>
)}
</div>
<Tabs defaultValue="staff">
<TabsList>
<TabsTrigger value="staff">
Staff Members ({staffData?.pagination.total || 0})
</TabsTrigger>
<TabsTrigger value="invites">
Pending Invites ({invitesData?.total || 0})
</TabsTrigger>
</TabsList>
<TabsContent value="staff" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search staff..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent>
{staffLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading...
</div>
) : !staffData?.staff.length ? (
<div className="text-center py-8 text-muted-foreground">
No staff members found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>2FA</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{staffData.staff.map((staff) => {
const RoleIcon = roleIcons[staff.role]
return (
<TableRow key={staff.id}>
<TableCell className="font-medium">
{staff.name || 'No name'}
{staff.isCurrentUser && (
<Badge variant="outline" className="ml-2">
You
</Badge>
)}
</TableCell>
<TableCell>{staff.email}</TableCell>
<TableCell>
{canManage && !staff.isCurrentUser && staff.role !== 'OWNER' ? (
<Select
value={staff.role}
onValueChange={(value) =>
handleRoleChange(staff.id, value as StaffRole)
}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="MANAGER">Manager</SelectItem>
<SelectItem value="SUPPORT">Support</SelectItem>
</SelectContent>
</Select>
) : (
<Badge
variant="secondary"
className={roleColors[staff.role]}
>
<RoleIcon className="h-3 w-3 mr-1" />
{staff.role}
</Badge>
)}
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={statusColors[staff.status]}
>
{staff.status}
</Badge>
</TableCell>
<TableCell>
{staff.twoFactorEnabled ? (
<Badge variant="secondary" className="bg-green-100 text-green-800">
Enabled
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800">
Disabled
</Badge>
)}
</TableCell>
<TableCell>
{new Date(staff.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
{!staff.isCurrentUser && canManage && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{staff.status === 'ACTIVE' ? (
<DropdownMenuItem
onClick={() =>
handleStatusChange(staff.id, 'SUSPENDED')
}
>
<UserX className="h-4 w-4 mr-2" />
Suspend
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
handleStatusChange(staff.id, 'ACTIVE')
}
>
<UserCheck className="h-4 w-4 mr-2" />
Activate
</DropdownMenuItem>
)}
{canDelete && staff.role !== 'OWNER' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => setStaffToDelete(staff.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="invites" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>
Staff members who have been invited but haven't created their account yet
</CardDescription>
</CardHeader>
<CardContent>
{invitesLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading...
</div>
) : !invitesData?.invitations.length ? (
<div className="text-center py-8 text-muted-foreground">
No pending invitations
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Invited By</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitesData.invitations.map((invite) => (
<TableRow key={invite.id}>
<TableCell className="font-medium">
{invite.email}
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={roleColors[invite.role]}
>
{invite.role}
</Badge>
</TableCell>
<TableCell>
{invite.invitedByStaff?.name || invite.invitedByStaff?.email || 'Unknown'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-muted-foreground" />
{new Date(invite.expiresAt).toLocaleDateString()}
</div>
</TableCell>
<TableCell>
{invite.isExpired ? (
<Badge variant="destructive">Expired</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</TableCell>
<TableCell>
{canInvite && (
<Button
variant="ghost"
size="icon"
onClick={() => setInviteToCancel(invite.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Invite Dialog */}
<InviteStaffDialog
open={showInviteDialog}
onOpenChange={setShowInviteDialog}
currentRole={userRole}
/>
{/* Delete Staff Confirmation */}
<AlertDialog
open={!!staffToDelete}
onOpenChange={() => setStaffToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Staff Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this staff member? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteStaff}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Cancel Invite Confirmation */}
<AlertDialog
open={!!inviteToCancel}
onOpenChange={() => setInviteToCancel(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Invitation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this invitation? The invite link
will no longer work.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Invite</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelInvite}
className="bg-red-600 hover:bg-red-700"
>
Cancel Invite
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { handlers } from '@/lib/auth'
import { loginLimiter } from '@/lib/rate-limit'
export const GET = handlers.GET
/**
* POST handler with rate limiting for login attempts.
* NextAuth credential auth goes through POST /api/auth/callback/credentials
*/
export async function POST(request: NextRequest) {
// Apply rate limiting to credential login attempts
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const url = request.nextUrl.pathname
if (url.includes('/callback/credentials')) {
const result = loginLimiter.check(`login:${ip}`)
if (result.limited) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': String(result.retryAfter || 60),
},
}
)
}
}
return handlers.POST(request)
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { statsCollectionService } from '@/lib/services/stats-collection-service'
/**
* GET /api/cron/cleanup-stats
* Delete stats snapshots older than 90 days
*
* This endpoint is designed to be called by a daily cron job.
* It should be protected by a secret token in production.
*
* Example cron schedule: Once per day at 3am
* Vercel cron config in vercel.json:
* {
* "crons": [
* { "path": "/api/cron/cleanup-stats", "schedule": "0 3 * * *" }
* ]
* }
*/
export async function GET(request: NextRequest) {
// Verify cron secret
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('[Cron] CRON_SECRET environment variable is not set')
return NextResponse.json({ error: 'Cron not configured' }, { status: 500 })
}
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const startTime = Date.now()
const deletedCount = await statsCollectionService.cleanupOldSnapshots()
const duration = Date.now() - startTime
return NextResponse.json({
success: true,
message: `Cleaned up ${deletedCount} old stats snapshots`,
deleted: deletedCount,
duration: `${duration}ms`,
retentionDays: 90,
timestamp: new Date().toISOString()
})
} catch (error) {
console.error('Cron job failed - cleanup-stats:', error)
return NextResponse.json(
{
error: 'Stats cleanup failed',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
)
}
}
// Also support POST for flexibility
export async function POST(request: NextRequest) {
return GET(request)
}

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server'
import { statsCollectionService } from '@/lib/services/stats-collection-service'
import { logScanningService } from '@/lib/services/log-scanning-service'
import { containerHealthService } from '@/lib/services/container-health-service'
/**
* GET /api/cron/collect-stats
* Collect stats, scan logs for errors, and check container health for all enterprise servers
*
* This endpoint is designed to be called by a cron job or scheduled task.
* It should be protected by a secret token in production.
*
* Example cron schedule: Every 5 minutes
* Vercel cron config in vercel.json:
* {
* "crons": [
* { "path": "/api/cron/collect-stats", "schedule": "*\/5 * * * *" }
* ]
* }
*
* What this cron does:
* 1. Collects performance stats from Netcup + Portainer for all active servers
* 2. Scans container logs for errors matching client-defined rules
* 3. Checks container health and detects crashes/OOM kills
*/
export async function GET(request: NextRequest) {
// Verify cron secret
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('[Cron] CRON_SECRET environment variable is not set')
return NextResponse.json({ error: 'Cron not configured' }, { status: 500 })
}
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const startTime = Date.now()
const results = {
stats: { collected: 0, failed: 0 },
logScan: { totalServers: 0, scannedServers: 0, failedServers: 0, totalErrorsFound: 0, duration: 0 },
healthCheck: { totalServers: 0, checkedServers: 0, failedServers: 0, eventsDetected: 0, crashes: 0, oomKills: 0, duration: 0 },
errors: [] as string[],
}
// 1. Collect performance stats (existing functionality)
try {
results.stats = await statsCollectionService.collectAllStats()
console.log(`[Cron] Stats collection: ${results.stats.collected} servers, ${results.stats.failed} failed`)
} catch (error) {
console.error('[Cron] Stats collection failed:', error)
results.errors.push(`Stats collection: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
// 2. Scan container logs for errors
try {
results.logScan = await logScanningService.scanAllServers()
console.log(`[Cron] Log scan: ${results.logScan.scannedServers}/${results.logScan.totalServers} servers, ${results.logScan.totalErrorsFound} errors found`)
} catch (error) {
console.error('[Cron] Log scanning failed:', error)
results.errors.push(`Log scanning: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
// 3. Check container health (crash detection)
try {
results.healthCheck = await containerHealthService.checkAllServers()
console.log(`[Cron] Health check: ${results.healthCheck.checkedServers}/${results.healthCheck.totalServers} servers, ${results.healthCheck.eventsDetected} events detected`)
} catch (error) {
console.error('[Cron] Health check failed:', error)
results.errors.push(`Health check: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
const totalDuration = Date.now() - startTime
const hasErrors = results.errors.length > 0
return NextResponse.json({
success: !hasErrors,
timestamp: new Date().toISOString(),
duration: `${totalDuration}ms`,
// Stats collection results
stats: {
serversCollected: results.stats.collected,
serversFailed: results.stats.failed,
},
// Log scanning results
logScan: {
serversScanned: results.logScan.scannedServers,
serversFailed: results.logScan.failedServers,
errorsFound: results.logScan.totalErrorsFound,
duration: `${results.logScan.duration}ms`,
},
// Health check results
healthCheck: {
serversChecked: results.healthCheck.checkedServers,
serversFailed: results.healthCheck.failedServers,
eventsDetected: results.healthCheck.eventsDetected,
crashes: results.healthCheck.crashes,
oomKills: results.healthCheck.oomKills,
duration: `${results.healthCheck.duration}ms`,
},
// Any errors that occurred
...(hasErrors && { errors: results.errors }),
}, { status: hasErrors ? 207 : 200 }) // 207 Multi-Status if partial failure
}
// Also support POST for flexibility
export async function POST(request: NextRequest) {
return GET(request)
}

View File

@@ -0,0 +1,313 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireStaffPermission } from '@/lib/auth-helpers'
import { prisma } from '@/lib/prisma'
import { OrderStatus, SubscriptionPlan, SubscriptionTier, UserStatus, SubscriptionStatus } from '@prisma/client'
type TimeRange = '7d' | '30d' | '90d'
function getDateRange(range: TimeRange): Date {
const now = new Date()
switch (range) {
case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
case '90d':
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
default:
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
}
}
function getPreviousRange(range: TimeRange): { start: Date; end: Date } {
const now = new Date()
const currentStart = getDateRange(range)
const duration = now.getTime() - currentStart.getTime()
return {
start: new Date(currentStart.getTime() - duration),
end: currentStart,
}
}
/**
* GET /api/v1/admin/analytics
* Get comprehensive analytics data for the dashboard
* Query params: range=7d|30d|90d (default: 30d)
*/
export async function GET(request: NextRequest) {
try {
await requireStaffPermission('dashboard:view')
const searchParams = request.nextUrl.searchParams
const range = (searchParams.get('range') || '30d') as TimeRange
const startDate = getDateRange(range)
const previousRange = getPreviousRange(range)
// === OVERVIEW METRICS ===
// Total orders (all time)
const totalOrders = await prisma.order.count()
// Orders in current period
const currentPeriodOrders = await prisma.order.count({
where: { createdAt: { gte: startDate } },
})
// Orders in previous period
const previousPeriodOrders = await prisma.order.count({
where: {
createdAt: {
gte: previousRange.start,
lt: previousRange.end,
},
},
})
// Active customers
const activeCustomers = await prisma.user.count({
where: { status: UserStatus.ACTIVE },
})
// Current period new customers
const currentPeriodCustomers = await prisma.user.count({
where: { createdAt: { gte: startDate } },
})
// Previous period new customers
const previousPeriodCustomers = await prisma.user.count({
where: {
createdAt: {
gte: previousRange.start,
lt: previousRange.end,
},
},
})
// Active subscriptions
const activeSubscriptions = await prisma.subscription.count({
where: { status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL] } },
})
// Success rate (fulfilled / (fulfilled + failed))
const fulfilledOrders = await prisma.order.count({
where: { status: { in: [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] } },
})
const failedOrders = await prisma.order.count({
where: { status: OrderStatus.FAILED },
})
const successRate = fulfilledOrders + failedOrders > 0
? (fulfilledOrders / (fulfilledOrders + failedOrders)) * 100
: 100
// === ORDERS BY DAY ===
const ordersByDay = await prisma.$queryRaw<{ date: Date; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM orders
WHERE created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date ASC
`
// === ORDERS BY STATUS ===
const ordersByStatus = await prisma.order.groupBy({
by: ['status'],
_count: { status: true },
})
const statusCounts: Record<string, number> = {}
Object.values(OrderStatus).forEach((status) => {
statusCounts[status] = 0
})
ordersByStatus.forEach((item) => {
statusCounts[item.status] = item._count.status
})
// === CUSTOMER GROWTH BY DAY ===
const customerGrowth = await prisma.$queryRaw<{ date: Date; count: bigint }[]>`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM users
WHERE created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date ASC
`
// === SUBSCRIPTIONS BY PLAN ===
const subscriptionsByPlan = await prisma.subscription.groupBy({
by: ['plan'],
_count: { plan: true },
})
const planCounts: Record<string, number> = {}
Object.values(SubscriptionPlan).forEach((plan) => {
planCounts[plan] = 0
})
subscriptionsByPlan.forEach((item) => {
planCounts[item.plan] = item._count.plan
})
// === SUBSCRIPTIONS BY TIER ===
const subscriptionsByTier = await prisma.subscription.groupBy({
by: ['tier'],
_count: { tier: true },
})
const tierCounts: Record<string, number> = {}
Object.values(SubscriptionTier).forEach((tier) => {
tierCounts[tier] = 0
})
subscriptionsByTier.forEach((item) => {
tierCounts[item.tier] = item._count.tier
})
// === TOKEN USAGE BY DAY ===
const tokenUsageByDay = await prisma.$queryRaw<{ date: Date; tokens: bigint }[]>`
SELECT DATE(created_at) as date,
SUM(tokens_input + tokens_output) as tokens
FROM token_usage
WHERE created_at >= ${startDate}
GROUP BY DATE(created_at)
ORDER BY date ASC
`
// === TOKEN USAGE BY OPERATION ===
const tokensByOperation = await prisma.$queryRaw<{ operation: string; tokens: bigint }[]>`
SELECT operation, SUM(tokens_input + tokens_output) as tokens
FROM token_usage
WHERE created_at >= ${startDate}
GROUP BY operation
ORDER BY tokens DESC
`
// === TOP TOKEN CONSUMERS ===
const topConsumers = await prisma.$queryRaw<{ userId: string; tokens: bigint }[]>`
SELECT user_id as "userId", SUM(tokens_input + tokens_output) as tokens
FROM token_usage
WHERE created_at >= ${startDate}
GROUP BY user_id
ORDER BY tokens DESC
LIMIT 10
`
// Get customer names for top consumers
const consumerIds = topConsumers.map((c) => c.userId)
const consumers = await prisma.user.findMany({
where: { id: { in: consumerIds } },
select: { id: true, name: true, email: true, company: true },
})
const consumerMap = new Map(consumers.map((c) => [c.id, c]))
const topConsumersWithNames = topConsumers.map((c) => {
const user = consumerMap.get(c.userId)
return {
userId: c.userId,
name: user?.name || user?.company || user?.email || 'Unknown',
tokens: Number(c.tokens),
}
})
// === PROVISIONING METRICS ===
// Recent failures
const recentFailures = await prisma.order.findMany({
where: {
status: OrderStatus.FAILED,
updatedAt: { gte: startDate },
},
select: {
id: true,
domain: true,
updatedAt: true,
provisioningLogs: {
where: { level: 'ERROR' },
orderBy: { timestamp: 'desc' },
take: 1,
select: { message: true },
},
},
orderBy: { updatedAt: 'desc' },
take: 10,
})
// Orders by automation mode
const ordersByAutomation = await prisma.order.groupBy({
by: ['automationMode'],
_count: { automationMode: true },
})
const automationCounts: Record<string, number> = {
AUTO: 0,
MANUAL: 0,
PAUSED: 0,
}
ordersByAutomation.forEach((item) => {
automationCounts[item.automationMode] = item._count.automationMode
})
// Calculate trends
const ordersTrend = previousPeriodOrders > 0
? ((currentPeriodOrders - previousPeriodOrders) / previousPeriodOrders) * 100
: currentPeriodOrders > 0 ? 100 : 0
const customersTrend = previousPeriodCustomers > 0
? ((currentPeriodCustomers - previousPeriodCustomers) / previousPeriodCustomers) * 100
: currentPeriodCustomers > 0 ? 100 : 0
return NextResponse.json({
range,
overview: {
totalOrders,
ordersTrend: Math.round(ordersTrend * 10) / 10,
activeCustomers,
customersTrend: Math.round(customersTrend * 10) / 10,
activeSubscriptions,
successRate: Math.round(successRate * 10) / 10,
},
orders: {
byDay: ordersByDay.map((row) => ({
date: row.date.toISOString().split('T')[0],
count: Number(row.count),
})),
byStatus: statusCounts,
},
customers: {
growthByDay: customerGrowth.map((row) => ({
date: row.date.toISOString().split('T')[0],
count: Number(row.count),
})),
byPlan: planCounts,
byTier: tierCounts,
},
tokens: {
usageByDay: tokenUsageByDay.map((row) => ({
date: row.date.toISOString().split('T')[0],
tokens: Number(row.tokens),
})),
byOperation: tokensByOperation.map((row) => ({
operation: row.operation,
tokens: Number(row.tokens),
})),
topConsumers: topConsumersWithNames,
},
provisioning: {
successRate: Math.round(successRate * 10) / 10,
byAutomation: automationCounts,
recentFailures: recentFailures.map((order) => ({
orderId: order.id,
domain: order.domain,
date: order.updatedAt.toISOString(),
reason: order.provisioningLogs[0]?.message || 'Unknown error',
})),
},
})
} catch (error) {
if (typeof error === 'object' && error !== null && 'status' in error) {
const err = error as { status: number; message: string }
return NextResponse.json({ error: err.message }, { status: err.status })
}
console.error('Error fetching analytics:', error)
return NextResponse.json(
{ error: 'Failed to fetch analytics' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,244 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { UserStatus } from '@prisma/client'
/**
* GET /api/v1/admin/customers/[id]
* Get customer details with orders and subscriptions
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: customerId } = await params
const customer = await prisma.user.findUnique({
where: { id: customerId },
include: {
subscriptions: {
orderBy: { createdAt: 'desc' },
},
orders: {
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { provisioningLogs: true },
},
},
},
tokenUsage: {
orderBy: { createdAt: 'desc' },
take: 100,
},
_count: {
select: {
orders: true,
subscriptions: true,
tokenUsage: true,
},
},
},
})
if (!customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Calculate total token usage
const totalTokensUsed = customer.tokenUsage.reduce(
(acc, usage) => acc + usage.tokensInput + usage.tokensOutput,
0
)
// Get current subscription's token limit
const currentSubscription = customer.subscriptions[0]
const tokenLimit = currentSubscription?.tokenLimit || 0
return NextResponse.json({
...customer,
totalTokensUsed,
tokenLimit,
})
} catch (error) {
console.error('Error getting customer:', error)
return NextResponse.json(
{ error: 'Failed to get customer' },
{ status: 500 }
)
}
}
/**
* PATCH /api/v1/admin/customers/[id]
* Update customer details
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: customerId } = await params
const body = await request.json()
// Validate customer exists
const existingCustomer = await prisma.user.findUnique({
where: { id: customerId },
})
if (!existingCustomer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Build update data
const updateData: {
name?: string
company?: string
status?: UserStatus
} = {}
if (body.name !== undefined) {
updateData.name = body.name
}
if (body.company !== undefined) {
updateData.company = body.company
}
if (body.status !== undefined) {
updateData.status = body.status as UserStatus
}
const customer = await prisma.user.update({
where: { id: customerId },
data: updateData,
include: {
subscriptions: {
orderBy: { createdAt: 'desc' },
take: 1,
},
_count: {
select: {
orders: true,
subscriptions: true,
},
},
},
})
return NextResponse.json(customer)
} catch (error) {
console.error('Error updating customer:', error)
return NextResponse.json(
{ error: 'Failed to update customer' },
{ status: 500 }
)
}
}
/**
* DELETE /api/v1/admin/customers/[id]
* Delete a customer and all related records (orders, subscriptions, token usage)
* Does NOT touch any actual servers - just removes from Hub database
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: customerId } = await params
// Find existing customer with their orders
const existingCustomer = await prisma.user.findUnique({
where: { id: customerId },
include: {
orders: {
include: {
dnsVerification: true,
},
},
},
})
if (!existingCustomer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Note: Staff users are in a separate table, so this endpoint only handles customers
// Delete in correct order to respect foreign key constraints
// 1. For each order, delete related records
for (const order of existingCustomer.orders) {
// Delete DNS records and verification
if (order.dnsVerification) {
await prisma.dnsRecord.deleteMany({
where: { dnsVerificationId: order.dnsVerification.id },
})
await prisma.dnsVerification.delete({
where: { id: order.dnsVerification.id },
})
}
// Delete provisioning logs
await prisma.provisioningLog.deleteMany({
where: { orderId: order.id },
})
// Delete jobs
await prisma.provisioningJob.deleteMany({
where: { orderId: order.id },
})
}
// 2. Delete all orders
await prisma.order.deleteMany({
where: { userId: customerId },
})
// 3. Delete subscriptions
await prisma.subscription.deleteMany({
where: { userId: customerId },
})
// 4. Delete token usage records
await prisma.tokenUsage.deleteMany({
where: { userId: customerId },
})
// 5. Delete the customer/user
await prisma.user.delete({
where: { id: customerId },
})
return NextResponse.json({
success: true,
message: `Customer ${existingCustomer.email} and all related records deleted`,
deletedOrders: existingCustomer.orders.length,
})
} catch (error) {
console.error('Error deleting customer:', error)
return NextResponse.json(
{ error: 'Failed to delete customer' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { UserStatus, Prisma } from '@prisma/client'
import bcrypt from 'bcryptjs'
interface CreateCustomerRequest {
email: string
name?: string
company?: string
status?: UserStatus
}
/**
* GET /api/v1/admin/customers
* List all customers with optional filters
*/
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const searchParams = request.nextUrl.searchParams
const status = searchParams.get('status') as UserStatus | null
const search = searchParams.get('search')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const where: Prisma.UserWhereInput = {}
if (status) {
where.status = status
}
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
{ company: { contains: search, mode: 'insensitive' } },
]
}
const [customers, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
company: true,
status: true,
createdAt: true,
subscriptions: {
select: {
id: true,
plan: true,
tier: true,
status: true,
},
take: 1,
orderBy: { createdAt: 'desc' },
},
_count: {
select: {
orders: true,
subscriptions: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where }),
])
return NextResponse.json({
customers,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Error listing customers:', error)
return NextResponse.json(
{ error: 'Failed to list customers' },
{ status: 500 }
)
}
}
/**
* POST /api/v1/admin/customers
* Create a new customer
*/
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateCustomerRequest = await request.json()
// Validate required fields
if (!body.email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
)
}
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email: body.email },
})
if (existingUser) {
return NextResponse.json(
{ error: 'A customer with this email already exists' },
{ status: 409 }
)
}
// Generate a random password (customer will need to reset it)
const tempPassword = Math.random().toString(36).slice(-12)
const passwordHash = await bcrypt.hash(tempPassword, 10)
// Create the customer
const customer = await prisma.user.create({
data: {
email: body.email,
name: body.name || null,
company: body.company || null,
status: body.status || 'PENDING_VERIFICATION',
passwordHash,
},
select: {
id: true,
email: true,
name: true,
company: true,
status: true,
createdAt: true,
subscriptions: {
select: {
id: true,
plan: true,
tier: true,
status: true,
},
},
_count: {
select: {
orders: true,
subscriptions: true,
},
},
},
})
return NextResponse.json(customer, { status: 201 })
} catch (error) {
console.error('Error creating customer:', error)
return NextResponse.json(
{ error: 'Failed to create customer' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { containerHealthService } from '@/lib/services/container-health-service'
import type { ContainerEventType } from '@prisma/client'
// GET /api/v1/admin/enterprise-clients/[id]/container-events
// List container events (crashes, restarts, etc.) for a client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
const { searchParams } = new URL(request.url)
// Verify client exists
const client = await prisma.enterpriseClient.findUnique({
where: { id: clientId },
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
// Parse filters
const eventType = searchParams.get('type') as ContainerEventType | undefined
const serverId = searchParams.get('serverId') || undefined
const limit = parseInt(searchParams.get('limit') || '50', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
try {
const result = await containerHealthService.getUnacknowledgedEvents(clientId, {
eventType,
serverId,
limit: Math.min(limit, 200),
offset,
})
return NextResponse.json({
events: result.events,
total: result.total,
pagination: {
limit,
offset,
hasMore: offset + result.events.length < result.total,
},
})
} catch (error) {
console.error('Failed to get container events:', error)
return NextResponse.json(
{ error: 'Failed to get container events' },
{ status: 500 }
)
}
}
// POST /api/v1/admin/enterprise-clients/[id]/container-events
// Acknowledge container events
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
// Verify client exists
const client = await prisma.enterpriseClient.findUnique({
where: { id: clientId },
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
try {
const body = await request.json()
const { eventIds } = body as { eventIds: string[] }
if (!eventIds || !Array.isArray(eventIds) || eventIds.length === 0) {
return NextResponse.json(
{ error: 'eventIds array is required' },
{ status: 400 }
)
}
// Verify events belong to this client
const events = await prisma.containerEvent.findMany({
where: {
id: { in: eventIds },
server: { clientId },
},
})
if (events.length !== eventIds.length) {
return NextResponse.json(
{ error: 'Some events not found or do not belong to this client' },
{ status: 400 }
)
}
const userId = session.user.email || 'unknown'
const acknowledgedCount = await containerHealthService.acknowledgeEvents(eventIds, userId)
return NextResponse.json({
success: true,
acknowledged: acknowledgedCount,
})
} catch (error) {
console.error('Failed to acknowledge container events:', error)
return NextResponse.json(
{ error: 'Failed to acknowledge container events' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { errorDashboardService } from '@/lib/services/error-dashboard-service'
// GET /api/v1/admin/enterprise-clients/[id]/error-dashboard
// Get aggregated error dashboard data for a client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
// Verify client exists
const client = await prisma.enterpriseClient.findUnique({
where: { id: clientId },
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
try {
const dashboard = await errorDashboardService.getClientDashboard(clientId)
return NextResponse.json(dashboard)
} catch (error) {
console.error('Failed to get error dashboard:', error)
return NextResponse.json(
{ error: 'Failed to get error dashboard' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
getErrorRule,
updateErrorRule,
deleteErrorRule,
} from '@/lib/services/error-detection-service'
import type { ErrorSeverity } from '@prisma/client'
// GET /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]
// Get a specific error detection rule
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; ruleId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, ruleId } = await params
// Verify rule belongs to client
const rule = await prisma.errorDetectionRule.findFirst({
where: {
id: ruleId,
clientId,
},
})
if (!rule) {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
}
try {
const ruleWithCount = await getErrorRule(ruleId)
return NextResponse.json(ruleWithCount)
} catch (error) {
console.error('Failed to get error rule:', error)
return NextResponse.json(
{ error: 'Failed to get error rule' },
{ status: 500 }
)
}
}
// PATCH /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]
// Update an error detection rule
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string; ruleId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, ruleId } = await params
const body = await request.json()
// Verify rule belongs to client
const existingRule = await prisma.errorDetectionRule.findFirst({
where: {
id: ruleId,
clientId,
},
})
if (!existingRule) {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
}
// Validate severity if provided
const validSeverities: ErrorSeverity[] = ['INFO', 'WARNING', 'ERROR', 'CRITICAL']
if (body.severity && !validSeverities.includes(body.severity)) {
return NextResponse.json(
{ error: 'Invalid severity. Must be one of: INFO, WARNING, ERROR, CRITICAL' },
{ status: 400 }
)
}
try {
const rule = await updateErrorRule(ruleId, {
name: body.name,
pattern: body.pattern,
severity: body.severity,
description: body.description,
isActive: body.isActive,
})
return NextResponse.json(rule)
} catch (error) {
console.error('Failed to update error rule:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update error rule' },
{ status: 400 }
)
}
}
// DELETE /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]
// Delete an error detection rule
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; ruleId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, ruleId } = await params
// Verify rule belongs to client
const rule = await prisma.errorDetectionRule.findFirst({
where: {
id: ruleId,
clientId,
},
})
if (!rule) {
return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
}
try {
await deleteErrorRule(ruleId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete error rule:', error)
return NextResponse.json(
{ error: 'Failed to delete error rule' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
getErrorRules,
createErrorRule,
seedDefaultRules,
} from '@/lib/services/error-detection-service'
import type { ErrorSeverity } from '@prisma/client'
// GET /api/v1/admin/enterprise-clients/[id]/error-rules
// List all error detection rules for a client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
// Verify client exists
const client = await prisma.enterpriseClient.findUnique({
where: { id: clientId },
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
try {
const rules = await getErrorRules(clientId)
return NextResponse.json(rules)
} catch (error) {
console.error('Failed to get error rules:', error)
return NextResponse.json(
{ error: 'Failed to get error rules' },
{ status: 500 }
)
}
}
// POST /api/v1/admin/enterprise-clients/[id]/error-rules
// Create a new error detection rule
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
const body = await request.json()
// Verify client exists
const client = await prisma.enterpriseClient.findUnique({
where: { id: clientId },
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
// Check if this is a request to seed default rules
if (body.seedDefaults === true) {
try {
const count = await seedDefaultRules(clientId)
return NextResponse.json({
success: true,
message: `Seeded ${count} default rules`,
count,
})
} catch (error) {
console.error('Failed to seed default rules:', error)
return NextResponse.json(
{ error: 'Failed to seed default rules' },
{ status: 500 }
)
}
}
// Validate required fields
if (!body.name || typeof body.name !== 'string') {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
if (!body.pattern || typeof body.pattern !== 'string') {
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
}
// Validate severity if provided
const validSeverities: ErrorSeverity[] = ['INFO', 'WARNING', 'ERROR', 'CRITICAL']
if (body.severity && !validSeverities.includes(body.severity)) {
return NextResponse.json(
{ error: 'Invalid severity. Must be one of: INFO, WARNING, ERROR, CRITICAL' },
{ status: 400 }
)
}
try {
const rule = await createErrorRule(clientId, {
name: body.name,
pattern: body.pattern,
severity: body.severity,
description: body.description,
})
return NextResponse.json(rule, { status: 201 })
} catch (error) {
console.error('Failed to create error rule:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to create error rule' },
{ status: 400 }
)
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { acknowledgeError } from '@/lib/services/error-detection-service'
// POST /api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge
// Acknowledge a detected error
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; errorId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, errorId } = await params
// Verify error belongs to a server owned by this client
const error = await prisma.detectedError.findFirst({
where: {
id: errorId,
server: {
clientId,
},
},
include: {
server: true,
},
})
if (!error) {
return NextResponse.json({ error: 'Error not found' }, { status: 404 })
}
if (error.acknowledgedAt) {
return NextResponse.json({ error: 'Error already acknowledged' }, { status: 400 })
}
try {
const userId = session.user.id || 'unknown'
await acknowledgeError(errorId, userId)
return NextResponse.json({ success: true })
} catch (err) {
console.error('Failed to acknowledge error:', err)
return NextResponse.json(
{ error: 'Failed to acknowledge error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getDetectedErrors } from '@/lib/services/error-detection-service'
import type { ErrorSeverity } from '@prisma/client'
// GET /api/v1/admin/enterprise-clients/[id]/errors
// List detected errors for a client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
const { searchParams } = new URL(request.url)
// Verify client exists
const client = await prisma.enterpriseClient.findUnique({
where: { id: clientId },
})
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
// Parse filters
const serverId = searchParams.get('serverId') || undefined
const severity = searchParams.get('severity') as ErrorSeverity | undefined
const acknowledgedParam = searchParams.get('acknowledged')
const acknowledged = acknowledgedParam === 'true' ? true : acknowledgedParam === 'false' ? false : undefined
const ruleId = searchParams.get('ruleId') || undefined
const limit = parseInt(searchParams.get('limit') || '100', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
try {
const errors = await getDetectedErrors(clientId, {
serverId,
severity,
acknowledged,
ruleId,
limit: Math.min(limit, 500), // Cap at 500
offset,
})
return NextResponse.json(errors)
} catch (error) {
console.error('Failed to get detected errors:', error)
return NextResponse.json(
{ error: 'Failed to get detected errors' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { notificationService } from '@/lib/services/notification-service'
import { z } from 'zod'
// Validation schema for updating notification settings
const updateNotificationSettingsSchema = z.object({
enabled: z.boolean().optional(),
criticalErrorsOnly: z.boolean().optional(),
containerCrashes: z.boolean().optional(),
recipients: z.array(z.string().email()).optional(),
cooldownMinutes: z.number().min(5).max(1440).optional(), // 5 min to 24 hours
})
// GET /api/v1/admin/enterprise-clients/[id]/notifications
// Get notification settings for a client
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
try {
const settings = await notificationService.getNotificationSettings(clientId)
return NextResponse.json({
enabled: settings.enabled,
criticalErrorsOnly: settings.criticalErrorsOnly,
containerCrashes: settings.containerCrashes,
recipients: settings.recipients,
cooldownMinutes: settings.cooldownMinutes,
lastNotifiedAt: settings.lastNotifiedAt,
})
} catch (error) {
console.error('[API] Error fetching notification settings:', error)
return NextResponse.json(
{ error: 'Failed to fetch notification settings' },
{ status: 500 }
)
}
}
// PATCH /api/v1/admin/enterprise-clients/[id]/notifications
// Update notification settings for a client
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId } = await params
try {
const body = await request.json()
const parsed = updateNotificationSettingsSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request body', details: parsed.error.format() },
{ status: 400 }
)
}
const settings = await notificationService.updateNotificationSettings(
clientId,
parsed.data
)
return NextResponse.json({
enabled: settings.enabled,
criticalErrorsOnly: settings.criticalErrorsOnly,
containerCrashes: settings.containerCrashes,
recipients: settings.recipients,
cooldownMinutes: settings.cooldownMinutes,
lastNotifiedAt: settings.lastNotifiedAt,
})
} catch (error) {
console.error('[API] Error updating notification settings:', error)
return NextResponse.json(
{ error: 'Failed to update notification settings' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
import { z } from 'zod'
interface RouteContext {
params: Promise<{ id: string }>
}
const updateClientSchema = z.object({
name: z.string().min(1).optional(),
companyName: z.string().optional().nullable(),
contactEmail: z.string().email().optional(),
contactPhone: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
isActive: z.boolean().optional()
})
/**
* GET /api/v1/admin/enterprise-clients/[id]
* Get enterprise client details with servers and stats overview
*/
export async function GET(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await context.params
try {
const [client, statsOverview] = await Promise.all([
enterpriseClientService.getClient(id),
enterpriseClientService.getClientStatsOverview(id)
])
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
return NextResponse.json({
...client,
statsOverview
})
} catch (error) {
console.error('Failed to get enterprise client:', error)
return NextResponse.json(
{ error: 'Failed to get enterprise client' },
{ status: 500 }
)
}
}
/**
* PATCH /api/v1/admin/enterprise-clients/[id]
* Update enterprise client
*/
export async function PATCH(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await context.params
try {
const body = await request.json()
const validation = updateClientSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten() },
{ status: 400 }
)
}
// Check if client exists
const existingClient = await enterpriseClientService.getClient(id)
if (!existingClient) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
const client = await enterpriseClientService.updateClient(id, validation.data)
return NextResponse.json(client)
} catch (error) {
console.error('Failed to update enterprise client:', error)
return NextResponse.json(
{ error: 'Failed to update enterprise client' },
{ status: 500 }
)
}
}
/**
* DELETE /api/v1/admin/enterprise-clients/[id]
* Delete enterprise client
*/
export async function DELETE(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await context.params
try {
// Check if client exists
const existingClient = await enterpriseClientService.getClient(id)
if (!existingClient) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
await enterpriseClientService.deleteClient(id)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete enterprise client:', error)
return NextResponse.json(
{ error: 'Failed to delete enterprise client' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
import { securityVerificationService } from '@/lib/services/security-verification-service'
import { netcupService } from '@/lib/services/netcup-service'
import { z } from 'zod'
interface RouteContext {
params: Promise<{ id: string; serverId: string }>
}
const powerActionSchema = z.object({
action: z.literal('power'),
command: z.enum(['ON', 'OFF', 'POWERCYCLE', 'RESET', 'POWEROFF'])
})
const verifiedActionSchema = z.object({
action: z.enum(['wipe', 'reinstall']),
verificationCode: z.string().length(6, 'Verification code must be 6 digits'),
imageId: z.string().optional() // Required for reinstall
})
const actionSchema = z.discriminatedUnion('action', [
powerActionSchema,
verifiedActionSchema.extend({ action: z.literal('wipe') }),
verifiedActionSchema.extend({ action: z.literal('reinstall'), imageId: z.string() })
])
/**
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions
* Perform server action (power control or verified wipe/reinstall)
*/
export async function POST(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
try {
const body = await request.json()
const validation = actionSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten() },
{ status: 400 }
)
}
// Check if server exists and belongs to client
const server = await enterpriseClientService.getServer(clientId, serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const data = validation.data
// Handle power actions (no verification needed)
if (data.action === 'power') {
await netcupService.powerAction(server.netcupServerId, data.command)
return NextResponse.json({
success: true,
message: `Power action ${data.command} initiated`
})
}
// Handle verified actions (wipe/reinstall)
if (data.action === 'wipe' || data.action === 'reinstall') {
// Verify the code
const verifyResult = await securityVerificationService.verifyCode(
clientId,
data.verificationCode
)
if (!verifyResult.valid) {
return NextResponse.json(
{ error: verifyResult.errorMessage || 'Invalid verification code' },
{ status: 400 }
)
}
// Ensure the code was for the correct action and server
if (verifyResult.action?.toLowerCase() !== data.action) {
return NextResponse.json(
{ error: 'Verification code was issued for a different action' },
{ status: 400 }
)
}
if (verifyResult.serverId !== serverId) {
return NextResponse.json(
{ error: 'Verification code was issued for a different server' },
{ status: 400 }
)
}
// Execute the action
if (data.action === 'reinstall' && 'imageId' in data) {
const task = await netcupService.reinstallServer(
server.netcupServerId,
data.imageId
)
return NextResponse.json({
success: true,
message: 'Server reinstall initiated',
taskId: task.taskId
})
} else if (data.action === 'wipe') {
// Wipe is essentially a reinstall with the same image
// First get available images
const images = await netcupService.getImageFlavours(server.netcupServerId)
const defaultImage = images.find(img => img.name.toLowerCase().includes('debian')) || images[0]
if (!defaultImage) {
return NextResponse.json(
{ error: 'No image available for wipe operation' },
{ status: 400 }
)
}
const task = await netcupService.reinstallServer(
server.netcupServerId,
defaultImage.id
)
return NextResponse.json({
success: true,
message: 'Server wipe initiated',
taskId: task.taskId
})
}
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
} catch (error) {
console.error('Failed to execute server action:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to execute action' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createPortainerClientForServer } from '@/lib/services/portainer-client'
// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs
// Get container logs
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId, containerId } = await params
const { searchParams } = new URL(request.url)
const tail = parseInt(searchParams.get('tail') || '500', 10)
// Verify server belongs to client
const server = await prisma.enterpriseServer.findFirst({
where: {
id: serverId,
clientId,
},
})
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
)
}
const portainerClient = await createPortainerClientForServer(serverId)
if (!portainerClient) {
return NextResponse.json(
{ error: 'Portainer not configured for this server' },
{ status: 400 }
)
}
try {
const logs = await portainerClient.getContainerLogs(containerId, tail)
return NextResponse.json({
containerId,
tail,
logs,
timestamp: new Date().toISOString(),
})
} catch (error) {
console.error('Failed to get container logs:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to get container logs' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,198 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createPortainerClientForServer } from '@/lib/services/portainer-client'
// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]
// Get container details
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId, containerId } = await params
// Verify server belongs to client
const server = await prisma.enterpriseServer.findFirst({
where: {
id: serverId,
clientId,
},
})
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
)
}
const portainerClient = await createPortainerClientForServer(serverId)
if (!portainerClient) {
return NextResponse.json(
{ error: 'Portainer not configured for this server' },
{ status: 400 }
)
}
try {
const container = await portainerClient.getContainer(containerId)
const stats = await portainerClient.getContainerStats(containerId)
return NextResponse.json({
id: container.Id,
name: container.Name.replace(/^\//, ''),
image: container.Image,
created: container.Created,
state: container.State,
config: {
hostname: container.Config.Hostname,
env: container.Config.Env,
image: container.Config.Image,
workingDir: container.Config.WorkingDir,
},
hostConfig: {
restartPolicy: container.HostConfig.RestartPolicy,
},
networkSettings: {
networks: container.NetworkSettings.Networks,
ports: container.NetworkSettings.Ports,
},
stats,
})
} catch (error) {
console.error('Failed to get container:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to get container' },
{ status: 500 }
)
}
}
// DELETE /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]
// Remove a container
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId, containerId } = await params
const { searchParams } = new URL(request.url)
const force = searchParams.get('force') === 'true'
// Verify server belongs to client
const server = await prisma.enterpriseServer.findFirst({
where: {
id: serverId,
clientId,
},
})
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
)
}
const portainerClient = await createPortainerClientForServer(serverId)
if (!portainerClient) {
return NextResponse.json(
{ error: 'Portainer not configured for this server' },
{ status: 400 }
)
}
try {
await portainerClient.removeContainer(containerId, force)
return NextResponse.json({
success: true,
message: 'Container removed successfully',
})
} catch (error) {
console.error('Failed to remove container:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to remove container' },
{ status: 500 }
)
}
}
// POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]
// Perform container action (start, stop, restart)
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; serverId: string; containerId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId, containerId } = await params
const body = await request.json()
const action = body.action as 'start' | 'stop' | 'restart'
if (!['start', 'stop', 'restart'].includes(action)) {
return NextResponse.json(
{ error: 'Invalid action. Must be one of: start, stop, restart' },
{ status: 400 }
)
}
// Verify server belongs to client
const server = await prisma.enterpriseServer.findFirst({
where: {
id: serverId,
clientId,
},
})
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
)
}
const portainerClient = await createPortainerClientForServer(serverId)
if (!portainerClient) {
return NextResponse.json(
{ error: 'Portainer not configured for this server' },
{ status: 400 }
)
}
try {
switch (action) {
case 'start':
await portainerClient.startContainer(containerId)
break
case 'stop':
await portainerClient.stopContainer(containerId)
break
case 'restart':
await portainerClient.restartContainer(containerId)
break
}
return NextResponse.json({
success: true,
message: `Container ${action} successful`,
})
} catch (error) {
console.error(`Failed to ${action} container:`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : `Failed to ${action} container` },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createPortainerClientForServer } from '@/lib/services/portainer-client'
// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers
// List all containers for a server
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; serverId: string }> }
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await params
const { searchParams } = new URL(request.url)
const all = searchParams.get('all') !== 'false' // Default to showing all containers
// Verify server belongs to client
const server = await prisma.enterpriseServer.findFirst({
where: {
id: serverId,
clientId,
},
})
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
)
}
// Check if Portainer is configured
const portainerClient = await createPortainerClientForServer(serverId)
if (!portainerClient) {
return NextResponse.json(
{ error: 'Portainer not configured for this server' },
{ status: 400 }
)
}
try {
// List containers
const containers = await portainerClient.listContainers(all)
// Get stats for running containers
const stats = await portainerClient.getAllContainerStats()
// Combine containers with stats
const containersWithStats = containers.map(container => ({
id: container.Id,
names: container.Names.map(n => n.replace(/^\//, '')), // Remove leading slash
image: container.Image,
imageId: container.ImageID,
command: container.Command,
created: container.Created,
state: container.State,
status: container.Status,
ports: container.Ports,
labels: container.Labels,
networks: container.NetworkSettings?.Networks || {},
stats: stats[container.Id] || null,
}))
return NextResponse.json({
serverId,
containers: containersWithStats,
total: containersWithStats.length,
running: containersWithStats.filter(c => c.state === 'running').length,
stopped: containersWithStats.filter(c => c.state !== 'running').length,
})
} catch (error) {
console.error('Failed to list containers:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to list containers' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
import { netcupService } from '@/lib/services/netcup-service'
import { z } from 'zod'
interface RouteContext {
params: Promise<{ id: string; serverId: string }>
}
const updateServerSchema = z.object({
nickname: z.string().optional().nullable(),
purpose: z.string().optional().nullable(),
isActive: z.boolean().optional(),
portainerUrl: z.string().url().optional().nullable(),
portainerUsername: z.string().optional().nullable(),
portainerPassword: z.string().optional()
})
/**
* GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]
* Get server details with Netcup live info
*/
export async function GET(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
try {
const server = await enterpriseClientService.getServer(clientId, serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
// Get Netcup live info
let netcupInfo = null
try {
netcupInfo = await netcupService.getServer(server.netcupServerId, true)
} catch (error) {
console.error('Failed to get Netcup server info:', error)
}
return NextResponse.json({
...server,
netcup: netcupInfo
})
} catch (error) {
console.error('Failed to get server:', error)
return NextResponse.json(
{ error: 'Failed to get server' },
{ status: 500 }
)
}
}
/**
* PATCH /api/v1/admin/enterprise-clients/[id]/servers/[serverId]
* Update server
*/
export async function PATCH(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
try {
const body = await request.json()
const validation = updateServerSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten() },
{ status: 400 }
)
}
// Check if server exists and belongs to client
const existingServer = await enterpriseClientService.getServer(clientId, serverId)
if (!existingServer) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const server = await enterpriseClientService.updateServer(serverId, validation.data)
return NextResponse.json(server)
} catch (error) {
console.error('Failed to update server:', error)
return NextResponse.json(
{ error: 'Failed to update server' },
{ status: 500 }
)
}
}
/**
* DELETE /api/v1/admin/enterprise-clients/[id]/servers/[serverId]
* Remove server from client (does not delete from Netcup)
*/
export async function DELETE(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
try {
// Check if server exists and belongs to client
const existingServer = await enterpriseClientService.getServer(clientId, serverId)
if (!existingServer) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
await enterpriseClientService.removeServer(clientId, serverId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to remove server:', error)
return NextResponse.json(
{ error: 'Failed to remove server' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
import { statsCollectionService } from '@/lib/services/stats-collection-service'
import type { StatsRange } from '@/lib/services/stats-collection-service'
interface RouteContext {
params: Promise<{ id: string; serverId: string }>
}
/**
* GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats
* Get stats history for a server
*/
export async function GET(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
const searchParams = request.nextUrl.searchParams
const range = (searchParams.get('range') || '24h') as StatsRange
try {
// Verify server belongs to client
const server = await enterpriseClientService.getServer(clientId, serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const [history, latest] = await Promise.all([
statsCollectionService.getServerStatsHistory(serverId, range),
statsCollectionService.getServerLatestStats(serverId)
])
return NextResponse.json({
serverId,
range,
latest,
history,
dataPoints: history.length
})
} catch (error) {
console.error('Failed to get server stats:', error)
return NextResponse.json(
{ error: 'Failed to get server stats' },
{ status: 500 }
)
}
}
/**
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats
* Trigger manual stats collection for a server
*/
export async function POST(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
try {
// Verify server belongs to client
const server = await enterpriseClientService.getServer(clientId, serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const snapshot = await statsCollectionService.collectServerStats(serverId)
if (!snapshot) {
return NextResponse.json(
{ error: 'Failed to collect stats' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
message: 'Stats collected successfully',
snapshot: {
id: snapshot.id,
timestamp: snapshot.timestamp,
cpuPercent: snapshot.cpuPercent,
memoryUsedMb: snapshot.memoryUsedMb,
memoryTotalMb: snapshot.memoryTotalMb,
diskReadMbps: snapshot.diskReadMbps,
diskWriteMbps: snapshot.diskWriteMbps,
networkInMbps: snapshot.networkInMbps,
networkOutMbps: snapshot.networkOutMbps
}
})
} catch (error) {
console.error('Failed to collect server stats:', error)
return NextResponse.json(
{ error: 'Failed to collect stats' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { PortainerClient } from '@/lib/services/portainer-client'
interface RouteParams {
params: Promise<{
id: string
serverId: string
}>
}
/**
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer
* Test Portainer connection with provided credentials (doesn't require saved credentials)
*/
export async function POST(request: NextRequest, { params }: RouteParams) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await params
try {
const body = await request.json()
const { portainerUrl, portainerUsername, portainerPassword } = body
if (!portainerUrl || !portainerUsername || !portainerPassword) {
return NextResponse.json(
{ error: 'Missing required fields: portainerUrl, portainerUsername, portainerPassword' },
{ status: 400 }
)
}
// Create a temporary client with provided credentials
const client = new PortainerClient({
url: portainerUrl,
username: portainerUsername,
password: portainerPassword,
})
// Test the connection
const success = await client.testConnection()
if (success) {
return NextResponse.json({
success: true,
message: 'Connection successful',
})
} else {
return NextResponse.json({
success: false,
message: 'Connection failed - check credentials and URL',
})
}
} catch (error) {
console.error(`[API] Test Portainer connection failed for server ${serverId} in client ${clientId}:`, error)
const message = error instanceof Error ? error.message : 'Unknown error'
// Provide more specific error messages
if (message.includes('ECONNREFUSED') || message.includes('ENOTFOUND')) {
return NextResponse.json({
success: false,
message: 'Cannot connect to Portainer - check the URL is correct and the server is reachable',
})
}
if (message.includes('401') || message.includes('authentication failed')) {
return NextResponse.json({
success: false,
message: 'Authentication failed - check username and password',
})
}
return NextResponse.json({
success: false,
message: `Connection test failed: ${message}`,
})
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
import { securityVerificationService } from '@/lib/services/security-verification-service'
import { z } from 'zod'
interface RouteContext {
params: Promise<{ id: string; serverId: string }>
}
const requestCodeSchema = z.object({
action: z.enum(['WIPE', 'REINSTALL'])
})
/**
* POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify
* Request a verification code for a destructive action
*/
export async function POST(
request: NextRequest,
context: RouteContext
) {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: clientId, serverId } = await context.params
try {
const body = await request.json()
const validation = requestCodeSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten() },
{ status: 400 }
)
}
// Check if server exists and belongs to client
const server = await enterpriseClientService.getServer(clientId, serverId)
if (!server) {
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
// Request verification code
const result = await securityVerificationService.requestVerificationCode(
clientId,
serverId,
validation.data.action
)
return NextResponse.json({
success: true,
message: `Verification code sent to ${result.email}`,
email: result.email,
expiresAt: result.expiresAt.toISOString()
})
} catch (error) {
console.error('Failed to request verification code:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to request verification code' },
{ status: 500 }
)
}
}

Some files were not shown because too many files have changed in this diff Show More