Complete Hub Admin Dashboard with analytics, settings, and enterprise features
Major additions: - Analytics dashboard with charts (line, bar, donut) - Enterprise client monitoring with container management - Staff management with 2FA support - Profile management and settings pages - Netcup server integration - DNS verification panel - Portainer integration - Container logs and health monitoring - Automation controls for orders New API endpoints: - /api/v1/admin/analytics - /api/v1/admin/enterprise-clients - /api/v1/admin/netcup - /api/v1/admin/settings - /api/v1/admin/staff - /api/v1/profile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
60493cfbdd
commit
92092760a7
|
|
@ -50,3 +50,6 @@ Thumbs.db
|
||||||
# Prisma
|
# Prisma
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
prisma/*.db-journal
|
prisma/*.db-journal
|
||||||
|
|
||||||
|
# Job runtime data
|
||||||
|
jobs/
|
||||||
|
|
|
||||||
146
CLAUDE.md
146
CLAUDE.md
|
|
@ -11,6 +11,7 @@ The Hub provides:
|
||||||
- **Customer Management**: Create/manage customers and subscriptions
|
- **Customer Management**: Create/manage customers and subscriptions
|
||||||
- **Order Management**: Process and track provisioning orders
|
- **Order Management**: Process and track provisioning orders
|
||||||
- **Server Monitoring**: View and manage tenant servers
|
- **Server Monitoring**: View and manage tenant servers
|
||||||
|
- **Netcup Integration**: Full server management via Netcup SCP API
|
||||||
- **Token Usage Tracking**: Monitor AI token consumption
|
- **Token Usage Tracking**: Monitor AI token consumption
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
@ -29,19 +30,40 @@ src/
|
||||||
├── app/ # Next.js App Router
|
├── app/ # Next.js App Router
|
||||||
│ ├── admin/ # Admin dashboard pages
|
│ ├── admin/ # Admin dashboard pages
|
||||||
│ │ ├── customers/ # Customer management
|
│ │ ├── customers/ # Customer management
|
||||||
|
│ │ │ └── [id]/ # Customer detail with order creation
|
||||||
│ │ ├── orders/ # Order management
|
│ │ ├── orders/ # Order management
|
||||||
|
│ │ │ └── [id]/ # Order detail with DNS, provisioning
|
||||||
│ │ ├── servers/ # Server monitoring
|
│ │ ├── servers/ # Server monitoring
|
||||||
|
│ │ │ └── netcup/ # Netcup servers management
|
||||||
|
│ │ │ └── [id]/ # Netcup server detail
|
||||||
|
│ │ ├── settings/ # Admin settings
|
||||||
│ │ └── layout.tsx # Admin layout with sidebar
|
│ │ └── layout.tsx # Admin layout with sidebar
|
||||||
│ ├── api/v1/ # API routes
|
│ ├── api/v1/ # API routes
|
||||||
│ │ ├── admin/ # Admin API endpoints
|
│ │ ├── admin/ # Admin API endpoints
|
||||||
|
│ │ │ ├── customers/ # Customer CRUD
|
||||||
|
│ │ │ ├── orders/ # Order CRUD + provisioning
|
||||||
|
│ │ │ ├── netcup/ # Netcup SCP API integration
|
||||||
|
│ │ │ └── servers/ # Server management
|
||||||
│ │ └── public/ # Public API endpoints
|
│ │ └── public/ # Public API endpoints
|
||||||
│ └── (auth)/ # Authentication pages
|
│ └── (auth)/ # Authentication pages
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── admin/ # Admin-specific components
|
│ ├── admin/ # Admin-specific components
|
||||||
│ └── ui/ # Reusable UI 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
|
├── 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
|
├── lib/ # Utilities and shared code
|
||||||
│ └── prisma.ts # Prisma client singleton
|
│ ├── 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
|
└── types/ # TypeScript type definitions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -61,17 +83,70 @@ POST /api/v1/admin/orders # Create order
|
||||||
GET /api/v1/admin/orders/[id] # Get order detail
|
GET /api/v1/admin/orders/[id] # Get order detail
|
||||||
PATCH /api/v1/admin/orders/[id] # Update order
|
PATCH /api/v1/admin/orders/[id] # Update order
|
||||||
GET /api/v1/admin/orders/[id]/logs # Get provisioning logs (SSE)
|
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
|
# Servers
|
||||||
GET /api/v1/admin/servers # List servers (derived from orders)
|
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
|
# Dashboard
|
||||||
GET /api/v1/admin/dashboard/stats # Dashboard statistics
|
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
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Start database (required first)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
|
@ -105,15 +180,27 @@ All data fetching uses React Query hooks in `src/hooks/`:
|
||||||
- `useOrders()`, `useOrder(id)` - Order data
|
- `useOrders()`, `useOrder(id)` - Order data
|
||||||
- `useServers()` - Server list
|
- `useServers()` - Server list
|
||||||
- `useDashboardStats()` - Dashboard metrics
|
- `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:
|
Mutations follow the pattern:
|
||||||
- `useCreateOrder()`, `useUpdateOrder()`
|
- `useCreateOrder()`, `useUpdateOrder()`
|
||||||
|
- `useNetcupPowerAction()`, `useNetcupRescue()`
|
||||||
|
- `useCreateSnapshot()`, `useDeleteSnapshot()`, `useRevertSnapshot()`
|
||||||
- Automatic cache invalidation via `queryClient.invalidateQueries()`
|
- Automatic cache invalidation via `queryClient.invalidateQueries()`
|
||||||
|
|
||||||
### API Route Pattern
|
### API Route Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export async function GET(request: NextRequest) {
|
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
|
// Parse query params
|
||||||
const searchParams = request.nextUrl.searchParams
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
|
||||||
|
|
@ -140,6 +227,59 @@ export function MyComponent() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
## Coding Conventions
|
||||||
|
|
||||||
- Use `'use client'` directive for client components
|
- Use `'use client'` directive for client components
|
||||||
|
|
@ -148,3 +288,5 @@ export function MyComponent() {
|
||||||
- Follow existing shadcn/ui component patterns
|
- Follow existing shadcn/ui component patterns
|
||||||
- Use React Query for server state management
|
- Use React Query for server state management
|
||||||
- TypeScript strict mode - no `any` types
|
- TypeScript strict mode - no `any` types
|
||||||
|
- Services are singletons exported from `lib/services/`
|
||||||
|
- Environment variables in `.env.local` (never commit)
|
||||||
|
|
|
||||||
12
Dockerfile
12
Dockerfile
|
|
@ -34,9 +34,21 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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 addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
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
|
# Create public directory and copy contents if they exist
|
||||||
RUN mkdir -p public
|
RUN mkdir -p public
|
||||||
COPY --from=builder /app/public/. ./public/
|
COPY --from=builder /app/public/. ./public/
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,25 @@ services:
|
||||||
DATABASE_URL: postgresql://letsbe_hub:letsbe_hub_dev@db:5432/letsbe_hub
|
DATABASE_URL: postgresql://letsbe_hub:letsbe_hub_dev@db:5432/letsbe_hub
|
||||||
NEXTAUTH_URL: http://localhost:3000
|
NEXTAUTH_URL: http://localhost:3000
|
||||||
NEXTAUTH_SECRET: dev-secret-change-in-production-min-32-chars
|
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
|
||||||
|
# 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { NextConfig } from 'next'
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
// reactCompiler: true, // Requires babel-plugin-react-compiler - enable later
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '2mb',
|
bodySizeLimit: '2mb',
|
||||||
|
|
@ -15,6 +16,21 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// 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'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
|
|
@ -19,52 +19,69 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
|
"@aws-sdk/client-s3": "^3.968.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@prisma/client": "^6.2.1",
|
"@prisma/client": "^6.2.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-select": "^2.1.4",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@tanstack/react-query": "^5.64.2",
|
"@tanstack/react-query": "^5.64.2",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "15.1.4",
|
"next": "16.1.1",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.30",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^19.0.0",
|
"nodemailer": "^7.0.12",
|
||||||
"react-dom": "^19.0.0",
|
"otplib": "^13.1.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"undici": "^7.18.2",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/nodemailer": "^7.0.5",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.0.4",
|
"@types/react": "^19.0.4",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "15.1.4",
|
"eslint-config-next": "16.1.1",
|
||||||
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^4.0.16",
|
||||||
|
"vitest-mock-extended": "^3.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "staff" ADD COLUMN "profile_photo_key" TEXT;
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -21,8 +21,15 @@ enum UserStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StaffRole {
|
enum StaffRole {
|
||||||
ADMIN
|
OWNER // Full access, cannot be deleted
|
||||||
SUPPORT
|
ADMIN // Full access, can manage staff
|
||||||
|
MANAGER // Orders + servers, no staff/settings
|
||||||
|
SUPPORT // View only + limited actions
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StaffStatus {
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionPlan {
|
enum SubscriptionPlan {
|
||||||
|
|
@ -56,6 +63,21 @@ enum OrderStatus {
|
||||||
FAILED
|
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 {
|
enum JobStatus {
|
||||||
PENDING
|
PENDING
|
||||||
CLAIMED
|
CLAIMED
|
||||||
|
|
@ -72,6 +94,37 @@ enum LogLevel {
|
||||||
ERROR
|
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
|
// USER & STAFF MODELS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -87,6 +140,12 @@ model User {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_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[]
|
subscriptions Subscription[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
tokenUsage TokenUsage[]
|
tokenUsage TokenUsage[]
|
||||||
|
|
@ -95,17 +154,40 @@ model User {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Staff {
|
model Staff {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
name String
|
name String
|
||||||
role StaffRole @default(SUPPORT)
|
role StaffRole @default(SUPPORT)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
status StaffStatus @default(ACTIVE)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
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")
|
@@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
|
// SUBSCRIPTION & BILLING
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -134,38 +216,98 @@ model Subscription {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
status OrderStatus @default(PAYMENT_CONFIRMED)
|
status OrderStatus @default(PAYMENT_CONFIRMED)
|
||||||
tier SubscriptionTier
|
tier SubscriptionTier
|
||||||
domain String
|
domain String
|
||||||
tools String[]
|
tools String[]
|
||||||
configJson Json @map("config_json")
|
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)
|
// Server credentials (entered by staff)
|
||||||
serverIp String? @map("server_ip")
|
serverIp String? @map("server_ip")
|
||||||
serverPasswordEncrypted String? @map("server_password_encrypted")
|
serverPasswordEncrypted String? @map("server_password_encrypted")
|
||||||
sshPort Int @default(22) @map("ssh_port")
|
sshPort Int @default(22) @map("ssh_port")
|
||||||
|
netcupServerId String? @map("netcup_server_id") // Netcup API server ID for linking
|
||||||
|
|
||||||
// Generated after provisioning
|
// Generated after provisioning
|
||||||
portainerUrl String? @map("portainer_url")
|
portainerUrl String? @map("portainer_url")
|
||||||
dashboardUrl String? @map("dashboard_url")
|
dashboardUrl String? @map("dashboard_url")
|
||||||
failureReason String? @map("failure_reason")
|
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
|
// Timestamps
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
serverReadyAt DateTime? @map("server_ready_at")
|
serverReadyAt DateTime? @map("server_ready_at")
|
||||||
provisioningStartedAt DateTime? @map("provisioning_started_at")
|
provisioningStartedAt DateTime? @map("provisioning_started_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
|
dnsVerifiedAt DateTime? @map("dns_verified_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
provisioningLogs ProvisioningLog[]
|
provisioningLogs ProvisioningLog[]
|
||||||
jobs ProvisioningJob[]
|
jobs ProvisioningJob[]
|
||||||
|
serverConnection ServerConnection?
|
||||||
|
dnsVerification DnsVerification?
|
||||||
|
|
||||||
@@map("orders")
|
@@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 {
|
model ProvisioningLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orderId String @map("order_id")
|
orderId String @map("order_id")
|
||||||
|
|
@ -261,3 +403,317 @@ model RunnerToken {
|
||||||
|
|
||||||
@@map("runner_tokens")
|
@@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")
|
||||||
|
|
||||||
|
// 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 // 6-digit code
|
||||||
|
action String // "WIPE" | "REINSTALL"
|
||||||
|
targetServerId String @map("target_server_id") // Which server
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
usedAt DateTime? @map("used_at")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
}))
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
@ -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',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,12 @@ function LoginForm() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loginError, setLoginError] = useState<string | null>(null)
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
@ -41,6 +47,40 @@ function LoginForm() {
|
||||||
callbackUrl,
|
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) {
|
if (result?.error) {
|
||||||
setLoginError(result.error)
|
setLoginError(result.error)
|
||||||
} else if (result?.ok) {
|
} else if (result?.ok) {
|
||||||
|
|
@ -54,6 +94,88 @@ function LoginForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCustomers } from '@/hooks/use-customers'
|
import { useCustomers } from '@/hooks/use-customers'
|
||||||
|
import { AddCustomerDialog } from '@/components/admin/AddCustomerDialog'
|
||||||
import { UserStatus as ApiUserStatus } from '@/types/api'
|
import { UserStatus as ApiUserStatus } from '@/types/api'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -28,6 +29,11 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Users,
|
||||||
|
UserCheck,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
UserX,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
|
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
|
||||||
|
|
@ -50,81 +56,171 @@ interface Customer {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status badge component
|
// Status badge component with dot indicator and animations
|
||||||
function UserStatusBadge({ status }: { status: UserStatus }) {
|
function UserStatusBadge({ status }: { status: UserStatus }) {
|
||||||
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
|
const statusConfig: Record<UserStatus, {
|
||||||
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
label: string
|
||||||
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
|
bgColor: string
|
||||||
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
|
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]
|
const config = statusConfig[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
<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}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
|
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
|
||||||
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
|
const statusConfig: Record<SubscriptionStatus, {
|
||||||
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
|
label: string
|
||||||
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
bgColor: string
|
||||||
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
|
textColor: string
|
||||||
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
|
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]
|
const config = statusConfig[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
<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}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer table row component
|
// 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 }) {
|
function CustomerRow({ customer }: { customer: Customer }) {
|
||||||
return (
|
return (
|
||||||
<tr className="border-b hover:bg-gray-50">
|
<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">
|
||||||
<td className="px-4 py-3">
|
{/* Customer Info */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
<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-5 w-5 text-primary" />
|
<User className="h-6 w-6 text-primary" />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
href={`/admin/customers/${customer.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{customer.name}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Mail className="h-3 w-3" />
|
|
||||||
{customer.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div className="min-w-0">
|
||||||
<td className="px-4 py-3">
|
<Link
|
||||||
{customer.company ? (
|
href={`/admin/customers/${customer.id}`}
|
||||||
<div className="flex items-center gap-2">
|
className="font-semibold hover:text-primary transition-colors block truncate"
|
||||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
>
|
||||||
<span>{customer.company}</span>
|
{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>
|
</div>
|
||||||
) : (
|
{customer.company && (
|
||||||
<span className="text-muted-foreground">-</span>
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||||
)}
|
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||||
</td>
|
<span className="truncate">{customer.company}</span>
|
||||||
<td className="px-4 py-3">
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="hidden md:flex items-center justify-center px-4">
|
||||||
<UserStatusBadge status={customer.status} />
|
<UserStatusBadge status={customer.status} />
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-3">
|
|
||||||
|
{/* Subscription */}
|
||||||
|
<div className="hidden lg:block px-4 min-w-[140px]">
|
||||||
{customer.subscription ? (
|
{customer.subscription ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium capitalize">{customer.subscription.plan.toLowerCase()}</span>
|
<span className="font-medium capitalize text-sm">{customer.subscription.plan.toLowerCase()}</span>
|
||||||
<SubscriptionBadge status={customer.subscription.status} />
|
<SubscriptionBadge status={customer.subscription.status} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground capitalize">
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
|
@ -132,57 +228,157 @@ function CustomerRow({ customer }: { customer: Customer }) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">No subscription</span>
|
<span className="text-sm text-muted-foreground">No subscription</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-3">
|
|
||||||
{customer.subscription ? (
|
{/* Token Usage */}
|
||||||
<div className="space-y-1">
|
<div className="hidden xl:block px-4 w-36">
|
||||||
<div className="text-sm">
|
{customer.subscription && customer.subscription.tokenLimit > 0 ? (
|
||||||
{customer.subscription.tokensUsed.toLocaleString()} /{' '}
|
<TokenUsageBar
|
||||||
{customer.subscription.tokenLimit.toLocaleString()}
|
used={customer.subscription.tokensUsed}
|
||||||
</div>
|
limit={customer.subscription.tokenLimit}
|
||||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-gray-200">
|
/>
|
||||||
<div
|
|
||||||
className="h-full bg-primary"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(
|
|
||||||
(customer.subscription.tokensUsed / customer.subscription.tokenLimit) * 100,
|
|
||||||
100
|
|
||||||
)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Servers & Date */}
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<div className="hidden sm:flex items-center gap-6 px-4">
|
||||||
<span>{customer.activeServers}</span>
|
<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>
|
||||||
</td>
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<td className="px-4 py-3">
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
{new Date(customer.createdAt).toLocaleDateString()}
|
{new Date(customer.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1">
|
{/* Actions */}
|
||||||
<Link href={`/admin/customers/${customer.id}`}>
|
<div className="flex items-center gap-1 pl-4 opacity-70 group-hover:opacity-100 transition-opacity">
|
||||||
<Button variant="ghost" size="icon">
|
<Link href={`/admin/customers/${customer.id}`}>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<Button variant="ghost" size="sm" className="h-9 w-9 p-0">
|
||||||
</Button>
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Link>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</td>
|
</CardContent>
|
||||||
</tr>
|
{/* 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,6 +386,7 @@ export default function CustomersPage() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||||
const itemsPerPage = 10
|
const itemsPerPage = 10
|
||||||
|
|
||||||
// Fetch customers from API
|
// Fetch customers from API
|
||||||
|
|
@ -237,15 +434,25 @@ export default function CustomersPage() {
|
||||||
}), [customers, data?.pagination?.total])
|
}), [customers, data?.pagination?.total])
|
||||||
|
|
||||||
const totalPages = data?.pagination?.totalPages || 1
|
const totalPages = data?.pagination?.totalPages || 1
|
||||||
|
const hasFilters = search !== '' || statusFilter !== 'all'
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('')
|
||||||
|
setStatusFilter('all')
|
||||||
|
setCurrentPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[50vh]">
|
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="relative">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
|
||||||
<p className="text-muted-foreground">Loading customers...</p>
|
<div className="relative p-4 rounded-full bg-muted">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading customers...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -253,90 +460,114 @@ export default function CustomersPage() {
|
||||||
// Error state
|
// Error state
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[50vh]">
|
<div className="flex flex-col items-center justify-center h-[50vh] gap-4">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="relative">
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<div className="absolute inset-0 bg-destructive/20 rounded-full blur-xl" />
|
||||||
<div>
|
<div className="relative p-4 rounded-full bg-destructive/10">
|
||||||
<p className="font-medium text-destructive">Failed to load customers</p>
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{error instanceof Error ? error.message : 'An error occurred'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => refetch()}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
{/* Hero Header */}
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
{/* Background decoration */}
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Customers</h1>
|
<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" />
|
||||||
<p className="text-muted-foreground">
|
<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" />
|
||||||
Manage customer accounts and subscriptions
|
|
||||||
</p>
|
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
</div>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/20 border-2 border-primary/10">
|
||||||
<Button
|
<Users className="h-8 w-8 text-primary" />
|
||||||
variant="outline"
|
</div>
|
||||||
size="sm"
|
<div>
|
||||||
onClick={() => refetch()}
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Customers</h1>
|
||||||
disabled={isFetching}
|
<p className="text-muted-foreground mt-1">
|
||||||
>
|
Manage customer accounts and subscriptions
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
</p>
|
||||||
Refresh
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
<Button>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Button
|
||||||
Add Customer
|
variant="outline"
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<StatsCard
|
||||||
<CardContent className="pt-6">
|
title="Total Customers"
|
||||||
<div className="text-2xl font-bold">{stats.total}</div>
|
value={stats.total}
|
||||||
<p className="text-sm text-muted-foreground">Total Customers</p>
|
icon={Users}
|
||||||
</CardContent>
|
iconBg="bg-blue-100 dark:bg-blue-900/30"
|
||||||
</Card>
|
iconColor="text-blue-600 dark:text-blue-400"
|
||||||
<Card>
|
subtitle="All registered users"
|
||||||
<CardContent className="pt-6">
|
/>
|
||||||
<div className="text-2xl font-bold">{stats.active}</div>
|
<StatsCard
|
||||||
<p className="text-sm text-muted-foreground">Active</p>
|
title="Active"
|
||||||
</CardContent>
|
value={stats.active}
|
||||||
</Card>
|
icon={UserCheck}
|
||||||
<Card>
|
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
|
||||||
<CardContent className="pt-6">
|
iconColor="text-emerald-600 dark:text-emerald-400"
|
||||||
<div className="text-2xl font-bold">{stats.trial}</div>
|
subtitle="Currently active"
|
||||||
<p className="text-sm text-muted-foreground">On Trial</p>
|
/>
|
||||||
</CardContent>
|
<StatsCard
|
||||||
</Card>
|
title="On Trial"
|
||||||
<Card>
|
value={stats.trial}
|
||||||
<CardContent className="pt-6">
|
icon={Clock}
|
||||||
<div className="text-2xl font-bold">{stats.totalServers}</div>
|
iconBg="bg-amber-100 dark:bg-amber-900/30"
|
||||||
<p className="text-sm text-muted-foreground">Total Servers</p>
|
iconColor="text-amber-600 dark:text-amber-400"
|
||||||
</CardContent>
|
subtitle="Trial subscriptions"
|
||||||
</Card>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Filters and table */}
|
{/* Filters and list */}
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader className="border-b bg-muted/30">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>All Customers</CardTitle>
|
<CardTitle className="text-lg">All Customers</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
|
{data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
{/* Search input */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -345,73 +576,58 @@ export default function CustomersPage() {
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
setCurrentPage(1) // Reset to first page on search
|
setCurrentPage(1)
|
||||||
}}
|
}}
|
||||||
className="pl-9 w-full sm:w-64"
|
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>
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatusFilter(e.target.value)
|
|
||||||
setCurrentPage(1) // Reset to first page on filter change
|
|
||||||
}}
|
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
<option value="ACTIVE">Active</option>
|
|
||||||
<option value="SUSPENDED">Suspended</option>
|
|
||||||
<option value="PENDING_VERIFICATION">Pending</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 md:p-6">
|
||||||
{customers.length === 0 ? (
|
{customers.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<EmptyState hasFilters={hasFilters} onClearFilters={clearFilters} />
|
||||||
<p className="text-muted-foreground">No customers found</p>
|
|
||||||
{(search || statusFilter !== 'all') && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch('')
|
|
||||||
setStatusFilter('all')
|
|
||||||
setCurrentPage(1)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
{/* Customer list */}
|
||||||
<table className="w-full">
|
<div className="space-y-3">
|
||||||
<thead>
|
{customers.map((customer) => (
|
||||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
<CustomerRow key={customer.id} customer={customer} />
|
||||||
<th className="px-4 py-3 font-medium">Customer</th>
|
))}
|
||||||
<th className="px-4 py-3 font-medium">Company</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Status</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Subscription</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Token Usage</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Servers</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Joined</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{customers.map((customer) => (
|
|
||||||
<CustomerRow key={customer.id} customer={customer} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Enhanced Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between border-t pt-4">
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {currentPage} of {totalPages}
|
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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -419,15 +635,45 @@ export default function CustomersPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
|
className="gap-1.5 h-9 px-4"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
|
className="gap-1.5 h-9 px-4"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
|
@ -439,6 +685,12 @@ export default function CustomersPage() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Customer Dialog */}
|
||||||
|
<AddCustomerDialog
|
||||||
|
open={showAddDialog}
|
||||||
|
onOpenChange={setShowAddDialog}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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're looking for doesn'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} • {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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're looking for doesn'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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're looking for doesn'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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're looking for doesn'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,16 +13,29 @@ import {
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useOrder, useUpdateOrder, useTriggerProvisioning } from '@/hooks/use-orders'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useOrder, useUpdateOrder, useTriggerProvisioning, useDeleteOrder } from '@/hooks/use-orders'
|
||||||
import { useProvisioningLogs, StreamedLog } from '@/hooks/use-provisioning-logs'
|
import { useProvisioningLogs, StreamedLog } from '@/hooks/use-provisioning-logs'
|
||||||
import { OrderStatus, SubscriptionTier, LogLevel } from '@/types/api'
|
import { portainerKeys } from '@/hooks/use-portainer'
|
||||||
|
import { useDnsVerification, useTriggerDnsVerification, useSkipDnsVerification } from '@/hooks/use-dns'
|
||||||
|
import { useChangeAutomationMode } from '@/hooks/use-automation'
|
||||||
|
import { OrderStatus, SubscriptionTier, LogLevel, AutomationMode } from '@/types/api'
|
||||||
|
import { DnsVerificationPanel } from '@/components/admin/dns-verification-panel'
|
||||||
|
import { AutomationControls } from '@/components/admin/automation-controls'
|
||||||
|
import { ServerQuickActions } from '@/components/admin/server-quick-actions'
|
||||||
|
import { NetcupServerLink } from '@/components/admin/netcup-server-link'
|
||||||
|
import { ServerMetricsPanel } from '@/components/admin/server-metrics-panel'
|
||||||
|
import { PortainerCredentialsPanel } from '@/components/admin/portainer-credentials-panel'
|
||||||
|
import { ContainerList } from '@/components/admin/container-list'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowDown,
|
||||||
Globe,
|
Globe,
|
||||||
User,
|
User,
|
||||||
Server,
|
Server,
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
@ -33,7 +46,14 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog'
|
||||||
|
import { RefreshButton } from '@/components/ui/refresh-button'
|
||||||
|
import { ToolsEditor } from '@/components/admin/ToolsEditor'
|
||||||
|
import { OrderTimeline } from '@/components/admin/order-timeline'
|
||||||
|
import { ProvisioningConfigForm, ProvisioningConfig } from '@/components/admin/provisioning-config-form'
|
||||||
|
import { SSH_PORT_BEFORE_PROVISION, SSH_PORT_AFTER_PROVISION } from '@/lib/ssh/constants'
|
||||||
|
|
||||||
// Status badge component
|
// Status badge component
|
||||||
function StatusBadge({ status }: { status: OrderStatus }) {
|
function StatusBadge({ status }: { status: OrderStatus }) {
|
||||||
|
|
@ -58,14 +78,132 @@ function StatusBadge({ status }: { status: OrderStatus }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server card component with SSH test
|
||||||
|
function ServerCard({
|
||||||
|
orderId,
|
||||||
|
serverIp,
|
||||||
|
sshPort,
|
||||||
|
showTestButton,
|
||||||
|
}: {
|
||||||
|
orderId: string
|
||||||
|
serverIp: string | null
|
||||||
|
sshPort: number | null
|
||||||
|
showTestButton: boolean
|
||||||
|
}) {
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency?: number } | null>(null)
|
||||||
|
|
||||||
|
const handleTestSSH = async () => {
|
||||||
|
if (!serverIp) return
|
||||||
|
setTesting(true)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/orders/${orderId}/test-ssh`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setTestResult({
|
||||||
|
success: true,
|
||||||
|
message: `SSH connection successful!`,
|
||||||
|
latency: data.latency,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: data.error || 'Connection failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : 'Connection test failed',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
Server
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{serverIp ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-mono font-medium">{serverIp}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">SSH Port: {sshPort || 22}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTestButton && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTestSSH}
|
||||||
|
disabled={testing}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Testing SSH...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Terminal className="mr-2 h-4 w-4" />
|
||||||
|
Test SSH Connection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-md p-3 text-sm ${
|
||||||
|
testResult.success
|
||||||
|
? 'bg-green-50 text-green-800'
|
||||||
|
: 'bg-red-50 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.success ? (
|
||||||
|
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p>{testResult.message}</p>
|
||||||
|
{testResult.latency && (
|
||||||
|
<p className="text-xs opacity-75">Latency: {testResult.latency}ms</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Not configured</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Server credentials form component
|
// Server credentials form component
|
||||||
function ServerCredentialsForm({
|
function ServerCredentialsForm({
|
||||||
|
orderId,
|
||||||
initialIp,
|
initialIp,
|
||||||
initialPort,
|
initialPort,
|
||||||
hasCredentials,
|
hasCredentials,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
|
orderId: string
|
||||||
initialIp?: string
|
initialIp?: string
|
||||||
initialPort?: number
|
initialPort?: number
|
||||||
hasCredentials: boolean
|
hasCredentials: boolean
|
||||||
|
|
@ -74,7 +212,7 @@ function ServerCredentialsForm({
|
||||||
}) {
|
}) {
|
||||||
const [ip, setIp] = useState(initialIp || '')
|
const [ip, setIp] = useState(initialIp || '')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [port, setPort] = useState(String(initialPort || 22))
|
const [port, setPort] = useState(String(initialPort || SSH_PORT_BEFORE_PROVISION))
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
|
|
@ -82,10 +220,36 @@ function ServerCredentialsForm({
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
// TODO: Call API to test SSH connection
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
const response = await fetch(`/api/v1/admin/orders/${orderId}/test-ssh`, {
|
||||||
setTestResult({ success: true, message: 'Connection successful! SSH version: OpenSSH_8.4' })
|
method: 'POST',
|
||||||
setTesting(false)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverIp: ip,
|
||||||
|
password: password,
|
||||||
|
sshPort: parseInt(port),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setTestResult({
|
||||||
|
success: true,
|
||||||
|
message: `Connection successful! Latency: ${data.latency}ms`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: data.error || 'Connection failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: err instanceof Error ? err.message : 'Connection test failed',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
|
@ -125,11 +289,14 @@ function ServerCredentialsForm({
|
||||||
<Input
|
<Input
|
||||||
id="port"
|
id="port"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="22"
|
placeholder={String(SSH_PORT_BEFORE_PROVISION)}
|
||||||
value={port}
|
value={port}
|
||||||
onChange={(e) => setPort(e.target.value)}
|
onChange={(e) => setPort(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default: {SSH_PORT_BEFORE_PROVISION} (changes to {SSH_PORT_AFTER_PROVISION} after provisioning)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -216,7 +383,7 @@ function ServerCredentialsForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provisioning logs component
|
// Provisioning logs component
|
||||||
function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
function ProvisioningLogs({ logs, isLive, isConnected, isComplete, finalStatus, onReconnect }: {
|
||||||
logs: Array<{
|
logs: Array<{
|
||||||
id: string
|
id: string
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
|
|
@ -226,8 +393,15 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
||||||
}>
|
}>
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
isConnected?: boolean
|
isConnected?: boolean
|
||||||
|
isComplete?: boolean
|
||||||
|
finalStatus?: 'FULFILLED' | 'FAILED' | null
|
||||||
onReconnect?: () => void
|
onReconnect?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true)
|
||||||
|
const userScrolledRef = useRef(false)
|
||||||
|
const lastScrollTopRef = useRef(0)
|
||||||
|
|
||||||
const levelColors: Record<LogLevel, string> = {
|
const levelColors: Record<LogLevel, string> = {
|
||||||
INFO: 'text-blue-400',
|
INFO: 'text-blue-400',
|
||||||
WARN: 'text-yellow-400',
|
WARN: 'text-yellow-400',
|
||||||
|
|
@ -240,8 +414,40 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
||||||
return d.toLocaleTimeString('en-US', { hour12: false })
|
return d.toLocaleTimeString('en-US', { hour12: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle scroll to detect if user manually scrolled up
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!logContainerRef.current) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current
|
||||||
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100
|
||||||
|
|
||||||
|
// Detect if user scrolled UP manually (not programmatic scroll down)
|
||||||
|
if (scrollTop < lastScrollTopRef.current && !isAtBottom) {
|
||||||
|
userScrolledRef.current = true
|
||||||
|
setAutoScroll(false)
|
||||||
|
} else if (isAtBottom) {
|
||||||
|
// User scrolled back to bottom
|
||||||
|
userScrolledRef.current = false
|
||||||
|
setAutoScroll(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollTopRef.current = scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new logs arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && logContainerRef.current) {
|
||||||
|
// Use requestAnimationFrame for smooth scrolling
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (logContainerRef.current) {
|
||||||
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||||
|
lastScrollTopRef.current = logContainerRef.current.scrollTop
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [logs.length, autoScroll])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className={isComplete ? (finalStatus === 'FULFILLED' ? 'border-green-500/50' : 'border-red-500/50') : ''}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -252,7 +458,28 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
||||||
<CardDescription>Real-time output from the provisioning process</CardDescription>
|
<CardDescription>Real-time output from the provisioning process</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isLive && (
|
{/* Completion status */}
|
||||||
|
{isComplete && (
|
||||||
|
<div className={`flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${
|
||||||
|
finalStatus === 'FULFILLED'
|
||||||
|
? 'bg-green-500/10 text-green-600'
|
||||||
|
: 'bg-red-500/10 text-red-600'
|
||||||
|
}`}>
|
||||||
|
{finalStatus === 'FULFILLED' ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Completed Successfully
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
Provisioning Failed
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Live status */}
|
||||||
|
{isLive && !isComplete && (
|
||||||
<>
|
<>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -268,24 +495,49 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
||||||
<WifiOff className="h-4 w-4 text-red-500" />
|
<WifiOff className="h-4 w-4 text-red-500" />
|
||||||
<span className="text-sm text-red-500">Disconnected</span>
|
<span className="text-sm text-red-500">Disconnected</span>
|
||||||
{onReconnect && (
|
{onReconnect && (
|
||||||
<Button variant="ghost" size="sm" onClick={onReconnect}>
|
<RefreshButton
|
||||||
<RefreshCw className="h-4 w-4" />
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
|
onClick={onReconnect}
|
||||||
|
showLabel={false}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isLive && logs.length > 0 && (
|
{!isLive && !isComplete && logs.length > 0 && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{logs.length} log{logs.length !== 1 ? 's' : ''}
|
{logs.length} log{logs.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Auto-scroll indicator */}
|
||||||
|
{!autoScroll && isLive && !isComplete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
userScrolledRef.current = false
|
||||||
|
setAutoScroll(true)
|
||||||
|
if (logContainerRef.current) {
|
||||||
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||||
|
lastScrollTopRef.current = logContainerRef.current.scrollTop
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4 mr-1" />
|
||||||
|
Resume auto-scroll
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-96 overflow-y-auto rounded-lg bg-gray-900 p-4 font-mono text-sm" id="log-container">
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="h-96 overflow-y-auto rounded-lg bg-gray-900 p-4 font-mono text-sm"
|
||||||
|
>
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{isLive && isConnected ? 'Waiting for logs...' : 'No logs available yet.'}
|
{isLive && isConnected ? 'Waiting for logs...' : 'No logs available yet.'}
|
||||||
|
|
@ -306,119 +558,10 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order timeline component
|
|
||||||
function OrderTimeline({
|
|
||||||
status,
|
|
||||||
timestamps,
|
|
||||||
}: {
|
|
||||||
status: OrderStatus
|
|
||||||
timestamps: {
|
|
||||||
createdAt?: Date | null
|
|
||||||
serverReadyAt?: Date | null
|
|
||||||
provisioningStartedAt?: Date | null
|
|
||||||
completedAt?: Date | null
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
const stages = [
|
|
||||||
{ key: 'payment_confirmed', label: 'Payment Confirmed', status: 'PAYMENT_CONFIRMED' },
|
|
||||||
{ key: 'awaiting_server', label: 'Server Ordered', status: 'AWAITING_SERVER' },
|
|
||||||
{ key: 'server_ready', label: 'Server Ready', status: 'SERVER_READY' },
|
|
||||||
{ key: 'dns_ready', label: 'DNS Configured', status: 'DNS_READY' },
|
|
||||||
{ key: 'provisioning', label: 'Provisioning', status: 'PROVISIONING' },
|
|
||||||
{ key: 'fulfilled', label: 'Fulfilled', status: 'FULFILLED' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOrder = stages.map((s) => s.status)
|
|
||||||
const currentIndex = statusOrder.indexOf(status)
|
|
||||||
|
|
||||||
const formatDate = (date: Date | null | undefined) => {
|
|
||||||
if (!date) return null
|
|
||||||
return new Date(date).toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTimestamp = (key: string): string | null => {
|
|
||||||
switch (key) {
|
|
||||||
case 'payment_confirmed':
|
|
||||||
return formatDate(timestamps.createdAt)
|
|
||||||
case 'server_ready':
|
|
||||||
return formatDate(timestamps.serverReadyAt)
|
|
||||||
case 'provisioning':
|
|
||||||
return formatDate(timestamps.provisioningStartedAt)
|
|
||||||
case 'fulfilled':
|
|
||||||
return formatDate(timestamps.completedAt)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" />
|
|
||||||
Order Timeline
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="relative">
|
|
||||||
{stages.map((stage, index) => {
|
|
||||||
const isComplete = index < currentIndex || (index === currentIndex && status !== 'FAILED')
|
|
||||||
const isCurrent = index === currentIndex
|
|
||||||
const timestamp = getTimestamp(stage.key)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={stage.key} className="flex gap-4 pb-8 last:pb-0">
|
|
||||||
<div className="relative flex flex-col items-center">
|
|
||||||
<div
|
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full border-2 ${
|
|
||||||
isComplete
|
|
||||||
? 'border-green-500 bg-green-500 text-white'
|
|
||||||
: isCurrent
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isComplete ? (
|
|
||||||
<CheckCircle className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-medium">{index + 1}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{index < stages.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={`absolute top-8 h-full w-0.5 ${
|
|
||||||
isComplete ? 'bg-green-500' : 'bg-gray-200'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 pt-1">
|
|
||||||
<p className={`font-medium ${isCurrent ? 'text-blue-600' : ''}`}>
|
|
||||||
{stage.label}
|
|
||||||
</p>
|
|
||||||
{timestamp && (
|
|
||||||
<p className="text-sm text-muted-foreground">{timestamp}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrderDetailPage() {
|
export default function OrderDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const orderId = params.id as string
|
const orderId = params.id as string
|
||||||
|
|
||||||
// Fetch order data
|
// Fetch order data
|
||||||
|
|
@ -434,6 +577,16 @@ export default function OrderDetailPage() {
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateOrder = useUpdateOrder()
|
const updateOrder = useUpdateOrder()
|
||||||
const triggerProvision = useTriggerProvisioning()
|
const triggerProvision = useTriggerProvisioning()
|
||||||
|
const changeAutomationMode = useChangeAutomationMode()
|
||||||
|
|
||||||
|
// DNS hooks
|
||||||
|
const { data: dnsData } = useDnsVerification(orderId)
|
||||||
|
const triggerDnsVerification = useTriggerDnsVerification()
|
||||||
|
const skipDnsVerification = useSkipDnsVerification()
|
||||||
|
|
||||||
|
// Delete hook
|
||||||
|
const deleteOrder = useDeleteOrder()
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
|
||||||
// Check if we should enable SSE streaming
|
// Check if we should enable SSE streaming
|
||||||
const isProvisioning = order?.status === OrderStatus.PROVISIONING
|
const isProvisioning = order?.status === OrderStatus.PROVISIONING
|
||||||
|
|
@ -454,7 +607,10 @@ export default function OrderDetailPage() {
|
||||||
onComplete: useCallback((success: boolean) => {
|
onComplete: useCallback((success: boolean) => {
|
||||||
// Refetch order data when provisioning completes
|
// Refetch order data when provisioning completes
|
||||||
refetch()
|
refetch()
|
||||||
}, [refetch]),
|
// Also invalidate Portainer credentials to pick up any synced credentials
|
||||||
|
queryClient.invalidateQueries({ queryKey: portainerKeys.credentials(orderId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: portainerKeys.containers(orderId) })
|
||||||
|
}, [refetch, queryClient, orderId]),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
|
|
@ -488,15 +644,7 @@ export default function OrderDetailPage() {
|
||||||
)
|
)
|
||||||
}, [order?.provisioningLogs, streamedLogs])
|
}, [order?.provisioningLogs, streamedLogs])
|
||||||
|
|
||||||
// Auto-scroll logs to bottom when new logs come in
|
// Note: Auto-scroll is handled inside ProvisioningLogs component via logContainerRef
|
||||||
useEffect(() => {
|
|
||||||
if (isProvisioning && allLogs.length > 0) {
|
|
||||||
const container = document.getElementById('log-container')
|
|
||||||
if (container) {
|
|
||||||
container.scrollTop = container.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [allLogs.length, isProvisioning])
|
|
||||||
|
|
||||||
const handleCredentialsSubmit = async (ip: string, password: string, port: number) => {
|
const handleCredentialsSubmit = async (ip: string, password: string, port: number) => {
|
||||||
await updateOrder.mutateAsync({
|
await updateOrder.mutateAsync({
|
||||||
|
|
@ -505,6 +653,18 @@ export default function OrderDetailPage() {
|
||||||
serverIp: ip,
|
serverIp: ip,
|
||||||
serverPassword: password,
|
serverPassword: password,
|
||||||
sshPort: port,
|
sshPort: port,
|
||||||
|
status: OrderStatus.SERVER_READY, // Mark as ready after saving credentials
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProvisioningConfigSave = async (config: ProvisioningConfig) => {
|
||||||
|
await updateOrder.mutateAsync({
|
||||||
|
id: orderId,
|
||||||
|
data: {
|
||||||
|
customer: config.customer,
|
||||||
|
companyName: config.companyName,
|
||||||
|
licenseKey: config.licenseKey,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -514,9 +674,28 @@ export default function OrderDetailPage() {
|
||||||
await triggerProvision.mutateAsync(orderId)
|
await triggerProvision.mutateAsync(orderId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to trigger provisioning:', err)
|
console.error('Failed to trigger provisioning:', err)
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to start provisioning'
|
||||||
|
alert(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAutomationModeChange = async (action: 'auto' | 'manual' | 'pause' | 'resume') => {
|
||||||
|
await changeAutomationMode.mutateAsync({ orderId, action })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDnsVerify = async () => {
|
||||||
|
await triggerDnsVerification.mutateAsync(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDnsSkip = async () => {
|
||||||
|
await skipDnsVerification.mutateAsync(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteOrder = async () => {
|
||||||
|
await deleteOrder.mutateAsync(orderId)
|
||||||
|
router.push('/admin/orders')
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -546,18 +725,23 @@ export default function OrderDetailPage() {
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => refetch()}>
|
<RefreshButton
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
variant="outline"
|
||||||
Retry
|
onClick={() => refetch()}
|
||||||
</Button>
|
isRefreshing={isFetching}
|
||||||
|
label="Retry"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCredentialsForm = order.status === OrderStatus.AWAITING_SERVER || order.status === OrderStatus.SERVER_READY
|
// Always show credentials form - allows entering/updating server details at any stage
|
||||||
const showProvisionButton = order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED
|
const showCredentialsForm = order.status !== OrderStatus.FULFILLED && order.status !== OrderStatus.EMAIL_CONFIGURED
|
||||||
|
const showProvisionButton = order.status === OrderStatus.SERVER_READY || order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED
|
||||||
|
// Provisioning config is editable before provisioning starts
|
||||||
|
const isProvisioningConfigEditable = !([OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] as OrderStatus[]).includes(order.status)
|
||||||
const showLogs = order.status === OrderStatus.PROVISIONING ||
|
const showLogs = order.status === OrderStatus.PROVISIONING ||
|
||||||
order.status === OrderStatus.FULFILLED ||
|
order.status === OrderStatus.FULFILLED ||
|
||||||
order.status === OrderStatus.EMAIL_CONFIGURED ||
|
order.status === OrderStatus.EMAIL_CONFIGURED ||
|
||||||
|
|
@ -580,15 +764,10 @@ export default function OrderDetailPage() {
|
||||||
<p className="text-muted-foreground">Order #{orderId.slice(0, 8)}...</p>
|
<p className="text-muted-foreground">Order #{orderId.slice(0, 8)}...</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<RefreshButton
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
disabled={isFetching}
|
isRefreshing={isFetching}
|
||||||
>
|
/>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
{showProvisionButton && (
|
{showProvisionButton && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleTriggerProvisioning}
|
onClick={handleTriggerProvisioning}
|
||||||
|
|
@ -623,9 +802,43 @@ export default function OrderDetailPage() {
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</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 Order"
|
||||||
|
description={`This will permanently delete order "${order.domain}" and all related records (logs, jobs, DNS verification). This action cannot be undone. The actual server will NOT be affected.`}
|
||||||
|
confirmText="Delete Order"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={handleDeleteOrder}
|
||||||
|
isLoading={deleteOrder.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Order Timeline - Horizontal progress bar at top */}
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<OrderTimeline
|
||||||
|
status={order.status}
|
||||||
|
timestamps={{
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
serverReadyAt: order.serverReadyAt,
|
||||||
|
provisioningStartedAt: order.provisioningStartedAt,
|
||||||
|
completedAt: order.completedAt,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Failure reason banner */}
|
{/* Failure reason banner */}
|
||||||
{order.status === OrderStatus.FAILED && order.failureReason && (
|
{order.status === OrderStatus.FAILED && order.failureReason && (
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
|
|
@ -637,6 +850,29 @@ export default function OrderDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Provisioning Logs - Prominent display at top when active */}
|
||||||
|
{showLogs && (
|
||||||
|
<ProvisioningLogs
|
||||||
|
logs={allLogs}
|
||||||
|
isLive={isProvisioning}
|
||||||
|
isConnected={isConnected}
|
||||||
|
isComplete={isComplete}
|
||||||
|
finalStatus={order.status === OrderStatus.FULFILLED ? 'FULFILLED' : order.status === OrderStatus.FAILED ? 'FAILED' : null}
|
||||||
|
onReconnect={reconnect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Automation Controls */}
|
||||||
|
<AutomationControls
|
||||||
|
orderId={orderId}
|
||||||
|
mode={order.automationMode}
|
||||||
|
pausedAt={order.automationPausedAt}
|
||||||
|
pausedReason={order.automationPausedReason}
|
||||||
|
source={order.source}
|
||||||
|
status={order.status}
|
||||||
|
onModeChange={handleAutomationModeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Order info cards */}
|
{/* Order info cards */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -675,49 +911,36 @@ export default function OrderDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<ServerCard
|
||||||
<CardHeader>
|
orderId={orderId}
|
||||||
<CardTitle className="flex items-center gap-2">
|
serverIp={order.serverIp}
|
||||||
<Server className="h-5 w-5" />
|
sshPort={order.sshPort}
|
||||||
Server
|
showTestButton={showProvisionButton}
|
||||||
</CardTitle>
|
/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{order.serverIp ? (
|
|
||||||
<>
|
|
||||||
<p className="font-mono font-medium">{order.serverIp}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">SSH Port: {order.sshPort || 22}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">Not configured</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tools list */}
|
{/* Tools list - editable before provisioning */}
|
||||||
<Card>
|
<ToolsEditor
|
||||||
<CardHeader>
|
tools={order.tools}
|
||||||
<CardTitle>Selected Tools</CardTitle>
|
onSave={async (tools) => {
|
||||||
<CardDescription>Tools to be deployed on this server</CardDescription>
|
await updateOrder.mutateAsync({ id: orderId, data: { tools } })
|
||||||
</CardHeader>
|
}}
|
||||||
<CardContent>
|
isEditable={!([OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] as OrderStatus[]).includes(order.status)}
|
||||||
<div className="flex flex-wrap gap-2">
|
isSaving={updateOrder.isPending}
|
||||||
{order.tools.map((tool) => (
|
/>
|
||||||
<span
|
|
||||||
key={tool}
|
{/* Provisioning config form - customer/license info */}
|
||||||
className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium capitalize"
|
<ProvisioningConfigForm
|
||||||
>
|
order={order}
|
||||||
{tool}
|
onSave={handleProvisioningConfigSave}
|
||||||
</span>
|
isEditable={isProvisioningConfigEditable}
|
||||||
))}
|
isSaving={updateOrder.isPending}
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Server credentials form (show for AWAITING_SERVER or SERVER_READY status) */}
|
{/* Server credentials form (show for AWAITING_SERVER or SERVER_READY status) */}
|
||||||
{showCredentialsForm && (
|
{showCredentialsForm && (
|
||||||
<ServerCredentialsForm
|
<ServerCredentialsForm
|
||||||
|
orderId={orderId}
|
||||||
initialIp={order.serverIp || undefined}
|
initialIp={order.serverIp || undefined}
|
||||||
initialPort={order.sshPort}
|
initialPort={order.sshPort}
|
||||||
hasCredentials={!!order.serverIp}
|
hasCredentials={!!order.serverIp}
|
||||||
|
|
@ -726,28 +949,69 @@ export default function OrderDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two column layout for timeline and logs */}
|
{/* Netcup Server Linking & Quick Actions */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
{order.serverIp && (
|
||||||
<OrderTimeline
|
<Card className="rounded-xl">
|
||||||
status={order.status}
|
<CardHeader>
|
||||||
timestamps={{
|
<div className="flex items-center gap-3">
|
||||||
createdAt: order.createdAt,
|
<div className="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
|
||||||
serverReadyAt: order.serverReadyAt,
|
<Server className="h-4 w-4 text-violet-600 dark:text-violet-400" />
|
||||||
provisioningStartedAt: order.provisioningStartedAt,
|
</div>
|
||||||
completedAt: order.completedAt,
|
<div>
|
||||||
}}
|
<CardTitle className="text-lg">Netcup Server</CardTitle>
|
||||||
/>
|
<CardDescription>Link this order to a Netcup server for management</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<NetcupServerLink
|
||||||
|
orderId={orderId}
|
||||||
|
currentNetcupServerId={order.netcupServerId}
|
||||||
|
serverIp={order.serverIp}
|
||||||
|
onLinked={() => refetch()}
|
||||||
|
/>
|
||||||
|
{order.netcupServerId && (
|
||||||
|
<ServerQuickActions
|
||||||
|
netcupServerId={order.netcupServerId}
|
||||||
|
serverIp={order.serverIp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show logs for provisioning/completed/failed status */}
|
{/* Server Metrics (show when Netcup server is linked) */}
|
||||||
{showLogs && (
|
{order.netcupServerId && (
|
||||||
<ProvisioningLogs
|
<ServerMetricsPanel
|
||||||
logs={allLogs}
|
netcupServerId={order.netcupServerId}
|
||||||
isLive={isProvisioning}
|
serverName={order.domain}
|
||||||
isConnected={isConnected}
|
/>
|
||||||
onReconnect={reconnect}
|
)}
|
||||||
/>
|
|
||||||
)}
|
{/* DNS Verification Panel (show when server is configured) */}
|
||||||
</div>
|
{order.serverIp && (
|
||||||
|
<DnsVerificationPanel
|
||||||
|
orderId={orderId}
|
||||||
|
domain={order.domain}
|
||||||
|
serverIp={order.serverIp}
|
||||||
|
requiredSubdomains={dnsData?.requiredSubdomains || []}
|
||||||
|
verification={dnsData?.verification || null}
|
||||||
|
onVerify={handleDnsVerify}
|
||||||
|
onSkip={handleDnsSkip}
|
||||||
|
isVerifying={triggerDnsVerification.isPending}
|
||||||
|
isSkipping={skipDnsVerification.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Portainer & Container Management (show for provisioned/completed orders) */}
|
||||||
|
{(order.status === OrderStatus.FULFILLED ||
|
||||||
|
order.status === OrderStatus.EMAIL_CONFIGURED ||
|
||||||
|
order.status === OrderStatus.PROVISIONING) && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortainerCredentialsPanel orderId={orderId} />
|
||||||
|
<ContainerList orderId={orderId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Jobs history */}
|
{/* Jobs history */}
|
||||||
{order.jobs && order.jobs.length > 0 && (
|
{order.jobs && order.jobs.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
OrderKanban,
|
OrderKanban,
|
||||||
OrderPipelineCompact,
|
OrderPipelineCompact,
|
||||||
|
|
@ -22,7 +23,11 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Package,
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { exportOrdersToCsv } from '@/lib/csv-export'
|
||||||
|
|
||||||
// View modes
|
// View modes
|
||||||
type ViewMode = 'kanban' | 'list'
|
type ViewMode = 'kanban' | 'list'
|
||||||
|
|
@ -76,6 +81,27 @@ function mapApiOrderToCardOrder(apiOrder: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export default function OrdersPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('kanban')
|
const [viewMode, setViewMode] = useState<ViewMode>('kanban')
|
||||||
|
|
@ -107,6 +133,9 @@ export default function OrdersPage() {
|
||||||
return data.orders.map(mapApiOrderToCardOrder)
|
return data.orders.map(mapApiOrderToCardOrder)
|
||||||
}, [data?.orders])
|
}, [data?.orders])
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasActiveFilters = filters.search || filters.tier !== 'all' || filters.status !== 'all'
|
||||||
|
|
||||||
// Handle order action
|
// Handle order action
|
||||||
const handleOrderAction = (order: OrderCardType, action: string) => {
|
const handleOrderAction = (order: OrderCardType, action: string) => {
|
||||||
console.log(`Action "${action}" triggered for order:`, order)
|
console.log(`Action "${action}" triggered for order:`, order)
|
||||||
|
|
@ -126,18 +155,46 @@ export default function OrdersPage() {
|
||||||
|
|
||||||
// Handle export
|
// Handle export
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
// TODO: Implement CSV export
|
if (!data?.orders || data.orders.length === 0) {
|
||||||
console.log('Exporting orders...')
|
alert('No orders to export')
|
||||||
alert('Export functionality coming soon!')
|
return
|
||||||
|
}
|
||||||
|
exportOrdersToCsv(data.orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilters({ search: '', tier: 'all', status: 'all' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex flex-col items-center gap-4">
|
{/* Hero header skeleton */}
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<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">
|
||||||
<p className="text-muted-foreground">Loading orders...</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -146,19 +203,31 @@ export default function OrdersPage() {
|
||||||
// Error state
|
// Error state
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
{/* Hero header */}
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<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>
|
<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" />
|
||||||
<p className="font-medium text-destructive">Failed to load orders</p>
|
<div className="relative">
|
||||||
<p className="text-sm text-muted-foreground">
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Order Pipeline</h1>
|
||||||
{error instanceof Error ? error.message : 'An error occurred'}
|
<p className="text-muted-foreground mt-1">Manage and track customer provisioning orders</p>
|
||||||
</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>
|
||||||
<Button variant="outline" onClick={() => refetch()}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -166,133 +235,221 @@ export default function OrdersPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full space-y-6">
|
<div className="flex flex-col h-full space-y-6">
|
||||||
{/* Page header */}
|
{/* Hero Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<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>
|
{/* Background decoration */}
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Order Pipeline</h1>
|
<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" />
|
||||||
<p className="text-muted-foreground">
|
<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" />
|
||||||
Manage and track customer provisioning orders
|
|
||||||
</p>
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</div>
|
{/* Title section */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Package className="h-7 w-7 text-primary" />
|
||||||
Export
|
</div>
|
||||||
</Button>
|
<div>
|
||||||
<Button size="sm" onClick={() => setIsCreateDialogOpen(true)}>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Order Pipeline</h1>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<p className="text-muted-foreground mt-0.5">Manage and track customer provisioning orders</p>
|
||||||
New Order
|
</div>
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="rounded-xl border bg-card/50 p-4">
|
||||||
{/* Search and filters */}
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
{/* Search and filters */}
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
{/* Search input */}
|
||||||
<Input
|
<div className="relative flex-1 max-w-sm">
|
||||||
placeholder="Search by domain, customer..."
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
value={filters.search}
|
<Input
|
||||||
onChange={(e) =>
|
placeholder="Search by domain, customer..."
|
||||||
setFilters((prev) => ({ ...prev, search: e.target.value }))
|
value={filters.search}
|
||||||
}
|
onChange={(e) =>
|
||||||
className="pl-9"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Tier filter */}
|
{/* View controls */}
|
||||||
<select
|
<div className="flex items-center gap-3">
|
||||||
value={filters.tier}
|
{/* Refresh button */}
|
||||||
onChange={(e) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
tier: e.target.value as OrderTier | 'all',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<option value="all">All Tiers</option>
|
|
||||||
<option value="hub-dashboard">Hub Dashboard</option>
|
|
||||||
<option value="control-panel">Control Panel</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Status filter */}
|
|
||||||
<select
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters((prev) => ({
|
|
||||||
...prev,
|
|
||||||
status: e.target.value as OrderStatus | 'all',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View controls */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center border rounded-md">
|
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === 'kanban' ? 'secondary' : 'ghost'}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-r-none"
|
onClick={handleRefresh}
|
||||||
onClick={() => setViewMode('kanban')}
|
disabled={isFetching}
|
||||||
|
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<RefreshCw
|
||||||
</Button>
|
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
|
||||||
<Button
|
/>
|
||||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
size="sm"
|
|
||||||
className="rounded-l-none"
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filters indicator */}
|
{/* Active filters indicator */}
|
||||||
{(filters.search || filters.tier !== 'all' || filters.status !== 'all') && (
|
{hasActiveFilters && (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<span className="text-muted-foreground">
|
<Sparkles className="h-4 w-4" />
|
||||||
Showing {orders.length} orders
|
<span>
|
||||||
{data?.pagination && ` of ${data.pagination.total}`}
|
Showing <span className="font-semibold text-foreground">{orders.length}</span> orders
|
||||||
</span>
|
{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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={clearFilters}
|
||||||
setFilters({ search: '', tier: 'all', status: 'all' })
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -300,14 +457,33 @@ export default function OrdersPage() {
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{orders.length === 0 && (
|
{orders.length === 0 && (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/20 p-12 max-w-md text-center">
|
||||||
<p className="text-muted-foreground">No orders found</p>
|
<div className="mx-auto w-fit p-4 rounded-full bg-muted/60 mb-4">
|
||||||
{(filters.search || filters.tier !== 'all' || filters.status !== 'all') && (
|
<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
|
<Button
|
||||||
variant="link"
|
variant="outline"
|
||||||
onClick={() => setFilters({ search: '', tier: 'all', status: 'all' })}
|
onClick={clearFilters}
|
||||||
|
className="mt-6 gap-2"
|
||||||
>
|
>
|
||||||
Clear filters to see all orders
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,75 +21,142 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
LayoutDashboard,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Stats card component
|
// Enhanced stats card with icon backgrounds and hover effects
|
||||||
function StatsCard({
|
function StatsCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
iconBgColor = 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
|
iconColor = 'text-blue-600 dark:text-blue-400',
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
value: string | number
|
value: string | number
|
||||||
description: string
|
description: string
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
iconBgColor?: string
|
||||||
|
iconColor?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<div className={`p-2.5 rounded-lg ${iconBgColor}`}>
|
||||||
|
<Icon className={`h-4 w-4 ${iconColor}`} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
|
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<div className="text-3xl font-bold tracking-tight tabular-nums">{value}</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order status badge
|
// Enhanced order status badge with dot indicator
|
||||||
function OrderStatusBadge({ status }: { status: string }) {
|
function OrderStatusBadge({ status }: { status: string }) {
|
||||||
const statusStyles: Record<string, string> = {
|
const statusConfig: Record<string, {
|
||||||
PAYMENT_CONFIRMED: 'bg-blue-100 text-blue-800',
|
label: string
|
||||||
AWAITING_SERVER: 'bg-yellow-100 text-yellow-800',
|
bgColor: string
|
||||||
SERVER_READY: 'bg-purple-100 text-purple-800',
|
textColor: string
|
||||||
DNS_PENDING: 'bg-orange-100 text-orange-800',
|
borderColor: string
|
||||||
DNS_READY: 'bg-cyan-100 text-cyan-800',
|
dotColor: string
|
||||||
PROVISIONING: 'bg-indigo-100 text-indigo-800',
|
pulse?: boolean
|
||||||
FULFILLED: 'bg-green-100 text-green-800',
|
}> = {
|
||||||
EMAIL_CONFIGURED: 'bg-emerald-100 text-emerald-800',
|
PAYMENT_CONFIRMED: {
|
||||||
FAILED: 'bg-red-100 text-red-800',
|
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 statusLabels: Record<string, string> = {
|
const config = statusConfig[status] || {
|
||||||
PAYMENT_CONFIRMED: 'Payment Confirmed',
|
label: status,
|
||||||
AWAITING_SERVER: 'Awaiting Server',
|
bgColor: 'bg-slate-50 dark:bg-slate-950/30',
|
||||||
SERVER_READY: 'Server Ready',
|
textColor: 'text-slate-700 dark:text-slate-400',
|
||||||
DNS_PENDING: 'DNS Pending',
|
borderColor: 'border-slate-200 dark:border-slate-800',
|
||||||
DNS_READY: 'DNS Ready',
|
dotColor: 'bg-slate-500'
|
||||||
PROVISIONING: 'Provisioning',
|
|
||||||
FULFILLED: 'Fulfilled',
|
|
||||||
EMAIL_CONFIGURED: 'Complete',
|
|
||||||
FAILED: 'Failed',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
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}`}
|
||||||
statusStyles[status] || 'bg-gray-100 text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{statusLabels[status] || status}
|
<span className={`h-1.5 w-1.5 rounded-full ${config.dotColor} ${config.pulse ? 'animate-pulse' : ''}`} />
|
||||||
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +177,7 @@ function formatTimeSince(date: Date | string): string {
|
||||||
return then.toLocaleDateString()
|
return then.toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent orders component
|
// Recent orders component with enhanced styling
|
||||||
function RecentOrders({ orders, isLoading }: {
|
function RecentOrders({ orders, isLoading }: {
|
||||||
orders: Array<{
|
orders: Array<{
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -122,51 +189,60 @@ function RecentOrders({ orders, isLoading }: {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="col-span-2">
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<CardTitle>Recent Orders</CardTitle>
|
<div className="p-2 rounded-lg bg-muted">
|
||||||
<CardDescription>Latest customer provisioning orders</CardDescription>
|
<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>
|
</div>
|
||||||
<Link href="/admin/orders">
|
<Link href="/admin/orders">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground hover:text-foreground">
|
||||||
View All <ArrowRight className="ml-1 h-4 w-4" />
|
View All <ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<div key={i} className="flex items-center justify-between rounded-lg border p-4">
|
<div key={i} className="flex items-center justify-between rounded-xl border bg-card p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-36 bg-muted animate-pulse rounded" />
|
||||||
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
|
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-20 bg-muted animate-pulse rounded-full" />
|
<div className="h-6 w-24 bg-muted animate-pulse rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : orders.length === 0 ? (
|
) : orders.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-12 rounded-xl border-2 border-dashed border-muted-foreground/20 bg-muted/20">
|
||||||
No orders yet
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
<Link
|
<Link
|
||||||
key={order.id}
|
key={order.id}
|
||||||
href={`/admin/orders/${order.id}`}
|
href={`/admin/orders/${order.id}`}
|
||||||
className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/50 transition-colors"
|
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">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">{order.domain}</p>
|
<p className="font-medium group-hover:text-primary transition-colors">{order.domain}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{order.user.name || order.user.company || order.user.email}
|
{order.user.name || order.user.company || order.user.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
{formatTimeSince(order.createdAt)}
|
{formatTimeSince(order.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
<OrderStatusBadge status={order.status} />
|
<OrderStatusBadge status={order.status} />
|
||||||
|
|
@ -180,7 +256,7 @@ function RecentOrders({ orders, isLoading }: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipeline overview component
|
// Enhanced pipeline overview component
|
||||||
function PipelineOverview({ stats, isLoading }: {
|
function PipelineOverview({ stats, isLoading }: {
|
||||||
stats: {
|
stats: {
|
||||||
pending: number
|
pending: number
|
||||||
|
|
@ -191,37 +267,90 @@ function PipelineOverview({ stats, isLoading }: {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
const stages = [
|
const stages = [
|
||||||
{ name: 'Payment & Server', count: stats?.pending || 0, icon: Clock, color: 'text-yellow-500' },
|
{
|
||||||
{ name: 'Provisioning', count: stats?.inProgress || 0, icon: TrendingUp, color: 'text-indigo-500' },
|
name: 'Payment & Server',
|
||||||
{ name: 'Completed', count: stats?.completed || 0, icon: CheckCircle, color: 'text-green-500' },
|
count: stats?.pending || 0,
|
||||||
{ name: 'Failed', count: stats?.failed || 0, icon: AlertCircle, color: 'text-red-500' },
|
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 (
|
return (
|
||||||
<Card>
|
<Card className="rounded-xl border bg-gradient-to-br from-card to-muted/10">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<CardTitle>Order Pipeline</CardTitle>
|
<div className="flex items-center gap-3">
|
||||||
<CardDescription>Orders by current stage</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{stages.map((stage) => (
|
{stages.map((stage) => {
|
||||||
<div key={stage.name} className="flex items-center justify-between">
|
const percentage = total > 0 ? (stage.count / total) * 100 : 0
|
||||||
<div className="flex items-center gap-3">
|
return (
|
||||||
<stage.icon className={`h-5 w-5 ${stage.color}`} />
|
<div key={stage.name} className="group">
|
||||||
<span className="text-sm">{stage.name}</span>
|
<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>
|
||||||
{isLoading ? (
|
)
|
||||||
<div className="h-5 w-8 bg-muted animate-pulse rounded" />
|
})}
|
||||||
) : (
|
|
||||||
<span className="font-medium">{stage.count}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-6 pt-4 border-t">
|
||||||
<Link href="/admin/orders">
|
<Link href="/admin/orders">
|
||||||
<Button variant="outline" className="w-full">
|
<Button variant="outline" className="w-full gap-2 hover:bg-muted/50">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
View Pipeline
|
View Pipeline
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -238,15 +367,17 @@ export default function AdminDashboard() {
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[50vh]">
|
<div className="flex items-center justify-center h-[50vh]">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<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">
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<div className="p-4 rounded-full bg-destructive/10">
|
||||||
<div>
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
<p className="font-medium text-destructive">Failed to load dashboard</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Could not fetch statistics</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => refetch()}>
|
<div>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<p className="font-semibold text-destructive text-lg">Failed to load dashboard</p>
|
||||||
Retry
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,27 +385,39 @@ export default function AdminDashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
{/* Hero Header Section */}
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
{/* Background decorative elements */}
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
<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" />
|
||||||
<p className="text-muted-foreground">
|
<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" />
|
||||||
Overview of your LetsBe Hub platform
|
|
||||||
</p>
|
<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>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats grid */}
|
{/* Stats grid with enhanced cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Total Orders"
|
title="Total Orders"
|
||||||
|
|
@ -282,6 +425,8 @@ export default function AdminDashboard() {
|
||||||
description="All time orders"
|
description="All time orders"
|
||||||
icon={ShoppingCart}
|
icon={ShoppingCart}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
iconBgColor="bg-blue-100 dark:bg-blue-900/30"
|
||||||
|
iconColor="text-blue-600 dark:text-blue-400"
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Active Customers"
|
title="Active Customers"
|
||||||
|
|
@ -289,6 +434,8 @@ export default function AdminDashboard() {
|
||||||
description="Verified customers"
|
description="Verified customers"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
iconBgColor="bg-violet-100 dark:bg-violet-900/30"
|
||||||
|
iconColor="text-violet-600 dark:text-violet-400"
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Completed Deployments"
|
title="Completed Deployments"
|
||||||
|
|
@ -296,6 +443,8 @@ export default function AdminDashboard() {
|
||||||
description="Successfully provisioned"
|
description="Successfully provisioned"
|
||||||
icon={Server}
|
icon={Server}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
iconBgColor="bg-emerald-100 dark:bg-emerald-900/30"
|
||||||
|
iconColor="text-emerald-600 dark:text-emerald-400"
|
||||||
/>
|
/>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Pending Actions"
|
title="Pending Actions"
|
||||||
|
|
@ -303,6 +452,8 @@ export default function AdminDashboard() {
|
||||||
description="Orders needing attention"
|
description="Orders needing attention"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
iconBgColor="bg-amber-100 dark:bg-amber-900/30"
|
||||||
|
iconColor="text-amber-600 dark:text-amber-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -29,44 +29,103 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
Package,
|
Package,
|
||||||
|
Filter,
|
||||||
|
ServerCrash,
|
||||||
|
Zap,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Status badge component
|
// Status configuration with enhanced styling
|
||||||
function ServerStatusBadge({ status }: { status: ServerStatus }) {
|
const statusConfig: Record<ServerStatus, {
|
||||||
const config: Record<ServerStatus, { label: string; className: string; icon: React.ReactNode }> = {
|
label: string
|
||||||
online: {
|
bgColor: string
|
||||||
label: 'Online',
|
textColor: string
|
||||||
className: 'bg-green-100 text-green-800',
|
borderColor: string
|
||||||
icon: <CheckCircle className="h-3 w-3" />,
|
dotColor: string
|
||||||
},
|
iconBg: string
|
||||||
provisioning: {
|
iconColor: string
|
||||||
label: 'Provisioning',
|
cardGradient: string
|
||||||
className: 'bg-blue-100 text-blue-800',
|
icon: typeof CheckCircle
|
||||||
icon: <Clock className="h-3 w-3 animate-pulse" />,
|
}> = {
|
||||||
},
|
online: {
|
||||||
offline: {
|
label: 'Online',
|
||||||
label: 'Offline',
|
bgColor: 'bg-emerald-50 dark:bg-emerald-950/30',
|
||||||
className: 'bg-red-100 text-red-800',
|
textColor: 'text-emerald-700 dark:text-emerald-400',
|
||||||
icon: <XCircle className="h-3 w-3" />,
|
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
||||||
},
|
dotColor: 'bg-emerald-500',
|
||||||
pending: {
|
iconBg: 'bg-emerald-100 dark:bg-emerald-900/50',
|
||||||
label: 'Pending',
|
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||||
className: 'bg-yellow-100 text-yellow-800',
|
cardGradient: 'from-emerald-50/50 via-card to-card dark:from-emerald-950/20',
|
||||||
icon: <Clock className="h-3 w-3" />,
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const statusConfig = config[status]
|
// Enhanced status badge component with dot indicator and pulse animation
|
||||||
|
function ServerStatusBadge({ status }: { status: ServerStatus }) {
|
||||||
|
const config = statusConfig[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${statusConfig.className}`}>
|
<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}`}>
|
||||||
{statusConfig.icon}
|
<span className={`h-1.5 w-1.5 rounded-full ${config.dotColor} ${status === 'online' ? 'animate-pulse' : ''} ${status === 'provisioning' ? 'animate-pulse' : ''}`} />
|
||||||
{statusConfig.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server card component
|
// 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: {
|
function ServerCard({ server }: { server: {
|
||||||
id: string
|
id: string
|
||||||
domain: string
|
domain: string
|
||||||
|
|
@ -83,75 +142,88 @@ function ServerCard({ server }: { server: {
|
||||||
company: string | null
|
company: string | null
|
||||||
}
|
}
|
||||||
}}) {
|
}}) {
|
||||||
|
const config = statusConfig[server.serverStatus]
|
||||||
|
const StatusIcon = config.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="hover:shadow-md transition-shadow">
|
<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}`}>
|
||||||
<CardContent className="pt-6">
|
{/* Subtle gradient overlay on hover */}
|
||||||
<div className="flex items-start justify-between">
|
<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 items-center gap-3">
|
||||||
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
|
<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.serverStatus === 'online' ? 'bg-green-100' :
|
<Server className={`h-6 w-6 ${config.iconColor}`} />
|
||||||
server.serverStatus === 'provisioning' ? 'bg-blue-100' :
|
|
||||||
server.serverStatus === 'offline' ? 'bg-red-100' : 'bg-gray-100'
|
|
||||||
}`}>
|
|
||||||
<Server className={`h-6 w-6 ${
|
|
||||||
server.serverStatus === 'online' ? 'text-green-600' :
|
|
||||||
server.serverStatus === 'provisioning' ? 'text-blue-600' :
|
|
||||||
server.serverStatus === 'offline' ? 'text-red-600' : 'text-gray-600'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold">{server.domain}</h3>
|
<Link href={`/admin/servers/${server.id}`} className="hover:underline">
|
||||||
<ServerStatusBadge status={server.serverStatus} />
|
<h3 className="font-semibold text-base truncate group-hover:text-primary transition-colors">
|
||||||
|
{server.domain}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground font-mono">{server.serverIp}</p>
|
<p className="text-sm text-muted-foreground font-mono truncate">{server.serverIp}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/admin/orders/${server.id}`}>
|
<Link href={`/admin/servers/${server.id}`}>
|
||||||
<Button variant="ghost" size="icon">
|
<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" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
{/* Status badge */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="mb-4">
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
<ServerStatusBadge status={server.serverStatus} />
|
||||||
<div>
|
</div>
|
||||||
<p className="font-medium">{server.customer.name || server.customer.email}</p>
|
|
||||||
|
{/* 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 && (
|
{server.customer.company && (
|
||||||
<p className="text-xs text-muted-foreground">{server.customer.company}</p>
|
<p className="text-xs text-muted-foreground truncate">{server.customer.company}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<span className="capitalize">{server.tier.replace('_', ' ').toLowerCase()}</span>
|
<span className="capitalize truncate">{server.tier.replace('_', ' ').toLowerCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
<Cpu className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<span>SSH Port: {server.sshPort}</span>
|
<span className="truncate">SSH: {server.sshPort}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/30">
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<span>{new Date(server.createdAt).toLocaleDateString()}</span>
|
<span className="truncate">{new Date(server.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
{/* Tools section */}
|
||||||
|
<div className="pt-3 border-t border-border/50">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">Tools ({server.tools.length})</span>
|
<span className="text-sm font-medium">Tools ({server.tools.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{server.tools.map((tool) => (
|
{server.tools.slice(0, 4).map((tool) => (
|
||||||
<span
|
<ToolChip key={tool} tool={tool} />
|
||||||
key={tool}
|
|
||||||
className="px-2 py-0.5 text-xs bg-gray-100 rounded-full"
|
|
||||||
>
|
|
||||||
{tool}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -159,6 +231,173 @@ function ServerCard({ server }: { server: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export default function ServersPage() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<ServerStatus | 'all'>('all')
|
const [statusFilter, setStatusFilter] = useState<ServerStatus | 'all'>('all')
|
||||||
|
|
@ -192,15 +431,25 @@ export default function ServersPage() {
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const totalPages = data?.pagination?.totalPages || 1
|
const totalPages = data?.pagination?.totalPages || 1
|
||||||
|
const hasFilters = Boolean(search || statusFilter !== 'all')
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('')
|
||||||
|
setStatusFilter('all')
|
||||||
|
setCurrentPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[50vh]">
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="relative">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
|
||||||
<p className="text-muted-foreground">Loading servers...</p>
|
<div className="relative p-4 rounded-full bg-muted">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading servers...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -208,104 +457,106 @@ export default function ServersPage() {
|
||||||
// Error state
|
// Error state
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[50vh]">
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="relative">
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<div className="absolute inset-0 bg-destructive/20 rounded-full blur-xl" />
|
||||||
<div>
|
<div className="relative p-4 rounded-full bg-destructive/10">
|
||||||
<p className="font-medium text-destructive">Failed to load servers</p>
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{error instanceof Error ? error.message : 'An error occurred'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => refetch()}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Page header */}
|
{/* Hero Header Section */}
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
{/* Background decorations */}
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Servers</h1>
|
<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" />
|
||||||
<p className="text-muted-foreground">
|
<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" />
|
||||||
Manage deployed infrastructure servers
|
<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" />
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats cards */}
|
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="flex items-center gap-4">
|
||||||
<Card>
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 ring-1 ring-inset ring-primary/10">
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Server className="h-8 w-8 text-primary" />
|
<Server className="h-8 w-8 text-primary" />
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold">{stats.total}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Total Servers</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div>
|
||||||
</Card>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Servers</h1>
|
||||||
<Card>
|
<p className="text-muted-foreground mt-1">
|
||||||
<CardContent className="pt-6">
|
Manage and monitor your deployed infrastructure
|
||||||
<div className="flex items-center gap-3">
|
</p>
|
||||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold">{stats.online}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Online</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<Button
|
||||||
<Card>
|
variant="outline"
|
||||||
<CardContent className="pt-6">
|
size="sm"
|
||||||
<div className="flex items-center gap-3">
|
onClick={() => refetch()}
|
||||||
<Clock className="h-8 w-8 text-blue-600" />
|
disabled={isFetching}
|
||||||
<div>
|
className="shrink-0 gap-2 self-start md:self-auto"
|
||||||
<div className="text-2xl font-bold">{stats.provisioning}</div>
|
>
|
||||||
<p className="text-sm text-muted-foreground">Provisioning</p>
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
</div>
|
Refresh
|
||||||
</div>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<XCircle className="h-8 w-8 text-red-600" />
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold">{stats.offline}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Offline</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Stats Cards Grid */}
|
||||||
<Card>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<CardHeader>
|
<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 className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>All Servers</CardTitle>
|
<CardTitle className="text-lg">All Servers</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="mt-1">
|
||||||
{data?.pagination?.total || 0} server{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
|
{data?.pagination?.total || 0} server{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
|
||||||
|
{hasFilters && ' with current filters'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||||
|
{/* Search Input */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -316,30 +567,38 @@ export default function ServersPage() {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
}}
|
}}
|
||||||
className="pl-9 w-full sm:w-64"
|
className="pl-10 w-full sm:w-64 bg-muted/30 border-muted-foreground/20 focus:bg-background transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
value={statusFilter}
|
{/* Status Filter */}
|
||||||
onChange={(e) => {
|
<div className="relative">
|
||||||
setStatusFilter(e.target.value as ServerStatus | 'all')
|
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
setCurrentPage(1)
|
<select
|
||||||
}}
|
value={statusFilter}
|
||||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
onChange={(e) => {
|
||||||
>
|
setStatusFilter(e.target.value as ServerStatus | 'all')
|
||||||
<option value="all">All Status</option>
|
setCurrentPage(1)
|
||||||
<option value="online">Online</option>
|
}}
|
||||||
<option value="provisioning">Provisioning</option>
|
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="offline">Offline</option>
|
>
|
||||||
</select>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{data?.servers && data.servers.length > 0 ? (
|
{data?.servers && data.servers.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{/* Server grid */}
|
{/* Server Cards Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.servers.map((server) => (
|
{data.servers.map((server) => (
|
||||||
<ServerCard key={server.id} server={server} />
|
<ServerCard key={server.id} server={server} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -347,55 +606,15 @@ export default function ServersPage() {
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
<Pagination
|
||||||
<p className="text-sm text-muted-foreground">
|
currentPage={currentPage}
|
||||||
Page {currentPage} of {totalPages}
|
totalPages={totalPages}
|
||||||
</p>
|
onPageChange={setCurrentPage}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<EmptyState hasFilters={hasFilters} onClearFilters={clearFilters} />
|
||||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-lg font-medium">No servers found</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{search || statusFilter !== 'all'
|
|
||||||
? 'Try adjusting your search or filters'
|
|
||||||
: 'Servers will appear here once orders are provisioned'}
|
|
||||||
</p>
|
|
||||||
{(search || statusFilter !== 'all') && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
onClick={() => {
|
|
||||||
setSearch('')
|
|
||||||
setStatusFilter('all')
|
|
||||||
setCurrentPage(1)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
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 (for security in production)
|
||||||
|
const cronSecret = process.env.CRON_SECRET
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (cronSecret && 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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
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 (for security in production)
|
||||||
|
const cronSecret = process.env.CRON_SECRET
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (cronSecret && 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)
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -148,3 +148,97 @@ export async function PATCH(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { UserStatus, Prisma } from '@prisma/client'
|
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
|
* GET /api/v1/admin/customers
|
||||||
|
|
@ -86,3 +94,84 @@ export async function GET(request: NextRequest) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const addServerSchema = z.object({
|
||||||
|
netcupServerId: z.string().min(1, 'Netcup server ID is required'),
|
||||||
|
nickname: z.string().optional(),
|
||||||
|
purpose: z.string().optional(),
|
||||||
|
portainerUrl: z.string().url().optional(),
|
||||||
|
portainerUsername: z.string().optional(),
|
||||||
|
portainerPassword: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/enterprise-clients/[id]/servers
|
||||||
|
* List all servers for an enterprise client with live status from Netcup
|
||||||
|
*/
|
||||||
|
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 } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get client's servers from database
|
||||||
|
const servers = await enterpriseClientService.getClientServers(clientId)
|
||||||
|
|
||||||
|
// Enrich with live Netcup status if possible
|
||||||
|
const enrichedServers = await Promise.all(
|
||||||
|
servers.map(async (server) => {
|
||||||
|
try {
|
||||||
|
const netcupServer = await netcupService.getServer(server.netcupServerId, true)
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
netcupStatus: netcupServer?.state || 'unknown',
|
||||||
|
netcupHostname: netcupServer?.hostname,
|
||||||
|
netcupIps: [netcupServer?.primaryIpv4, netcupServer?.primaryIpv6].filter(Boolean) as string[]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
netcupStatus: 'error',
|
||||||
|
netcupHostname: null,
|
||||||
|
netcupIps: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(enrichedServers)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list servers:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list servers' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/enterprise-clients/[id]/servers
|
||||||
|
* Add a server to an enterprise client
|
||||||
|
*/
|
||||||
|
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 } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const validation = addServerSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client exists
|
||||||
|
const client = await enterpriseClientService.getClient(clientId)
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Netcup server exists
|
||||||
|
try {
|
||||||
|
const netcupServer = await netcupService.getServer(validation.data.netcupServerId)
|
||||||
|
if (!netcupServer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Netcup server not found' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to verify Netcup server. Is Netcup connected?' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await enterpriseClientService.addServer(clientId, validation.data)
|
||||||
|
return NextResponse.json(server, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add server:', error)
|
||||||
|
|
||||||
|
// Check for unique constraint violation
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'This Netcup server is already linked to this client' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to add server' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { statsCollectionService } from '@/lib/services/stats-collection-service'
|
||||||
|
import type { StatsRange } from '@/lib/services/stats-collection-service'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/enterprise-clients/[id]/stats
|
||||||
|
* Get aggregated stats overview for an enterprise client
|
||||||
|
*/
|
||||||
|
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 } = await context.params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const range = (searchParams.get('range') || '24h') as StatsRange
|
||||||
|
|
||||||
|
try {
|
||||||
|
const overview = await statsCollectionService.getClientStatsOverview(clientId)
|
||||||
|
return NextResponse.json({
|
||||||
|
...overview,
|
||||||
|
range
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get client stats:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get client stats' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { errorDashboardService } from '@/lib/services/error-dashboard-service'
|
||||||
|
|
||||||
|
// GET /api/v1/admin/enterprise-clients/error-summary
|
||||||
|
// Get error summary for ALL clients (for main enterprise page widget)
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summaries = await errorDashboardService.getAllClientsErrorSummary()
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = summaries.reduce(
|
||||||
|
(acc, client) => ({
|
||||||
|
criticalErrors24h: acc.criticalErrors24h + client.criticalErrors24h,
|
||||||
|
totalErrors24h: acc.totalErrors24h + client.totalErrors24h,
|
||||||
|
crashes24h: acc.crashes24h + client.crashes24h,
|
||||||
|
clientsWithIssues: acc.clientsWithIssues + (client.criticalErrors24h > 0 || client.crashes24h > 0 ? 1 : 0),
|
||||||
|
}),
|
||||||
|
{ criticalErrors24h: 0, totalErrors24h: 0, crashes24h: 0, clientsWithIssues: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate overall trend
|
||||||
|
const increasingCount = summaries.filter(s => s.errorTrend === 'increasing').length
|
||||||
|
const decreasingCount = summaries.filter(s => s.errorTrend === 'decreasing').length
|
||||||
|
let overallTrend: 'increasing' | 'decreasing' | 'stable' = 'stable'
|
||||||
|
if (increasingCount > decreasingCount) {
|
||||||
|
overallTrend = 'increasing'
|
||||||
|
} else if (decreasingCount > increasingCount) {
|
||||||
|
overallTrend = 'decreasing'
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
clients: summaries,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
overallTrend,
|
||||||
|
totalClients: summaries.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get error summary:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get error summary' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { enterpriseClientService } from '@/lib/services/enterprise-client-service'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const createClientSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
companyName: z.string().optional(),
|
||||||
|
contactEmail: z.string().email('Valid email is required'),
|
||||||
|
contactPhone: z.string().optional(),
|
||||||
|
notes: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/enterprise-clients
|
||||||
|
* List all enterprise clients
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clients = await enterpriseClientService.getClients()
|
||||||
|
return NextResponse.json(clients)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list enterprise clients:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list enterprise clients' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/enterprise-clients
|
||||||
|
* Create a new enterprise client
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const validation = createClientSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Validation failed', details: validation.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await enterpriseClientService.createClient(validation.data)
|
||||||
|
return NextResponse.json(client, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create enterprise client:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create enterprise client' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { netcupService, NetcupAuthError } from '@/lib/services/netcup-service'
|
||||||
|
|
||||||
|
// Store pending device auth sessions (in-memory for simplicity)
|
||||||
|
// In production, consider storing in Redis or database
|
||||||
|
const pendingAuthSessions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
deviceCode: string
|
||||||
|
expiresAt: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/netcup/auth
|
||||||
|
* Get current authentication status
|
||||||
|
*/
|
||||||
|
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 status = await netcupService.getAuthStatus()
|
||||||
|
|
||||||
|
return NextResponse.json(status)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Netcup auth status:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get auth status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/netcup/auth
|
||||||
|
* Initiate device auth flow or poll for token
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - action: 'initiate' | 'poll' | 'disconnect'
|
||||||
|
* - sessionId?: string (for poll action)
|
||||||
|
*/
|
||||||
|
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 = await request.json()
|
||||||
|
const { action, sessionId } = body as {
|
||||||
|
action: 'initiate' | 'poll' | 'disconnect'
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action || !['initiate', 'poll', 'disconnect'].includes(action)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid action. Must be: initiate, poll, or disconnect' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'initiate': {
|
||||||
|
// Start device auth flow
|
||||||
|
const deviceAuth = await netcupService.initiateDeviceAuth()
|
||||||
|
|
||||||
|
// Store session for polling
|
||||||
|
const newSessionId = crypto.randomUUID()
|
||||||
|
pendingAuthSessions.set(newSessionId, {
|
||||||
|
deviceCode: deviceAuth.device_code,
|
||||||
|
expiresAt: Date.now() + deviceAuth.expires_in * 1000,
|
||||||
|
interval: deviceAuth.interval,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up expired sessions
|
||||||
|
for (const [id, sess] of pendingAuthSessions) {
|
||||||
|
if (sess.expiresAt < Date.now()) {
|
||||||
|
pendingAuthSessions.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sessionId: newSessionId,
|
||||||
|
userCode: deviceAuth.user_code,
|
||||||
|
verificationUri: deviceAuth.verification_uri,
|
||||||
|
verificationUriComplete: deviceAuth.verification_uri_complete,
|
||||||
|
expiresIn: deviceAuth.expires_in,
|
||||||
|
interval: deviceAuth.interval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'poll': {
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Session ID required for polling' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSession = pendingAuthSessions.get(sessionId)
|
||||||
|
|
||||||
|
if (!pendingSession) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Session not found or expired' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSession.expiresAt < Date.now()) {
|
||||||
|
pendingAuthSessions.delete(sessionId)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Session expired' },
|
||||||
|
{ status: 410 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await netcupService.pollForToken(pendingSession.deviceCode)
|
||||||
|
|
||||||
|
if (!tokens) {
|
||||||
|
// Still waiting for user authorization
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
status: 'pending',
|
||||||
|
message: 'Waiting for user authorization',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success! Clean up session
|
||||||
|
pendingAuthSessions.delete(sessionId)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
status: 'authenticated',
|
||||||
|
message: 'Successfully authenticated with Netcup',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
pendingAuthSessions.delete(sessionId)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'disconnect': {
|
||||||
|
// Clear stored tokens
|
||||||
|
await netcupService.clearTokens()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Disconnected from Netcup',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in Netcup auth:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process auth request' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
netcupService,
|
||||||
|
NetcupAuthError,
|
||||||
|
NetcupApiError,
|
||||||
|
} from '@/lib/services/netcup-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/netcup/servers/[id]/metrics
|
||||||
|
* Get server metrics (CPU, disk, network)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - hours: Number of hours of history (default: 24, max: 1440)
|
||||||
|
* - type: 'all' | 'cpu' | 'disk' | 'network' (default: 'all')
|
||||||
|
*/
|
||||||
|
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: serverId } = await params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const hours = Math.min(parseInt(searchParams.get('hours') || '24', 10), 1440)
|
||||||
|
const type = searchParams.get('type') || 'all'
|
||||||
|
|
||||||
|
// Check if authenticated with Netcup
|
||||||
|
const isAuth = await netcupService.isAuthenticated()
|
||||||
|
if (!isAuth) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Not authenticated with Netcup' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'cpu': {
|
||||||
|
const cpu = await netcupService.getCpuMetrics(serverId, hours)
|
||||||
|
console.log('CPU metrics response:', JSON.stringify(cpu, null, 2))
|
||||||
|
return NextResponse.json({ cpu, period: `${hours}h` })
|
||||||
|
}
|
||||||
|
case 'disk': {
|
||||||
|
const disk = await netcupService.getDiskMetrics(serverId, hours)
|
||||||
|
return NextResponse.json({ disk, period: `${hours}h` })
|
||||||
|
}
|
||||||
|
case 'network': {
|
||||||
|
const network = await netcupService.getNetworkMetrics(serverId, hours)
|
||||||
|
return NextResponse.json({ network, period: `${hours}h` })
|
||||||
|
}
|
||||||
|
case 'all':
|
||||||
|
default: {
|
||||||
|
const metrics = await netcupService.getAllMetrics(serverId, hours)
|
||||||
|
console.log('All metrics response:', JSON.stringify(metrics, null, 2))
|
||||||
|
return NextResponse.json(metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting server metrics:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetcupApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get server metrics' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
netcupService,
|
||||||
|
NetcupAuthError,
|
||||||
|
NetcupApiError,
|
||||||
|
PowerAction,
|
||||||
|
} from '@/lib/services/netcup-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/netcup/servers/[id]
|
||||||
|
* Get server details
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - liveInfo=true: Include live CPU/RAM/disk usage
|
||||||
|
*/
|
||||||
|
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: serverId } = await params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const loadLiveInfo = searchParams.get('liveInfo') === 'true'
|
||||||
|
|
||||||
|
const server = await netcupService.getServer(serverId, loadLiveInfo)
|
||||||
|
|
||||||
|
return NextResponse.json(server)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Netcup server:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetcupApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get server' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/netcup/servers/[id]
|
||||||
|
* Perform action on server
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - action: 'power' | 'reinstall' | 'rescue' | 'hostname' | 'nickname'
|
||||||
|
* - powerAction?: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET' | 'POWEROFF'
|
||||||
|
* - imageFlavour?: string (for reinstall)
|
||||||
|
* - rescueAction?: 'activate' | 'deactivate' (for rescue)
|
||||||
|
* - hostname?: string (for hostname update)
|
||||||
|
* - nickname?: string (for nickname update)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
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: serverId } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const { action, powerAction, imageFlavour, rescueAction, hostname, nickname } = body as {
|
||||||
|
action: 'power' | 'reinstall' | 'rescue' | 'hostname' | 'nickname' | 'imageFlavours'
|
||||||
|
powerAction?: PowerAction
|
||||||
|
imageFlavour?: string
|
||||||
|
rescueAction?: 'activate' | 'deactivate'
|
||||||
|
hostname?: string
|
||||||
|
nickname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Action required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'power': {
|
||||||
|
if (!powerAction || !['ON', 'OFF', 'POWERCYCLE', 'RESET', 'POWEROFF'].includes(powerAction)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Valid powerAction required: ON, OFF, POWERCYCLE, RESET, POWEROFF' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await netcupService.powerAction(serverId, powerAction)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Power action ${powerAction} executed`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'reinstall': {
|
||||||
|
if (!imageFlavour) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'imageFlavour required for reinstall' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await netcupService.reinstallServer(serverId, imageFlavour)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Reinstall started',
|
||||||
|
taskId: task.taskId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rescue': {
|
||||||
|
if (!rescueAction || !['activate', 'deactivate'].includes(rescueAction)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'rescueAction required: activate or deactivate' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rescueAction === 'activate') {
|
||||||
|
const credentials = await netcupService.activateRescue(serverId)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rescue mode activated',
|
||||||
|
credentials,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await netcupService.deactivateRescue(serverId)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Rescue mode deactivated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'hostname': {
|
||||||
|
if (!hostname) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'hostname required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await netcupService.updateHostname(serverId, hostname)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Hostname updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nickname': {
|
||||||
|
if (nickname === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'nickname required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await netcupService.updateNickname(serverId, nickname)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Nickname updated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'imageFlavours': {
|
||||||
|
const flavours = await netcupService.getImageFlavours(serverId)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
flavours,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid action' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error performing Netcup server action:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetcupApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to perform server action' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
netcupService,
|
||||||
|
NetcupAuthError,
|
||||||
|
NetcupApiError,
|
||||||
|
} from '@/lib/services/netcup-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/netcup/servers/[id]/snapshots
|
||||||
|
* Get list of server snapshots
|
||||||
|
*/
|
||||||
|
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: serverId } = await params
|
||||||
|
|
||||||
|
// Check if authenticated with Netcup
|
||||||
|
const isAuth = await netcupService.isAuthenticated()
|
||||||
|
if (!isAuth) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Not authenticated with Netcup' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshots = await netcupService.getSnapshots(serverId)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
snapshots,
|
||||||
|
count: snapshots.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting server snapshots:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetcupApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get server snapshots' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/netcup/servers/[id]/snapshots
|
||||||
|
* Create a new snapshot or perform snapshot actions
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - action: 'create' | 'delete' | 'revert' | 'check'
|
||||||
|
* - name?: string (snapshot name for create/delete/revert)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
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: serverId } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const { action, name } = body as {
|
||||||
|
action: 'create' | 'delete' | 'revert' | 'check'
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Action required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authenticated with Netcup
|
||||||
|
const isAuth = await netcupService.isAuthenticated()
|
||||||
|
if (!isAuth) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Not authenticated with Netcup' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'check': {
|
||||||
|
const result = await netcupService.canCreateSnapshot(serverId)
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create': {
|
||||||
|
const result = await netcupService.createSnapshot(serverId, name)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Snapshot created: ${result.name}`,
|
||||||
|
snapshot: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Snapshot name required for delete' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await netcupService.deleteSnapshot(serverId, name)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Snapshot deleted: ${name}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'revert': {
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Snapshot name required for revert' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await netcupService.revertSnapshot(serverId, name)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Reverting to snapshot: ${name}`,
|
||||||
|
taskId: task.taskId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid action. Use: create, delete, revert, or check' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error performing snapshot action:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetcupApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to perform snapshot action' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { netcupService, NetcupAuthError, NetcupApiError } from '@/lib/services/netcup-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/netcup/servers
|
||||||
|
* Get list of all Netcup servers
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - liveInfo: "true" to load live status (ON/OFF) for each server (slower)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authenticated
|
||||||
|
const isAuth = await netcupService.isAuthenticated()
|
||||||
|
if (!isAuth) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Not authenticated with Netcup. Please connect your account first.' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for liveInfo query parameter
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const loadLiveInfo = searchParams.get('liveInfo') === 'true'
|
||||||
|
|
||||||
|
const servers = await netcupService.getServers(loadLiveInfo)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
servers,
|
||||||
|
count: servers.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Netcup servers:', error)
|
||||||
|
|
||||||
|
if (error instanceof NetcupAuthError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NetcupApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: error.statusCode }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get servers' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { netcupService } from '@/lib/services/netcup-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/netcup/tasks/[id]
|
||||||
|
* Get task status for polling during long-running operations (reinstall, etc.)
|
||||||
|
*/
|
||||||
|
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: taskId } = await params
|
||||||
|
|
||||||
|
const task = await netcupService.getTask(taskId)
|
||||||
|
|
||||||
|
return NextResponse.json(task)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting task status:', error)
|
||||||
|
|
||||||
|
// Check if it's a 404 (task not found or expired)
|
||||||
|
if (error instanceof Error && error.message.includes('404')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Task not found', taskId: (await params).id, status: 'UNKNOWN' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get task status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { AutomationMode } from '@prisma/client'
|
||||||
|
import {
|
||||||
|
processAutomation,
|
||||||
|
setAutomationMode,
|
||||||
|
resumeAutomation,
|
||||||
|
pauseAutomation,
|
||||||
|
takeManualControl,
|
||||||
|
enableAutoMode,
|
||||||
|
} from '@/lib/services/automation-worker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/automation
|
||||||
|
* Get current automation status
|
||||||
|
*/
|
||||||
|
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: orderId } = await params
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
automationMode: true,
|
||||||
|
automationPausedAt: true,
|
||||||
|
automationPausedReason: true,
|
||||||
|
source: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
mode: order.automationMode,
|
||||||
|
pausedAt: order.automationPausedAt,
|
||||||
|
pausedReason: order.automationPausedReason,
|
||||||
|
source: order.source,
|
||||||
|
status: order.status,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting automation status:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get automation status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/admin/orders/[id]/automation
|
||||||
|
* Change automation mode
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - action: 'auto' | 'manual' | 'pause' | 'resume'
|
||||||
|
* - reason?: string (optional reason for pause)
|
||||||
|
*/
|
||||||
|
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: orderId } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const { action, reason } = body as { action: string; reason?: string }
|
||||||
|
|
||||||
|
if (!action || !['auto', 'manual', 'pause', 'resume'].includes(action)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid action. Must be: auto, manual, pause, or resume' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check order exists
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
switch (action) {
|
||||||
|
case 'auto':
|
||||||
|
result = await enableAutoMode(orderId)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'manual':
|
||||||
|
await takeManualControl(orderId)
|
||||||
|
result = { triggered: false, action: 'Switched to manual mode' }
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'pause':
|
||||||
|
await pauseAutomation(orderId, reason)
|
||||||
|
result = { triggered: false, action: 'Automation paused' }
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'resume':
|
||||||
|
result = await resumeAutomation(orderId)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated order
|
||||||
|
const updatedOrder = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
automationMode: true,
|
||||||
|
automationPausedAt: true,
|
||||||
|
automationPausedReason: true,
|
||||||
|
source: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
automation: {
|
||||||
|
mode: updatedOrder?.automationMode,
|
||||||
|
pausedAt: updatedOrder?.automationPausedAt,
|
||||||
|
pausedReason: updatedOrder?.automationPausedReason,
|
||||||
|
source: updatedOrder?.source,
|
||||||
|
status: updatedOrder?.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating automation:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update automation' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/automation
|
||||||
|
* Trigger automation processing (useful for manual refresh)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
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: orderId } = await params
|
||||||
|
|
||||||
|
// Check order exists
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process automation
|
||||||
|
const result = await processAutomation(orderId)
|
||||||
|
|
||||||
|
// Get updated order
|
||||||
|
const updatedOrder = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
automationMode: true,
|
||||||
|
automationPausedAt: true,
|
||||||
|
automationPausedReason: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
automation: {
|
||||||
|
mode: updatedOrder?.automationMode,
|
||||||
|
status: updatedOrder?.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing automation:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process automation' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string; containerId: string; action: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ACTIONS = ['start', 'stop', 'restart'] as const
|
||||||
|
type ContainerAction = (typeof ALLOWED_ACTIONS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/containers/[containerId]/[action]
|
||||||
|
* Perform a container action (start, stop, restart)
|
||||||
|
*/
|
||||||
|
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: orderId, containerId, action } = await context.params
|
||||||
|
|
||||||
|
// Validate action
|
||||||
|
if (!ALLOWED_ACTIONS.includes(action as ContainerAction)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid action: ${action}. Allowed actions: ${ALLOWED_ACTIONS.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the action
|
||||||
|
switch (action as ContainerAction) {
|
||||||
|
case 'start':
|
||||||
|
await client.startContainer(containerId)
|
||||||
|
break
|
||||||
|
case 'stop':
|
||||||
|
await client.stopContainer(containerId)
|
||||||
|
break
|
||||||
|
case 'restart':
|
||||||
|
await client.restartContainer(containerId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, action })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ${action} container:`, error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : `Failed to ${action} container` },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string; containerId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/containers/[containerId]/logs
|
||||||
|
* Get container logs
|
||||||
|
* Query: ?tail=100 (number of lines)
|
||||||
|
*/
|
||||||
|
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: orderId, containerId } = await context.params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const tail = parseInt(searchParams.get('tail') || '100', 10)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await client.getContainerLogs(containerId, tail)
|
||||||
|
|
||||||
|
return NextResponse.json({ logs })
|
||||||
|
} 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string; containerId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/containers/[containerId]
|
||||||
|
* Get container details
|
||||||
|
*/
|
||||||
|
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: orderId, containerId } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await client.getContainer(containerId)
|
||||||
|
|
||||||
|
// Parse ports from NetworkSettings.Ports (inspect format)
|
||||||
|
const ports: Array<{ private: number; public?: number; type: string }> = []
|
||||||
|
if (container.NetworkSettings.Ports) {
|
||||||
|
for (const [portKey, bindings] of Object.entries(container.NetworkSettings.Ports)) {
|
||||||
|
// portKey is like "80/tcp" or "443/tcp"
|
||||||
|
const [port, type] = portKey.split('/')
|
||||||
|
const privatePort = parseInt(port, 10)
|
||||||
|
|
||||||
|
if (bindings && bindings.length > 0) {
|
||||||
|
// Has host bindings
|
||||||
|
for (const binding of bindings) {
|
||||||
|
ports.push({
|
||||||
|
private: privatePort,
|
||||||
|
public: binding.HostPort ? parseInt(binding.HostPort, 10) : undefined,
|
||||||
|
type: type || 'tcp',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Exposed but not published
|
||||||
|
ports.push({
|
||||||
|
private: privatePort,
|
||||||
|
type: type || 'tcp',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform to a more readable format
|
||||||
|
const formattedContainer = {
|
||||||
|
id: container.Id,
|
||||||
|
shortId: container.Id.substring(0, 12),
|
||||||
|
name: container.Name?.replace(/^\//, '') || container.Id.substring(0, 12),
|
||||||
|
image: container.Image,
|
||||||
|
state: container.State.Status, // State is an object in inspect response
|
||||||
|
status: container.State.Running ? 'running' : container.State.Status,
|
||||||
|
created: container.Created,
|
||||||
|
config: {
|
||||||
|
hostname: container.Config.Hostname,
|
||||||
|
image: container.Config.Image,
|
||||||
|
workingDir: container.Config.WorkingDir,
|
||||||
|
env: container.Config.Env || [],
|
||||||
|
},
|
||||||
|
hostConfig: {
|
||||||
|
restartPolicy: container.HostConfig.RestartPolicy,
|
||||||
|
},
|
||||||
|
ports,
|
||||||
|
networks: container.NetworkSettings.Networks,
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(formattedContainer)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get container details:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to get container details' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/admin/orders/[id]/containers/[containerId]
|
||||||
|
* Remove a container
|
||||||
|
* Query: ?force=true (force remove running container)
|
||||||
|
*/
|
||||||
|
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: orderId, containerId } = await context.params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const force = searchParams.get('force') === 'true'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.removeContainer(containerId, force)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove container:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to remove container' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string; containerId: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/containers/[containerId]/stats
|
||||||
|
* Get stats for a single container
|
||||||
|
*/
|
||||||
|
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: orderId, containerId } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await client.getContainerStats(containerId)
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Container not running or stats unavailable' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get container stats:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to get container stats' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/containers
|
||||||
|
* List all containers for an order's Portainer instance
|
||||||
|
* Query: ?all=true (include stopped containers)
|
||||||
|
*/
|
||||||
|
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: orderId } = await context.params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const all = searchParams.get('all') !== 'false' // Default to true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers = await client.listContainers(all)
|
||||||
|
|
||||||
|
// Transform to a simpler format for the frontend
|
||||||
|
const formattedContainers = containers.map((c) => ({
|
||||||
|
id: c.Id,
|
||||||
|
shortId: c.Id.substring(0, 12),
|
||||||
|
name: c.Names[0]?.replace(/^\//, '') || c.Id.substring(0, 12),
|
||||||
|
image: c.Image,
|
||||||
|
state: c.State,
|
||||||
|
status: c.Status,
|
||||||
|
created: c.Created,
|
||||||
|
ports: c.Ports.map((p) => ({
|
||||||
|
private: p.PrivatePort,
|
||||||
|
public: p.PublicPort,
|
||||||
|
type: p.Type,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json(formattedContainers)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list containers:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to list containers' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/containers/stats
|
||||||
|
* Get stats for all running containers
|
||||||
|
*/
|
||||||
|
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: orderId } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured for this order' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await client.getAllContainerStats()
|
||||||
|
|
||||||
|
return NextResponse.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get container stats:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to get container stats' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { getDnsStatus } from '@/lib/services/dns-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/dns
|
||||||
|
* Get DNS verification status for an order
|
||||||
|
*/
|
||||||
|
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: orderId } = await params
|
||||||
|
|
||||||
|
const status = await getDnsStatus(orderId)
|
||||||
|
|
||||||
|
return NextResponse.json(status)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting DNS status:', error)
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Order not found') {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get DNS status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { skipDnsVerification, getDnsStatus } from '@/lib/services/dns-service'
|
||||||
|
import { processAutomation } from '@/lib/services/automation-worker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/dns/skip
|
||||||
|
* Skip DNS verification with manual override (staff only)
|
||||||
|
*
|
||||||
|
* This allows staff to bypass DNS verification when they know
|
||||||
|
* DNS is configured correctly but checks are failing (e.g., DNS propagation delay).
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
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: orderId } = await params
|
||||||
|
|
||||||
|
// Skip DNS verification
|
||||||
|
await skipDnsVerification(orderId)
|
||||||
|
|
||||||
|
// Get updated status
|
||||||
|
const status = await getDnsStatus(orderId)
|
||||||
|
|
||||||
|
// Process automation (will auto-trigger next step if in AUTO mode)
|
||||||
|
const automationResult = await processAutomation(orderId)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'DNS verification skipped via manual override',
|
||||||
|
status,
|
||||||
|
automation: automationResult,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error skipping DNS verification:', error)
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Order not found') {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to skip DNS verification' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { runDnsVerification } from '@/lib/services/dns-service'
|
||||||
|
import { processAutomation } from '@/lib/services/automation-worker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/dns/verify
|
||||||
|
* Trigger DNS verification for an order
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
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: orderId } = await params
|
||||||
|
|
||||||
|
// Run DNS verification
|
||||||
|
const result = await runDnsVerification(orderId)
|
||||||
|
|
||||||
|
// Process automation (will auto-trigger next step if in AUTO mode)
|
||||||
|
const automationResult = await processAutomation(orderId)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
verification: result,
|
||||||
|
automation: automationResult,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying DNS:', error)
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === 'Order not found') {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (error.message === 'Server IP not configured') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server IP not configured. Please add server credentials first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to verify DNS' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,17 @@ import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { OrderStatus } from '@prisma/client'
|
import { OrderStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
// Polling interval in milliseconds - faster = smoother logs
|
||||||
|
const POLL_INTERVAL_MS = 500
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/admin/orders/[id]/logs/stream
|
* GET /api/v1/admin/orders/[id]/logs/stream
|
||||||
* Stream provisioning logs via Server-Sent Events
|
* Stream provisioning logs via Server-Sent Events
|
||||||
|
*
|
||||||
|
* Optimized for low-latency log streaming:
|
||||||
|
* - 500ms polling interval for near-real-time updates
|
||||||
|
* - Only sends status updates when changed
|
||||||
|
* - Batches multiple logs per event for efficiency
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -33,6 +41,7 @@ export async function GET(
|
||||||
// Create SSE response
|
// Create SSE response
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
let lastLogId: string | null = null
|
let lastLogId: string | null = null
|
||||||
|
let lastStatus: OrderStatus = order.status
|
||||||
let isActive = true
|
let isActive = true
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
|
|
@ -42,16 +51,31 @@ export async function GET(
|
||||||
encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`)
|
encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Send initial status
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: order.status })}\n\n`)
|
||||||
|
)
|
||||||
|
|
||||||
// Poll for new logs
|
// Poll for new logs
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current order status
|
// Fetch new logs and current status in parallel for speed
|
||||||
const currentOrder = await prisma.order.findUnique({
|
const [newLogs, currentOrder] = await Promise.all([
|
||||||
where: { id: orderId },
|
prisma.provisioningLog.findMany({
|
||||||
select: { status: true },
|
where: {
|
||||||
})
|
orderId,
|
||||||
|
...(lastLogId ? { id: { gt: lastLogId } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
take: 100, // Increased batch size
|
||||||
|
}),
|
||||||
|
prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { status: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
if (!currentOrder) {
|
if (!currentOrder) {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
|
|
@ -61,23 +85,11 @@ export async function GET(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build query for new logs
|
// Send logs immediately as they arrive
|
||||||
const query: Parameters<typeof prisma.provisioningLog.findMany>[0] = {
|
|
||||||
where: {
|
|
||||||
orderId,
|
|
||||||
...(lastLogId ? { id: { gt: lastLogId } } : {}),
|
|
||||||
},
|
|
||||||
orderBy: { timestamp: 'asc' as const },
|
|
||||||
take: 50,
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLogs = await prisma.provisioningLog.findMany(query)
|
|
||||||
|
|
||||||
if (newLogs.length > 0) {
|
if (newLogs.length > 0) {
|
||||||
// Update last seen log ID
|
|
||||||
lastLogId = newLogs[newLogs.length - 1].id
|
lastLogId = newLogs[newLogs.length - 1].id
|
||||||
|
|
||||||
// Send each log as an event
|
// Send each log individually for smooth streaming
|
||||||
for (const log of newLogs) {
|
for (const log of newLogs) {
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`event: log\ndata: ${JSON.stringify({
|
encoder.encode(`event: log\ndata: ${JSON.stringify({
|
||||||
|
|
@ -91,10 +103,13 @@ export async function GET(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send status update
|
// Only send status update if changed
|
||||||
controller.enqueue(
|
if (currentOrder.status !== lastStatus) {
|
||||||
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
|
lastStatus = currentOrder.status
|
||||||
)
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if provisioning is complete
|
// Check if provisioning is complete
|
||||||
const terminalStatuses: OrderStatus[] = [
|
const terminalStatuses: OrderStatus[] = [
|
||||||
|
|
@ -114,8 +129,8 @@ export async function GET(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue polling if still provisioning
|
// Continue polling with fast interval
|
||||||
setTimeout(poll, 2000) // Poll every 2 seconds
|
setTimeout(poll, POLL_INTERVAL_MS)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('SSE polling error:', err)
|
console.error('SSE polling error:', err)
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
|
|
@ -125,7 +140,7 @@ export async function GET(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start polling
|
// Start polling immediately
|
||||||
poll()
|
poll()
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { credentialService } from '@/lib/services/credential-service'
|
||||||
|
import { Agent, fetch as undiciFetch, FormData as UndiciFormData } from 'undici'
|
||||||
|
|
||||||
|
// Create undici agent that accepts self-signed certificates
|
||||||
|
const insecureAgent = new Agent({
|
||||||
|
connect: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/portainer/init
|
||||||
|
* Initialize Portainer by creating the local Docker endpoint
|
||||||
|
* This is needed when Portainer is set up with --admin-password-file (skips wizard)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// Auth check
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: orderId } = await params
|
||||||
|
|
||||||
|
// Get order with Portainer credentials
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
serverIp: true,
|
||||||
|
portainerUsername: true,
|
||||||
|
portainerPasswordEnc: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.serverIp || !order.portainerUsername || !order.portainerPasswordEnc) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Portainer credentials not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const portainerUrl = `https://${order.serverIp}:9443`
|
||||||
|
const password = credentialService.decrypt(order.portainerPasswordEnc)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Authenticate with Portainer
|
||||||
|
const authResponse = await undiciFetch(`${portainerUrl}/api/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: order.portainerUsername,
|
||||||
|
password: password,
|
||||||
|
}),
|
||||||
|
dispatcher: insecureAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!authResponse.ok) {
|
||||||
|
const error = await authResponse.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Authentication failed: ${error}` },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = await authResponse.json() as { jwt: string }
|
||||||
|
const jwt = authData.jwt
|
||||||
|
|
||||||
|
// Step 2: Check if endpoint already exists
|
||||||
|
const endpointsResponse = await undiciFetch(`${portainerUrl}/api/endpoints`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
dispatcher: insecureAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (endpointsResponse.ok) {
|
||||||
|
const endpoints = await endpointsResponse.json() as Array<{ Id: number; Name: string }>
|
||||||
|
if (endpoints.length > 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Endpoint already exists',
|
||||||
|
endpoint: endpoints[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Create local Docker socket endpoint
|
||||||
|
const formData = new UndiciFormData()
|
||||||
|
formData.append('Name', 'local')
|
||||||
|
formData.append('EndpointCreationType', '1') // 1 = Docker socket
|
||||||
|
|
||||||
|
const createResponse = await undiciFetch(`${portainerUrl}/api/endpoints`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
dispatcher: insecureAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
const error = await createResponse.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to create endpoint: ${error}` },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = await createResponse.json() as { Id: number; Name: string }
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Local Docker endpoint created successfully',
|
||||||
|
endpoint,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Portainer:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { credentialService } from '@/lib/services/credential-service'
|
||||||
|
import { createPortainerClient } from '@/lib/services/portainer-client'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/portainer
|
||||||
|
* Get Portainer credentials for an order (decrypted)
|
||||||
|
*/
|
||||||
|
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: orderId } = await context.params
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
serverIp: true,
|
||||||
|
portainerUsername: true,
|
||||||
|
portainerPasswordEnc: true,
|
||||||
|
credentialsSyncedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct Portainer URL from server IP (Portainer is accessed directly, not via subdomain)
|
||||||
|
const portainerUrl = order.serverIp ? `https://${order.serverIp}:9443` : null
|
||||||
|
|
||||||
|
// Decrypt password if present
|
||||||
|
let password: string | null = null
|
||||||
|
if (order.portainerPasswordEnc) {
|
||||||
|
try {
|
||||||
|
password = credentialService.decrypt(order.portainerPasswordEnc)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt Portainer password:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: portainerUrl,
|
||||||
|
username: order.portainerUsername,
|
||||||
|
password,
|
||||||
|
syncedAt: order.credentialsSyncedAt,
|
||||||
|
isConfigured: !!(order.portainerUsername && order.portainerPasswordEnc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/admin/orders/[id]/portainer
|
||||||
|
* Update Portainer credentials (manual entry)
|
||||||
|
*/
|
||||||
|
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: orderId } = await context.params
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { username, password } = body
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData: {
|
||||||
|
portainerUsername?: string
|
||||||
|
portainerPasswordEnc?: string
|
||||||
|
credentialsSyncedAt?: Date
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
if (username !== undefined) {
|
||||||
|
updateData.portainerUsername = username
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== undefined) {
|
||||||
|
updateData.portainerPasswordEnc = credentialService.encrypt(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as manually entered (update sync timestamp)
|
||||||
|
updateData.credentialsSyncedAt = new Date()
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: updateData,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/portainer
|
||||||
|
* Test Portainer connection
|
||||||
|
*/
|
||||||
|
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: orderId } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createPortainerClient(orderId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Portainer credentials not configured',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await client.testConnection()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: connected,
|
||||||
|
error: connected ? null : 'Failed to connect to Portainer',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Connection test failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,26 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { OrderStatus } from '@prisma/client'
|
import { JobStatus, OrderStatus } from '@prisma/client'
|
||||||
import { jobService } from '@/lib/services/job-service'
|
import { randomBytes } from 'crypto'
|
||||||
|
import {
|
||||||
|
generateJobConfig,
|
||||||
|
decryptPassword,
|
||||||
|
generateRunnerToken,
|
||||||
|
hashRunnerToken,
|
||||||
|
DockerHubCredentials,
|
||||||
|
} from '@/lib/services/config-generator'
|
||||||
|
import { spawnProvisioningContainer, isDockerAvailable } from '@/lib/services/docker-spawner'
|
||||||
|
import { netcupService } from '@/lib/services/netcup-service'
|
||||||
|
import { settingsService } from '@/lib/services/settings-service'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/admin/orders/[id]/provision
|
* POST /api/v1/admin/orders/[id]/provision
|
||||||
* Trigger provisioning for an order
|
* Trigger provisioning for an order
|
||||||
|
*
|
||||||
|
* This spawns a Docker container to handle provisioning asynchronously.
|
||||||
|
* The container streams logs back to the Hub via the job logs API.
|
||||||
|
* Use the SSE endpoint at /logs/stream to monitor progress in real-time.
|
||||||
*/
|
*/
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -24,18 +38,25 @@ export async function POST(
|
||||||
// Check if order exists and is ready for provisioning
|
// Check if order exists and is ready for provisioning
|
||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
serverConnection: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate order status - can only provision from DNS_READY or FAILED
|
// Validate order status - can only provision from SERVER_READY, DNS_READY, or FAILED
|
||||||
const validStatuses: OrderStatus[] = [OrderStatus.DNS_READY, OrderStatus.FAILED]
|
const validStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.SERVER_READY,
|
||||||
|
OrderStatus.DNS_READY,
|
||||||
|
OrderStatus.FAILED,
|
||||||
|
]
|
||||||
if (!validStatuses.includes(order.status)) {
|
if (!validStatuses.includes(order.status)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `Cannot provision order in status ${order.status}. Must be DNS_READY or FAILED.`,
|
error: `Cannot provision order in status ${order.status}. Must be SERVER_READY, DNS_READY, or FAILED.`,
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
|
|
@ -49,14 +70,219 @@ export async function POST(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create provisioning job
|
// Validate provisioning config
|
||||||
const result = await jobService.createJobForOrder(orderId)
|
if (!order.customer || !order.companyName || !order.licenseKey) {
|
||||||
const { jobId } = JSON.parse(result)
|
return NextResponse.json(
|
||||||
|
{ error: 'Provisioning config not configured. Please set customer, company name, and license key.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Docker availability
|
||||||
|
if (!await isDockerAvailable()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Docker is not available on the server' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Netcup hostname if server is linked
|
||||||
|
// Hostname format: {customer}-{first 8 chars of orderId}
|
||||||
|
if (order.netcupServerId && order.customer) {
|
||||||
|
try {
|
||||||
|
await netcupService.setServerHostname(order.netcupServerId, order.customer, orderId)
|
||||||
|
const hostname = `${order.customer.toLowerCase().replace(/[^a-z0-9]/g, '')}-${orderId.slice(0, 8)}`
|
||||||
|
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'INFO',
|
||||||
|
message: `Set Netcup server hostname to: ${hostname}`,
|
||||||
|
step: 'init',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// Not critical - log and continue
|
||||||
|
console.error('Failed to set Netcup hostname:', error)
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'WARN',
|
||||||
|
message: `Could not set Netcup hostname: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
step: 'init',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update server connection with registration token
|
||||||
|
let serverConnection = order.serverConnection
|
||||||
|
const registrationToken = `rt_${randomBytes(32).toString('hex')}`
|
||||||
|
|
||||||
|
if (!serverConnection) {
|
||||||
|
// Generate registration token for phone-home
|
||||||
|
serverConnection = await prisma.serverConnection.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
registrationToken,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Regenerate token if retrying
|
||||||
|
serverConnection = await prisma.serverConnection.update({
|
||||||
|
where: { id: serverConnection.id },
|
||||||
|
data: {
|
||||||
|
registrationToken,
|
||||||
|
status: 'PENDING',
|
||||||
|
hubApiKey: null, // Clear old API key
|
||||||
|
registeredAt: null,
|
||||||
|
lastHeartbeat: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate runner token for container authentication
|
||||||
|
const runnerToken = generateRunnerToken()
|
||||||
|
const runnerTokenHash = hashRunnerToken(runnerToken)
|
||||||
|
|
||||||
|
// Decrypt server password
|
||||||
|
const serverPassword = decryptPassword(order.serverPasswordEncrypted)
|
||||||
|
|
||||||
|
// Fetch Docker Hub credentials from settings
|
||||||
|
const dockerHubSettings = await settingsService.getDockerHubCredentials()
|
||||||
|
let dockerHub: DockerHubCredentials | undefined
|
||||||
|
if (dockerHubSettings.username && dockerHubSettings.token) {
|
||||||
|
dockerHub = {
|
||||||
|
username: dockerHubSettings.username,
|
||||||
|
token: dockerHubSettings.token,
|
||||||
|
registry: dockerHubSettings.registry || undefined,
|
||||||
|
}
|
||||||
|
console.log(`[Provision] Using Docker Hub credentials for user: ${dockerHubSettings.username}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate job config
|
||||||
|
const jobConfig = generateJobConfig(order, serverPassword, dockerHub)
|
||||||
|
|
||||||
|
// Create provisioning job record
|
||||||
|
const job = await prisma.provisioningJob.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
jobType: 'PROVISION',
|
||||||
|
status: JobStatus.PENDING,
|
||||||
|
configSnapshot: jobConfig as object,
|
||||||
|
runnerTokenHash,
|
||||||
|
attempt: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order status to PROVISIONING
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: OrderStatus.PROVISIONING,
|
||||||
|
provisioningStartedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the provisioning start
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'INFO',
|
||||||
|
message: `Provisioning initiated. Spawning Docker container for job ${job.id}.`,
|
||||||
|
step: 'init',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine Hub API URL for container callback
|
||||||
|
// In development, Docker containers need to use host.docker.internal instead of localhost
|
||||||
|
// IMPORTANT: Next.js auto-increments port if 3000 is in use, so we detect the actual port
|
||||||
|
let hubApiUrl: string
|
||||||
|
if (process.env.HUB_URL) {
|
||||||
|
// Production: use explicit HUB_URL
|
||||||
|
hubApiUrl = process.env.HUB_URL
|
||||||
|
} else {
|
||||||
|
// Development: detect actual port from request headers
|
||||||
|
const host = request.headers.get('host') || 'localhost:3000'
|
||||||
|
const port = host.split(':')[1] || '3000'
|
||||||
|
hubApiUrl = `http://host.docker.internal:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn provisioning container
|
||||||
|
const spawnResult = await spawnProvisioningContainer(
|
||||||
|
job.id,
|
||||||
|
jobConfig,
|
||||||
|
runnerToken,
|
||||||
|
hubApiUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!spawnResult.success) {
|
||||||
|
// Update job status to failed
|
||||||
|
await prisma.provisioningJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: JobStatus.FAILED,
|
||||||
|
error: spawnResult.error,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order status to failed
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: OrderStatus.FAILED,
|
||||||
|
failureReason: `Failed to spawn provisioning container: ${spawnResult.error}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the failure
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'ERROR',
|
||||||
|
message: `Failed to spawn container: ${spawnResult.error}`,
|
||||||
|
step: 'init',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: spawnResult.error || 'Failed to spawn provisioning container',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job with container info
|
||||||
|
await prisma.provisioningJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: JobStatus.RUNNING,
|
||||||
|
containerName: spawnResult.containerName,
|
||||||
|
claimedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log successful spawn
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'INFO',
|
||||||
|
message: `Container ${spawnResult.containerName} spawned successfully.`,
|
||||||
|
step: 'init',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Provisioning job created',
|
message: 'Provisioning container spawned',
|
||||||
jobId,
|
jobId: job.id,
|
||||||
|
containerName: spawnResult.containerName,
|
||||||
|
serverConnectionId: serverConnection.id,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error triggering provisioning:', error)
|
console.error('Error triggering provisioning:', error)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { OrderStatus } from '@prisma/client'
|
import { OrderStatus } from '@prisma/client'
|
||||||
import crypto from 'crypto'
|
import { processAutomation } from '@/lib/services/automation-worker'
|
||||||
|
import { netcupService } from '@/lib/services/netcup-service'
|
||||||
|
import { runDnsVerification } from '@/lib/services/dns-service'
|
||||||
|
import { credentialService } from '@/lib/services/credential-service'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/admin/orders/[id]
|
* GET /api/v1/admin/orders/[id]
|
||||||
|
|
@ -101,6 +105,12 @@ export async function PATCH(
|
||||||
sshPort?: number
|
sshPort?: number
|
||||||
serverReadyAt?: Date
|
serverReadyAt?: Date
|
||||||
failureReason?: string
|
failureReason?: string
|
||||||
|
tools?: string[]
|
||||||
|
domain?: string
|
||||||
|
customer?: string
|
||||||
|
companyName?: string
|
||||||
|
licenseKey?: string
|
||||||
|
netcupServerId?: string
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
// Handle status update
|
// Handle status update
|
||||||
|
|
@ -111,25 +121,136 @@ export async function PATCH(
|
||||||
// Handle server credentials
|
// Handle server credentials
|
||||||
if (body.serverIp) {
|
if (body.serverIp) {
|
||||||
updateData.serverIp = body.serverIp
|
updateData.serverIp = body.serverIp
|
||||||
|
|
||||||
|
// Try to auto-link to Netcup server by IP
|
||||||
|
if (!existingOrder.netcupServerId) {
|
||||||
|
try {
|
||||||
|
const netcupServer = await netcupService.findServerByIp(body.serverIp)
|
||||||
|
if (netcupServer) {
|
||||||
|
updateData.netcupServerId = netcupServer.id
|
||||||
|
console.log(`Auto-linked order ${orderId} to Netcup server ${netcupServer.id} (${netcupServer.name})`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Not critical - just log and continue
|
||||||
|
console.log('Could not auto-link Netcup server:', error instanceof Error ? error.message : 'Not authenticated')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle explicit netcupServerId (manual linking)
|
||||||
|
if (body.netcupServerId) {
|
||||||
|
updateData.netcupServerId = body.netcupServerId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.serverPassword) {
|
if (body.serverPassword) {
|
||||||
// Encrypt the password before storing
|
// Encrypt the password before storing
|
||||||
// TODO: Use proper encryption with environment-based key
|
updateData.serverPasswordEncrypted = credentialService.encrypt(body.serverPassword)
|
||||||
const encrypted = encryptPassword(body.serverPassword)
|
|
||||||
updateData.serverPasswordEncrypted = encrypted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.sshPort) {
|
if (body.sshPort) {
|
||||||
updateData.sshPort = body.sshPort
|
updateData.sshPort = body.sshPort
|
||||||
}
|
}
|
||||||
|
|
||||||
// If server credentials are being set and status is AWAITING_SERVER, move to SERVER_READY
|
// Handle tools update (only before provisioning starts)
|
||||||
|
if (body.tools && Array.isArray(body.tools)) {
|
||||||
|
// Only allow tools update before provisioning
|
||||||
|
const provisioningStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.PROVISIONING,
|
||||||
|
OrderStatus.FULFILLED,
|
||||||
|
OrderStatus.EMAIL_CONFIGURED,
|
||||||
|
]
|
||||||
|
if (provisioningStatuses.includes(existingOrder.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot modify tools after provisioning has started' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateData.tools = body.tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle domain update (only before provisioning starts)
|
||||||
|
if (body.domain) {
|
||||||
|
const provisioningStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.PROVISIONING,
|
||||||
|
OrderStatus.FULFILLED,
|
||||||
|
OrderStatus.EMAIL_CONFIGURED,
|
||||||
|
]
|
||||||
|
if (provisioningStatuses.includes(existingOrder.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot modify domain after provisioning has started' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateData.domain = body.domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle provisioning config fields (only before provisioning starts)
|
||||||
|
if (body.customer !== undefined || body.companyName !== undefined || body.licenseKey !== undefined) {
|
||||||
|
const provisioningStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.PROVISIONING,
|
||||||
|
OrderStatus.FULFILLED,
|
||||||
|
OrderStatus.EMAIL_CONFIGURED,
|
||||||
|
]
|
||||||
|
if (provisioningStatuses.includes(existingOrder.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot modify provisioning config after provisioning has started' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (body.customer) {
|
||||||
|
updateData.customer = body.customer
|
||||||
|
}
|
||||||
|
if (body.companyName) {
|
||||||
|
updateData.companyName = body.companyName
|
||||||
|
}
|
||||||
|
if (body.licenseKey) {
|
||||||
|
updateData.licenseKey = body.licenseKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track if we should trigger DNS verification after update
|
||||||
|
let shouldTriggerDnsVerification = false
|
||||||
|
|
||||||
|
// Auto-transition when credentials are saved:
|
||||||
|
// AWAITING_SERVER → DNS_PENDING (skip SERVER_READY, go straight to DNS verification)
|
||||||
|
// This happens regardless of automation mode
|
||||||
if (
|
if (
|
||||||
(body.serverIp || body.serverPassword) &&
|
(body.serverIp || body.serverPassword) &&
|
||||||
existingOrder.status === OrderStatus.AWAITING_SERVER
|
existingOrder.status === OrderStatus.AWAITING_SERVER &&
|
||||||
|
!body.status // Only auto-transition if no explicit status is provided
|
||||||
|
) {
|
||||||
|
updateData.status = OrderStatus.DNS_PENDING
|
||||||
|
updateData.serverReadyAt = new Date()
|
||||||
|
shouldTriggerDnsVerification = true
|
||||||
|
|
||||||
|
// Auto-populate provisioning config if not already set
|
||||||
|
// This ensures provisioning can proceed without manual config step
|
||||||
|
if (!existingOrder.customer && !updateData.customer) {
|
||||||
|
// Generate customer identifier from domain (e.g., "example.com" → "example")
|
||||||
|
const domain = existingOrder.domain || ''
|
||||||
|
updateData.customer = domain.split('.')[0].toLowerCase().replace(/[^a-z0-9]/g, '') || 'customer'
|
||||||
|
}
|
||||||
|
if (!existingOrder.companyName && !updateData.companyName) {
|
||||||
|
// Use user's company or name, or derive from domain
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: existingOrder.userId } })
|
||||||
|
updateData.companyName = user?.company || user?.name || existingOrder.domain || 'Company'
|
||||||
|
}
|
||||||
|
if (!existingOrder.licenseKey && !updateData.licenseKey) {
|
||||||
|
// Generate a unique license key
|
||||||
|
updateData.licenseKey = `LB-${crypto.randomBytes(8).toString('hex').toUpperCase()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also trigger DNS verification when explicitly setting status to DNS_PENDING
|
||||||
|
if (body.status === OrderStatus.DNS_PENDING) {
|
||||||
|
shouldTriggerDnsVerification = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set serverReadyAt when explicitly setting status to SERVER_READY or DNS_PENDING
|
||||||
|
if (
|
||||||
|
(body.status === OrderStatus.SERVER_READY || body.status === OrderStatus.DNS_PENDING) &&
|
||||||
|
!existingOrder.serverReadyAt
|
||||||
) {
|
) {
|
||||||
updateData.status = OrderStatus.SERVER_READY
|
|
||||||
updateData.serverReadyAt = new Date()
|
updateData.serverReadyAt = new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,11 +264,43 @@ export async function PATCH(
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
company: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Trigger DNS verification if credentials were saved or status changed to DNS_PENDING
|
||||||
|
// This runs regardless of automation mode - always verify DNS when credentials are ready
|
||||||
|
if (shouldTriggerDnsVerification && order.serverIp) {
|
||||||
|
try {
|
||||||
|
console.log(`Triggering DNS verification for order ${orderId}`)
|
||||||
|
const dnsResult = await runDnsVerification(orderId)
|
||||||
|
console.log(
|
||||||
|
`DNS verification for order ${orderId}: ${dnsResult.allPassed ? 'PASSED' : 'PENDING'} ` +
|
||||||
|
`(${dnsResult.passedCount}/${dnsResult.totalSubdomains} subdomains)`
|
||||||
|
)
|
||||||
|
// Note: runDnsVerification automatically transitions to DNS_READY if all checks pass
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail the request - DNS verification can be retried
|
||||||
|
console.error('DNS verification failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger automation processing if credentials were saved or status changed
|
||||||
|
// This will check if the order is in AUTO mode and process the next step
|
||||||
|
if (body.serverIp || body.serverPassword || body.status) {
|
||||||
|
try {
|
||||||
|
const result = await processAutomation(orderId)
|
||||||
|
if (result.triggered) {
|
||||||
|
console.log(`Automation triggered for order ${orderId}: ${result.action}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail the request - automation is async
|
||||||
|
console.error('Automation processing failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(order)
|
return NextResponse.json(order)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating order:', error)
|
console.error('Error updating order:', error)
|
||||||
|
|
@ -158,18 +311,72 @@ export async function PATCH(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to encrypt password
|
/**
|
||||||
function encryptPassword(password: string): string {
|
* DELETE /api/v1/admin/orders/[id]
|
||||||
// TODO: Implement proper encryption using environment-based key
|
* Delete an order and all related records (logs, jobs, DNS verification)
|
||||||
// For now, use a simple encryption for development
|
* Does NOT touch the actual server - just removes from Hub database
|
||||||
const key = crypto.scryptSync(
|
*/
|
||||||
process.env.ENCRYPTION_KEY || 'dev-key-change-in-production',
|
export async function DELETE(
|
||||||
'salt',
|
request: NextRequest,
|
||||||
32
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
)
|
) {
|
||||||
const iv = crypto.randomBytes(16)
|
try {
|
||||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
|
const session = await auth()
|
||||||
let encrypted = cipher.update(password, 'utf8', 'hex')
|
|
||||||
encrypted += cipher.final('hex')
|
if (!session || session.user.userType !== 'staff') {
|
||||||
return iv.toString('hex') + ':' + encrypted
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: orderId } = await params
|
||||||
|
|
||||||
|
// Find existing order
|
||||||
|
const existingOrder = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
dnsVerification: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingOrder) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete in correct order to respect foreign key constraints
|
||||||
|
// 1. Delete DNS records (if DNS verification exists)
|
||||||
|
if (existingOrder.dnsVerification) {
|
||||||
|
await prisma.dnsRecord.deleteMany({
|
||||||
|
where: { dnsVerificationId: existingOrder.dnsVerification.id },
|
||||||
|
})
|
||||||
|
await prisma.dnsVerification.delete({
|
||||||
|
where: { id: existingOrder.dnsVerification.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete provisioning logs
|
||||||
|
await prisma.provisioningLog.deleteMany({
|
||||||
|
where: { orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Delete jobs
|
||||||
|
await prisma.provisioningJob.deleteMany({
|
||||||
|
where: { orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Delete the order itself
|
||||||
|
await prisma.order.delete({
|
||||||
|
where: { id: orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Order ${orderId} and all related records deleted`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting order:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete order' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { testServerConnection } from '@/lib/ansible'
|
||||||
|
import { SSH_PORT_BEFORE_PROVISION } from '@/lib/ssh/constants'
|
||||||
|
import { decryptPassword } from '@/lib/services/config-generator'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/test-ssh
|
||||||
|
* Test SSH connection to the server
|
||||||
|
*
|
||||||
|
* Accepts optional body with credentials to test before saving:
|
||||||
|
* { serverIp?: string, password?: string, sshPort?: number }
|
||||||
|
*
|
||||||
|
* If body credentials provided, uses those (for testing before save)
|
||||||
|
* Otherwise uses saved order credentials
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: orderId } = await context.params
|
||||||
|
|
||||||
|
// Parse optional body for testing unsaved credentials
|
||||||
|
let body: { serverIp?: string; password?: string; sshPort?: number } = {}
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
// No body provided, will use saved credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which credentials to use
|
||||||
|
// Priority: body params > saved order data
|
||||||
|
const serverIp = body.serverIp || order.serverIp
|
||||||
|
const sshPort = body.sshPort || order.sshPort || SSH_PORT_BEFORE_PROVISION
|
||||||
|
|
||||||
|
// For password: use body.password directly, or decrypt saved password
|
||||||
|
let password: string | null = null
|
||||||
|
if (body.password) {
|
||||||
|
password = body.password
|
||||||
|
} else if (order.serverPasswordEncrypted) {
|
||||||
|
password = decryptPassword(order.serverPasswordEncrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate we have required credentials
|
||||||
|
if (!serverIp) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server IP not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server password not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
const result = await testServerConnection(
|
||||||
|
serverIp,
|
||||||
|
password,
|
||||||
|
sshPort
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Log successful test
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'INFO',
|
||||||
|
message: `SSH connection test successful to ${serverIp}:${sshPort} (latency: ${result.latency}ms)`,
|
||||||
|
step: 'ssh-test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
latency: result.latency,
|
||||||
|
message: 'SSH connection successful',
|
||||||
|
serverIp,
|
||||||
|
sshPort,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Log failed test
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
level: 'ERROR',
|
||||||
|
message: `SSH connection test failed to ${serverIp}:${sshPort}: ${result.error}`,
|
||||||
|
step: 'ssh-test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
message: 'SSH connection failed',
|
||||||
|
serverIp,
|
||||||
|
sshPort,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH test error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to test SSH connection' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { OrderStatus, SubscriptionTier, Prisma } from '@prisma/client'
|
import { OrderStatus, SubscriptionTier, AutomationMode, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a customer ID slug from company/user name
|
||||||
|
* Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$)
|
||||||
|
*/
|
||||||
|
function slugifyCustomer(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z]/g, '')
|
||||||
|
.substring(0, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique license key for the order
|
||||||
|
*/
|
||||||
|
function generateLicenseKey(): string {
|
||||||
|
const hex = randomBytes(16).toString('hex')
|
||||||
|
return `lb_inst_${hex}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/admin/orders
|
* GET /api/v1/admin/orders
|
||||||
|
|
@ -112,7 +132,13 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create order
|
// Auto-generate provisioning config from user's company/name
|
||||||
|
const displayName = user.company || user.name || 'customer'
|
||||||
|
const customer = slugifyCustomer(displayName) || 'customer'
|
||||||
|
const companyName = displayName
|
||||||
|
const licenseKey = generateLicenseKey()
|
||||||
|
|
||||||
|
// Create order with MANUAL automation mode (staff-created)
|
||||||
const order = await prisma.order.create({
|
const order = await prisma.order.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -121,6 +147,12 @@ export async function POST(request: NextRequest) {
|
||||||
tools,
|
tools,
|
||||||
status: OrderStatus.PAYMENT_CONFIRMED,
|
status: OrderStatus.PAYMENT_CONFIRMED,
|
||||||
configJson: { tools, tier, domain },
|
configJson: { tools, tier, domain },
|
||||||
|
automationMode: AutomationMode.MANUAL,
|
||||||
|
source: 'staff',
|
||||||
|
// Auto-generated provisioning config
|
||||||
|
customer,
|
||||||
|
companyName,
|
||||||
|
licenseKey,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import https from 'https'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/portainer/ping
|
||||||
|
* Check if Portainer is reachable at a given IP
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - ip: Server IP address
|
||||||
|
* - port: Portainer port (default: 9443)
|
||||||
|
*/
|
||||||
|
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 ip = searchParams.get('ip')
|
||||||
|
const port = searchParams.get('port') || '9443'
|
||||||
|
|
||||||
|
if (!ip) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'IP address required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IP format (basic check)
|
||||||
|
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||||
|
if (!ipRegex.test(ip)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid IP address format' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use native https module to handle self-signed certificates
|
||||||
|
const result = await new Promise<{ available: boolean; version?: string; instanceId?: string | null; needsSetup?: boolean; error?: string }>((resolve) => {
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: ip,
|
||||||
|
port: parseInt(port),
|
||||||
|
path: '/api/status',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 5000,
|
||||||
|
rejectUnauthorized: false, // Accept self-signed certificates
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data)
|
||||||
|
// InstanceID is only set after Portainer has been initialized
|
||||||
|
// (admin account created). If it's empty, Portainer is running
|
||||||
|
// but not configured yet.
|
||||||
|
const isInitialized = !!json.InstanceID
|
||||||
|
resolve({
|
||||||
|
available: isInitialized,
|
||||||
|
version: json.Version || 'unknown',
|
||||||
|
instanceId: json.InstanceID || null,
|
||||||
|
needsSetup: !isInitialized,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
resolve({
|
||||||
|
available: false,
|
||||||
|
version: 'unknown',
|
||||||
|
error: 'Failed to parse response',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
available: false,
|
||||||
|
error: `Portainer returned status ${res.statusCode}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
resolve({
|
||||||
|
available: false,
|
||||||
|
error: err.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy()
|
||||||
|
resolve({
|
||||||
|
available: false,
|
||||||
|
error: 'Connection timed out',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking Portainer status:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to check Portainer status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue