feat: Complete rewrite as Next.js admin dashboard
Major transformation from FastAPI telemetry service to Next.js admin dashboard: - Next.js 15 App Router with TypeScript - Prisma ORM with PostgreSQL (same schema, new client) - TanStack Query for data fetching - Tailwind CSS + shadcn/ui components - Admin dashboard with: - Dashboard stats overview - Customer management (list, detail, edit) - Order management (list, create, detail, logs) - Server monitoring (grid view) - Subscription management Pages implemented: - /admin (dashboard) - /admin/customers (list + [id] detail) - /admin/orders (list + [id] detail with SSE logs) - /admin/servers (grid view) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
02fc18f009
commit
a79b79efd2
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://letsbe:letsbe@localhost:5432/letsbe_hub"
|
||||||
|
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="your-secret-key-here-change-in-production"
|
||||||
|
|
||||||
|
# Stripe (Phase 5)
|
||||||
|
# STRIPE_SECRET_KEY="sk_test_..."
|
||||||
|
# STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||||
|
|
||||||
|
# Entri DNS API (Phase 3)
|
||||||
|
# ENTRI_APP_ID="..."
|
||||||
|
# ENTRI_SECRET="..."
|
||||||
|
|
||||||
|
# Runner Authentication
|
||||||
|
RUNNER_TOKEN="change-me-in-production"
|
||||||
|
|
||||||
|
# Admin Setup
|
||||||
|
ADMIN_EMAIL="admin@letsbe.solutions"
|
||||||
|
ADMIN_PASSWORD="change-me-in-production"
|
||||||
|
|
||||||
|
# Hub Internal URL (for runners)
|
||||||
|
HUB_INTERNAL_URL="http://localhost:3000"
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
# Byte-compiled / optimized / DLL files
|
# Dependencies
|
||||||
__pycache__/
|
node_modules/
|
||||||
*.py[cod]
|
.pnp/
|
||||||
*$py.class
|
.pnp.js
|
||||||
|
|
||||||
# Virtual environments
|
# Build outputs
|
||||||
.venv/
|
.next/
|
||||||
venv/
|
out/
|
||||||
ENV/
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
@ -18,19 +27,26 @@ ENV/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# Testing
|
# Debug
|
||||||
.pytest_cache/
|
npm-debug.log*
|
||||||
.coverage
|
yarn-debug.log*
|
||||||
htmlcov/
|
yarn-error.log*
|
||||||
|
|
||||||
# Build
|
# Typescript
|
||||||
dist/
|
*.tsbuildinfo
|
||||||
build/
|
next-env.d.ts
|
||||||
*.egg-info/
|
|
||||||
|
|
||||||
# Database
|
# Misc
|
||||||
*.db
|
.DS_Store
|
||||||
*.sqlite3
|
*.pem
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Serena
|
||||||
*.log
|
.serena/
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
|
|
|
||||||
203
CLAUDE.md
203
CLAUDE.md
|
|
@ -2,114 +2,149 @@
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
You are the engineering assistant for the LetsBe Hub.
|
You are the engineering assistant for the LetsBe Hub Dashboard.
|
||||||
This is the central licensing and telemetry service for the LetsBe Cloud platform.
|
This is the admin dashboard and API for managing the LetsBe Cloud platform.
|
||||||
|
|
||||||
The Hub provides:
|
The Hub provides:
|
||||||
|
|
||||||
- **License Management**: Issue and validate per-instance license keys
|
- **Admin Dashboard**: Next.js admin UI for platform management
|
||||||
- **Instance Activation**: Verify licenses during client installation
|
- **Customer Management**: Create/manage customers and subscriptions
|
||||||
- **Telemetry Collection**: Receive anonymized usage data from instances
|
- **Order Management**: Process and track provisioning orders
|
||||||
- **Client Management**: Track organizations and their deployments
|
- **Server Monitoring**: View and manage tenant servers
|
||||||
|
- **Token Usage Tracking**: Monitor AI token consumption
|
||||||
## Privacy Guarantee
|
|
||||||
|
|
||||||
**CRITICAL**: The Hub NEVER stores sensitive client data.
|
|
||||||
|
|
||||||
Allowed data:
|
|
||||||
- Instance identifiers
|
|
||||||
- Tool names
|
|
||||||
- Duration metrics
|
|
||||||
- Aggregated counts
|
|
||||||
- Error codes (not messages)
|
|
||||||
|
|
||||||
NEVER stored:
|
|
||||||
- Environment variable values
|
|
||||||
- File contents
|
|
||||||
- Request/response payloads
|
|
||||||
- Screenshots
|
|
||||||
- Credentials
|
|
||||||
- Stack traces or error messages
|
|
||||||
|
|
||||||
The `app/services/redactor.py` enforces this with an ALLOW-LIST approach.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Python 3.11
|
- **Next.js 15** (App Router)
|
||||||
- FastAPI
|
- **TypeScript** (strict mode)
|
||||||
- SQLAlchemy 2.0 (async)
|
- **Prisma** (PostgreSQL ORM)
|
||||||
- PostgreSQL
|
- **TanStack Query** (React Query v5)
|
||||||
- Alembic migrations
|
- **Tailwind CSS** + shadcn/ui components
|
||||||
- Pydantic v2
|
- **NextAuth.js** (authentication)
|
||||||
|
|
||||||
## API Endpoints
|
## Project Structure
|
||||||
|
|
||||||
### Public Endpoints
|
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/v1/instances/activate
|
src/
|
||||||
- Validates license key
|
├── app/ # Next.js App Router
|
||||||
- Returns hub_api_key for telemetry
|
│ ├── admin/ # Admin dashboard pages
|
||||||
- Called by client bootstrap scripts
|
│ │ ├── customers/ # Customer management
|
||||||
|
│ │ ├── orders/ # Order management
|
||||||
|
│ │ ├── servers/ # Server monitoring
|
||||||
|
│ │ └── layout.tsx # Admin layout with sidebar
|
||||||
|
│ ├── api/v1/ # API routes
|
||||||
|
│ │ ├── admin/ # Admin API endpoints
|
||||||
|
│ │ └── public/ # Public API endpoints
|
||||||
|
│ └── (auth)/ # Authentication pages
|
||||||
|
├── components/
|
||||||
|
│ ├── admin/ # Admin-specific components
|
||||||
|
│ └── ui/ # Reusable UI components
|
||||||
|
├── hooks/ # React Query hooks
|
||||||
|
├── lib/ # Utilities and shared code
|
||||||
|
│ └── prisma.ts # Prisma client singleton
|
||||||
|
└── types/ # TypeScript type definitions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin Endpoints (require X-Admin-Api-Key header)
|
## API Routes
|
||||||
|
|
||||||
|
### Admin Endpoints (authenticated)
|
||||||
|
|
||||||
```
|
```
|
||||||
# Clients
|
# Customers
|
||||||
POST /api/v1/admin/clients
|
GET /api/v1/admin/customers # List customers
|
||||||
GET /api/v1/admin/clients
|
GET /api/v1/admin/customers/[id] # Get customer detail
|
||||||
GET /api/v1/admin/clients/{id}
|
PATCH /api/v1/admin/customers/[id] # Update customer
|
||||||
PATCH /api/v1/admin/clients/{id}
|
|
||||||
DELETE /api/v1/admin/clients/{id}
|
|
||||||
|
|
||||||
# Instances
|
# Orders
|
||||||
POST /api/v1/admin/clients/{id}/instances
|
GET /api/v1/admin/orders # List orders
|
||||||
GET /api/v1/admin/clients/{id}/instances
|
POST /api/v1/admin/orders # Create order
|
||||||
GET /api/v1/admin/instances/{instance_id}
|
GET /api/v1/admin/orders/[id] # Get order detail
|
||||||
POST /api/v1/admin/instances/{instance_id}/rotate-license
|
PATCH /api/v1/admin/orders/[id] # Update order
|
||||||
POST /api/v1/admin/instances/{instance_id}/rotate-hub-key
|
GET /api/v1/admin/orders/[id]/logs # Get provisioning logs (SSE)
|
||||||
POST /api/v1/admin/instances/{instance_id}/suspend
|
|
||||||
POST /api/v1/admin/instances/{instance_id}/reactivate
|
# Servers
|
||||||
DELETE /api/v1/admin/instances/{instance_id}
|
GET /api/v1/admin/servers # List servers (derived from orders)
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
GET /api/v1/admin/dashboard/stats # Dashboard statistics
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Types
|
|
||||||
|
|
||||||
### License Key
|
|
||||||
Format: `lb_inst_<32_hex_chars>`
|
|
||||||
Example: `lb_inst_a1b2c3d4e5f6789012345678901234567890abcd`
|
|
||||||
|
|
||||||
Stored as SHA-256 hash. Only visible once at creation.
|
|
||||||
|
|
||||||
### Hub API Key
|
|
||||||
Format: `hk_<24_hex_chars>`
|
|
||||||
Example: `hk_abc123def456789012345678901234567890abcd`
|
|
||||||
|
|
||||||
Used for telemetry authentication. Stored as SHA-256 hash.
|
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start services
|
# Install dependencies
|
||||||
docker compose up --build
|
npm install
|
||||||
|
|
||||||
# Run migrations
|
# Start development server
|
||||||
docker compose exec api alembic upgrade head
|
npm run dev
|
||||||
|
|
||||||
# Create new migration
|
# Run database migrations
|
||||||
docker compose exec api alembic revision --autogenerate -m "description"
|
npx prisma migrate dev
|
||||||
|
|
||||||
# Run tests
|
# Generate Prisma client
|
||||||
docker compose exec api pytest -v
|
npx prisma generate
|
||||||
|
|
||||||
# API available at http://localhost:8200
|
# Seed database
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# App available at http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### React Query Hooks
|
||||||
|
|
||||||
|
All data fetching uses React Query hooks in `src/hooks/`:
|
||||||
|
- `useCustomers()`, `useCustomer(id)` - Customer data
|
||||||
|
- `useOrders()`, `useOrder(id)` - Order data
|
||||||
|
- `useServers()` - Server list
|
||||||
|
- `useDashboardStats()` - Dashboard metrics
|
||||||
|
|
||||||
|
Mutations follow the pattern:
|
||||||
|
- `useCreateOrder()`, `useUpdateOrder()`
|
||||||
|
- Automatic cache invalidation via `queryClient.invalidateQueries()`
|
||||||
|
|
||||||
|
### API Route Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Parse query params
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
|
||||||
|
// Database query with Prisma
|
||||||
|
const data = await prisma.model.findMany({...})
|
||||||
|
|
||||||
|
// Return JSON response
|
||||||
|
return NextResponse.json(data)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export function MyComponent() {
|
||||||
|
const { data, isLoading, error } = useMyData()
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
if (error) return <ErrorMessage error={error} />
|
||||||
|
|
||||||
|
return <div>...</div>
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Conventions
|
## Coding Conventions
|
||||||
|
|
||||||
- Everything async
|
- Use `'use client'` directive for client components
|
||||||
- Use the redactor for ALL telemetry data
|
- All API routes return `NextResponse.json()`
|
||||||
- Never log sensitive data
|
- Use Prisma for all database operations
|
||||||
- All exceptions should be caught and return proper HTTP errors
|
- Follow existing shadcn/ui component patterns
|
||||||
- Use constant-time comparison for secrets (secrets.compare_digest)
|
- Use React Query for server state management
|
||||||
|
- TypeScript strict mode - no `any` types
|
||||||
|
|
|
||||||
65
Dockerfile
65
Dockerfile
|
|
@ -1,24 +1,59 @@
|
||||||
FROM python:3.11-slim
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
COPY package.json package-lock.json* ./
|
||||||
gcc \
|
RUN npm ci
|
||||||
libpq-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Generate Prisma Client
|
||||||
COPY requirements.txt .
|
COPY prisma ./prisma/
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Copy application code
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create non-root user
|
# Next.js telemetry
|
||||||
RUN useradd -m -u 1000 hub && chown -R hub:hub /app
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
USER hub
|
|
||||||
|
|
||||||
EXPOSE 8000
|
RUN npm run build
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Copy Prisma
|
||||||
|
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""LetsBe Hub - Central licensing and telemetry service."""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
"""Hub configuration via environment variables."""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Hub settings loaded from environment variables."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
frozen=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_NAME: str = Field(default="LetsBe Hub", description="Application name")
|
|
||||||
APP_VERSION: str = Field(default="0.1.0", description="Application version")
|
|
||||||
DEBUG: bool = Field(default=False, description="Debug mode")
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL: str = Field(
|
|
||||||
default="postgresql+asyncpg://hub:hub@db:5432/hub",
|
|
||||||
description="PostgreSQL connection URL"
|
|
||||||
)
|
|
||||||
DB_POOL_SIZE: int = Field(default=5, ge=1, le=20, description="Connection pool size")
|
|
||||||
DB_MAX_OVERFLOW: int = Field(default=10, ge=0, le=50, description="Max overflow connections")
|
|
||||||
DB_POOL_TIMEOUT: int = Field(default=30, ge=5, le=120, description="Pool timeout in seconds")
|
|
||||||
DB_POOL_RECYCLE: int = Field(default=1800, ge=300, le=7200, description="Connection recycle time")
|
|
||||||
|
|
||||||
# Admin authentication
|
|
||||||
ADMIN_API_KEY: str = Field(
|
|
||||||
default="change-me-in-production",
|
|
||||||
min_length=16,
|
|
||||||
description="Admin API key for management endpoints"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Telemetry settings
|
|
||||||
TELEMETRY_RETENTION_DAYS: int = Field(
|
|
||||||
default=90,
|
|
||||||
ge=7,
|
|
||||||
le=365,
|
|
||||||
description="Days to retain telemetry data"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Rate limiting for activation endpoint
|
|
||||||
ACTIVATION_RATE_LIMIT_PER_MINUTE: int = Field(
|
|
||||||
default=10,
|
|
||||||
ge=1,
|
|
||||||
le=100,
|
|
||||||
description="Max activation attempts per instance per minute"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Get cached settings instance."""
|
|
||||||
return Settings()
|
|
||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
52
app/db.py
52
app/db.py
|
|
@ -1,52 +0,0 @@
|
||||||
"""Database configuration and session management."""
|
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import (
|
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# Create async engine with connection pooling
|
|
||||||
engine = create_async_engine(
|
|
||||||
settings.DATABASE_URL,
|
|
||||||
pool_size=settings.DB_POOL_SIZE,
|
|
||||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
|
||||||
pool_timeout=settings.DB_POOL_TIMEOUT,
|
|
||||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
|
||||||
echo=settings.DEBUG,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create async session factory
|
|
||||||
async_session_maker = async_sessionmaker(
|
|
||||||
engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
autocommit=False,
|
|
||||||
autoflush=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""
|
|
||||||
Dependency that provides an async database session.
|
|
||||||
|
|
||||||
Yields a session and ensures proper cleanup via finally block.
|
|
||||||
"""
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Type alias for dependency injection
|
|
||||||
AsyncSessionDep = Annotated[AsyncSession, Depends(get_db)]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""Hub dependencies."""
|
|
||||||
|
|
||||||
from app.dependencies.admin_auth import validate_admin_key
|
|
||||||
|
|
||||||
__all__ = ["validate_admin_key"]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
"""Admin authentication dependency."""
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def validate_admin_key(
|
|
||||||
x_admin_api_key: Annotated[str, Header(description="Admin API key")],
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Validate the admin API key.
|
|
||||||
|
|
||||||
Uses constant-time comparison to prevent timing attacks.
|
|
||||||
"""
|
|
||||||
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid admin API key",
|
|
||||||
)
|
|
||||||
return x_admin_api_key
|
|
||||||
|
|
||||||
|
|
||||||
# Type alias for dependency injection
|
|
||||||
AdminKeyDep = Annotated[str, validate_admin_key]
|
|
||||||
51
app/main.py
51
app/main.py
|
|
@ -1,51 +0,0 @@
|
||||||
"""LetsBe Hub - Central licensing and telemetry service."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from app import __version__
|
|
||||||
from app.config import settings
|
|
||||||
from app.db import engine
|
|
||||||
from app.routes import activation_router, admin_router, health_router, telemetry_router
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG if settings.DEBUG else logging.INFO,
|
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
"""Application lifespan handler."""
|
|
||||||
logger.info(f"Starting LetsBe Hub v{__version__}")
|
|
||||||
yield
|
|
||||||
logger.info("Shutting down LetsBe Hub")
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="LetsBe Hub",
|
|
||||||
description="Central licensing and telemetry service for LetsBe Cloud",
|
|
||||||
version=__version__,
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS middleware
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"], # Configure appropriately for production
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Include routers
|
|
||||||
app.include_router(health_router)
|
|
||||||
app.include_router(admin_router)
|
|
||||||
app.include_router(activation_router)
|
|
||||||
app.include_router(telemetry_router)
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"""Hub database models."""
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
|
||||||
from app.models.client import Client
|
|
||||||
from app.models.instance import Instance
|
|
||||||
from app.models.usage_sample import UsageSample
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Base",
|
|
||||||
"UUIDMixin",
|
|
||||||
"TimestampMixin",
|
|
||||||
"utc_now",
|
|
||||||
"Client",
|
|
||||||
"Instance",
|
|
||||||
"UsageSample",
|
|
||||||
]
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""Base model and mixins for SQLAlchemy ORM."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
|
||||||
"""Return current UTC datetime."""
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
class Base(AsyncAttrs, DeclarativeBase):
|
|
||||||
"""Base class for all SQLAlchemy models."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UUIDMixin:
|
|
||||||
"""Mixin that adds a UUID primary key."""
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
primary_key=True,
|
|
||||||
default=uuid.uuid4,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin:
|
|
||||||
"""Mixin that adds created_at and updated_at timestamps."""
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
default=utc_now,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
default=utc_now,
|
|
||||||
onupdate=utc_now,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""Client model - represents a company/organization using LetsBe."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.instance import Instance
|
|
||||||
|
|
||||||
|
|
||||||
class Client(UUIDMixin, TimestampMixin, Base):
|
|
||||||
"""
|
|
||||||
A client is a company or organization using LetsBe.
|
|
||||||
|
|
||||||
Clients can have multiple instances (orchestrator deployments).
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "clients"
|
|
||||||
|
|
||||||
# Client identification
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
||||||
|
|
||||||
# Billing/plan info (for future use)
|
|
||||||
billing_plan: Mapped[str] = mapped_column(String(50), default="free")
|
|
||||||
|
|
||||||
# Status
|
|
||||||
status: Mapped[str] = mapped_column(String(50), default="active")
|
|
||||||
# "active", "suspended", "archived"
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
instances: Mapped[list["Instance"]] = relationship(
|
|
||||||
back_populates="client",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
"""Instance model - represents a deployed orchestrator with licensing."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.client import Client
|
|
||||||
|
|
||||||
|
|
||||||
class Instance(UUIDMixin, TimestampMixin, Base):
|
|
||||||
"""
|
|
||||||
A deployed orchestrator instance with licensing.
|
|
||||||
|
|
||||||
Each instance is tied to a client and requires a valid license to operate.
|
|
||||||
The Hub issues license keys and tracks activation status.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "instances"
|
|
||||||
|
|
||||||
# Client relationship
|
|
||||||
client_id: Mapped[UUID] = mapped_column(
|
|
||||||
ForeignKey("clients.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Instance identification
|
|
||||||
instance_id: Mapped[str] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
unique=True,
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
# e.g., "acme-orchestrator"
|
|
||||||
|
|
||||||
# === LICENSING ===
|
|
||||||
license_key_hash: Mapped[str] = mapped_column(
|
|
||||||
String(64),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
# SHA-256 hash of the license key (lb_inst_...)
|
|
||||||
|
|
||||||
license_key_prefix: Mapped[str] = mapped_column(
|
|
||||||
String(12),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
# First 12 chars for display: "lb_inst_abc1"
|
|
||||||
|
|
||||||
license_status: Mapped[str] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
default="active",
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
# "active", "suspended", "expired", "revoked"
|
|
||||||
|
|
||||||
license_issued_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
license_expires_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# None = no expiry (perpetual)
|
|
||||||
|
|
||||||
# === ACTIVATION STATE ===
|
|
||||||
activated_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# Set when instance first calls /activate
|
|
||||||
|
|
||||||
last_activation_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# Updated on each activation call
|
|
||||||
|
|
||||||
activation_count: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
default=0,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# === TELEMETRY ===
|
|
||||||
hub_api_key_hash: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(64),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# Generated on activation, used for telemetry auth
|
|
||||||
|
|
||||||
# === METADATA ===
|
|
||||||
region: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# e.g., "eu-west-1"
|
|
||||||
|
|
||||||
version: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# Last reported orchestrator version
|
|
||||||
|
|
||||||
last_seen_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# Last telemetry or heartbeat
|
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
default="pending",
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
# "pending" (created, not yet activated), "active", "inactive", "suspended"
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
client: Mapped["Client"] = relationship(back_populates="instances")
|
|
||||||
|
|
||||||
def is_license_valid(self) -> bool:
|
|
||||||
"""Check if the license is currently valid."""
|
|
||||||
from app.models.base import utc_now
|
|
||||||
|
|
||||||
if self.license_status not in ("active",):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.license_expires_at and self.license_expires_at < utc_now():
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"""Telemetry sample model - stores aggregated metrics from orchestrators.
|
|
||||||
|
|
||||||
PRIVACY GUARANTEE: This model contains NO sensitive data fields.
|
|
||||||
Only aggregated counts, tool names, durations, and status metrics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, UUIDMixin
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetrySample(UUIDMixin, Base):
|
|
||||||
"""
|
|
||||||
Aggregated telemetry from an orchestrator instance.
|
|
||||||
|
|
||||||
PRIVACY: This model deliberately stores ONLY:
|
|
||||||
- Instance reference
|
|
||||||
- Time window boundaries
|
|
||||||
- Uptime counter
|
|
||||||
- Aggregated metrics (counts, durations, statuses)
|
|
||||||
|
|
||||||
It NEVER stores:
|
|
||||||
- Task payloads or results
|
|
||||||
- Environment variable values
|
|
||||||
- File contents
|
|
||||||
- Error messages or stack traces
|
|
||||||
- Any PII
|
|
||||||
|
|
||||||
De-duplication: The unique constraint on (instance_id, window_start)
|
|
||||||
prevents double-counting if the orchestrator retries submissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "telemetry_samples"
|
|
||||||
|
|
||||||
# Instance reference (FK to instances.id, not instance_id string)
|
|
||||||
instance_id: Mapped[UUID] = mapped_column(
|
|
||||||
ForeignKey("instances.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Time window for this sample
|
|
||||||
window_start: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
window_end: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Orchestrator uptime at time of submission
|
|
||||||
uptime_seconds: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Aggregated metrics (stored as JSON for flexibility)
|
|
||||||
# Uses generic JSON type for SQLite test compatibility
|
|
||||||
# PostgreSQL will use native JSON support in production
|
|
||||||
# Structure matches TelemetryMetrics schema:
|
|
||||||
# {
|
|
||||||
# "agents": {"online_count": 1, "offline_count": 0, "total_count": 1},
|
|
||||||
# "tasks": {
|
|
||||||
# "by_status": {"completed": 10, "failed": 1},
|
|
||||||
# "by_type": {"SHELL": {"count": 5, "avg_duration_ms": 1200}}
|
|
||||||
# },
|
|
||||||
# "servers": {"total_count": 1}
|
|
||||||
# }
|
|
||||||
metrics: Mapped[dict] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Unique constraint for de-duplication
|
|
||||||
# If orchestrator retries a failed submission, this prevents duplicates
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"instance_id",
|
|
||||||
"window_start",
|
|
||||||
name="uq_telemetry_instance_window",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"<TelemetrySample(instance_id={self.instance_id}, "
|
|
||||||
f"window_start={self.window_start})>"
|
|
||||||
)
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
"""Usage sample model - aggregated telemetry data.
|
|
||||||
|
|
||||||
PRIVACY GUARANTEE: This model contains NO sensitive data fields.
|
|
||||||
Only tool names, durations, and counts are stored.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.models.base import Base, UUIDMixin
|
|
||||||
|
|
||||||
|
|
||||||
class UsageSample(UUIDMixin, Base):
|
|
||||||
"""
|
|
||||||
Aggregated usage statistics for an instance.
|
|
||||||
|
|
||||||
PRIVACY: This model deliberately has NO fields for:
|
|
||||||
- Environment values
|
|
||||||
- File contents
|
|
||||||
- Request/response payloads
|
|
||||||
- Screenshots
|
|
||||||
- Credentials
|
|
||||||
- Error messages or stack traces
|
|
||||||
|
|
||||||
Only metadata fields are allowed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "usage_samples"
|
|
||||||
|
|
||||||
# Instance reference
|
|
||||||
instance_id: Mapped[UUID] = mapped_column(
|
|
||||||
ForeignKey("instances.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Time window
|
|
||||||
window_start: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
window_end: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
window_type: Mapped[str] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
# "minute", "hour", "day"
|
|
||||||
|
|
||||||
# Tool (ONLY name, never payloads)
|
|
||||||
tool_name: Mapped[str] = mapped_column(
|
|
||||||
String(255),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
# e.g., "sysadmin.env_update"
|
|
||||||
|
|
||||||
# Counts (aggregated)
|
|
||||||
call_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
success_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
error_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
rate_limited_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
|
|
||||||
# Duration stats (milliseconds)
|
|
||||||
total_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
min_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
max_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
"""Hub API routes."""
|
|
||||||
|
|
||||||
from app.routes.activation import router as activation_router
|
|
||||||
from app.routes.admin import router as admin_router
|
|
||||||
from app.routes.health import router as health_router
|
|
||||||
from app.routes.telemetry import router as telemetry_router
|
|
||||||
|
|
||||||
__all__ = ["admin_router", "activation_router", "health_router", "telemetry_router"]
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
"""Instance activation endpoint.
|
|
||||||
|
|
||||||
This is the PUBLIC endpoint that client instances call to validate their license
|
|
||||||
and activate with the Hub.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db import AsyncSessionDep
|
|
||||||
from app.models.base import utc_now
|
|
||||||
from app.models.instance import Instance
|
|
||||||
from app.schemas.instance import ActivationRequest, ActivationResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/instances", tags=["Activation"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activate", response_model=ActivationResponse)
|
|
||||||
async def activate_instance(
|
|
||||||
request: ActivationRequest,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
) -> ActivationResponse:
|
|
||||||
"""
|
|
||||||
Activate an instance with its license key.
|
|
||||||
|
|
||||||
Called by local_bootstrap.sh before running migrations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- 200 + ActivationResponse on success
|
|
||||||
- 400 with error details on failure
|
|
||||||
|
|
||||||
Privacy guarantee:
|
|
||||||
- Only receives license_key and instance_id
|
|
||||||
- Never receives sensitive client data
|
|
||||||
"""
|
|
||||||
# Find instance by instance_id
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == request.instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={"error": "Instance not found", "code": "instance_not_found"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate license key using constant-time comparison
|
|
||||||
provided_hash = hashlib.sha256(request.license_key.encode()).hexdigest()
|
|
||||||
if not secrets.compare_digest(provided_hash, instance.license_key_hash):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={"error": "Invalid license key", "code": "invalid_license"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check license status
|
|
||||||
if instance.license_status == "suspended":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={"error": "License suspended", "code": "suspended"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if instance.license_status == "revoked":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={"error": "License revoked", "code": "revoked"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check expiry
|
|
||||||
now = utc_now()
|
|
||||||
if instance.license_expires_at and instance.license_expires_at < now:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={"error": "License expired", "code": "expired"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update activation state
|
|
||||||
if instance.activated_at is None:
|
|
||||||
instance.activated_at = now
|
|
||||||
instance.last_activation_at = now
|
|
||||||
instance.activation_count += 1
|
|
||||||
instance.status = "active"
|
|
||||||
|
|
||||||
# Generate hub_api_key if not already set
|
|
||||||
hub_api_key: str
|
|
||||||
if instance.hub_api_key_hash:
|
|
||||||
# Key was pre-generated, client should use existing key
|
|
||||||
hub_api_key = "USE_EXISTING"
|
|
||||||
else:
|
|
||||||
# Generate new hub_api_key
|
|
||||||
hub_api_key = f"hk_{secrets.token_hex(24)}"
|
|
||||||
instance.hub_api_key_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return ActivationResponse(
|
|
||||||
status="ok",
|
|
||||||
instance_id=instance.instance_id,
|
|
||||||
hub_api_key=hub_api_key,
|
|
||||||
config={
|
|
||||||
"telemetry_enabled": True,
|
|
||||||
"telemetry_interval_seconds": 60,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
"""Admin routes for client and instance management."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
from typing import Annotated
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db import AsyncSessionDep
|
|
||||||
from app.models.base import utc_now
|
|
||||||
from app.models.client import Client
|
|
||||||
from app.models.instance import Instance
|
|
||||||
from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate
|
|
||||||
from app.schemas.instance import InstanceBriefResponse, InstanceCreate, InstanceResponse
|
|
||||||
|
|
||||||
|
|
||||||
def validate_admin_key(
|
|
||||||
x_admin_api_key: Annotated[str, Header(description="Admin API key")],
|
|
||||||
) -> str:
|
|
||||||
"""Validate the admin API key with constant-time comparison."""
|
|
||||||
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid admin API key",
|
|
||||||
)
|
|
||||||
return x_admin_api_key
|
|
||||||
|
|
||||||
|
|
||||||
AdminKeyDep = Annotated[str, Depends(validate_admin_key)]
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
|
|
||||||
|
|
||||||
|
|
||||||
# ============ CLIENT MANAGEMENT ============
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clients", response_model=ClientResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_client(
|
|
||||||
client: ClientCreate,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> Client:
|
|
||||||
"""Create a new client (company/organization)."""
|
|
||||||
db_client = Client(
|
|
||||||
name=client.name,
|
|
||||||
contact_email=client.contact_email,
|
|
||||||
billing_plan=client.billing_plan,
|
|
||||||
)
|
|
||||||
db.add(db_client)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_client)
|
|
||||||
return db_client
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clients", response_model=list[ClientResponse])
|
|
||||||
async def list_clients(
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> list[Client]:
|
|
||||||
"""List all clients."""
|
|
||||||
result = await db.execute(select(Client).order_by(Client.created_at.desc()))
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clients/{client_id}", response_model=ClientResponse)
|
|
||||||
async def get_client(
|
|
||||||
client_id: UUID,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> Client:
|
|
||||||
"""Get a specific client by ID."""
|
|
||||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
|
||||||
client = result.scalar_one_or_none()
|
|
||||||
if client is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Client not found",
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/clients/{client_id}", response_model=ClientResponse)
|
|
||||||
async def update_client(
|
|
||||||
client_id: UUID,
|
|
||||||
update: ClientUpdate,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> Client:
|
|
||||||
"""Update a client."""
|
|
||||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
|
||||||
client = result.scalar_one_or_none()
|
|
||||||
if client is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Client not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
update_data = update.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(client, field, value)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(client)
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/clients/{client_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_client(
|
|
||||||
client_id: UUID,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> None:
|
|
||||||
"""Delete a client and all associated instances."""
|
|
||||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
|
||||||
client = result.scalar_one_or_none()
|
|
||||||
if client is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Client not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.delete(client)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ============ INSTANCE MANAGEMENT ============
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/clients/{client_id}/instances",
|
|
||||||
response_model=InstanceResponse,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def create_instance(
|
|
||||||
client_id: UUID,
|
|
||||||
instance: InstanceCreate,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Create a new instance for a client.
|
|
||||||
|
|
||||||
Returns the license_key and hub_api_key in PLAINTEXT - this is the only time
|
|
||||||
they are visible. Store them securely and provide to client for their config.json.
|
|
||||||
"""
|
|
||||||
# Verify client exists
|
|
||||||
client_result = await db.execute(select(Client).where(Client.id == client_id))
|
|
||||||
client = client_result.scalar_one_or_none()
|
|
||||||
if client is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Client not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check instance_id uniqueness
|
|
||||||
existing = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance.instance_id)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Instance with id '{instance.instance_id}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate license key
|
|
||||||
license_key = f"lb_inst_{secrets.token_hex(32)}"
|
|
||||||
license_key_hash = hashlib.sha256(license_key.encode()).hexdigest()
|
|
||||||
license_key_prefix = license_key[:12]
|
|
||||||
|
|
||||||
# Generate hub API key
|
|
||||||
hub_api_key = f"hk_{secrets.token_hex(24)}"
|
|
||||||
hub_api_key_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
|
|
||||||
|
|
||||||
now = utc_now()
|
|
||||||
db_instance = Instance(
|
|
||||||
client_id=client_id,
|
|
||||||
instance_id=instance.instance_id,
|
|
||||||
license_key_hash=license_key_hash,
|
|
||||||
license_key_prefix=license_key_prefix,
|
|
||||||
license_status="active",
|
|
||||||
license_issued_at=now,
|
|
||||||
license_expires_at=instance.license_expires_at,
|
|
||||||
hub_api_key_hash=hub_api_key_hash,
|
|
||||||
region=instance.region,
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
db.add(db_instance)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_instance)
|
|
||||||
|
|
||||||
# Return instance with plaintext keys (only time visible)
|
|
||||||
return {
|
|
||||||
"id": db_instance.id,
|
|
||||||
"instance_id": db_instance.instance_id,
|
|
||||||
"client_id": db_instance.client_id,
|
|
||||||
"license_key": license_key, # Plaintext, only time visible
|
|
||||||
"license_key_prefix": db_instance.license_key_prefix,
|
|
||||||
"license_status": db_instance.license_status,
|
|
||||||
"license_issued_at": db_instance.license_issued_at,
|
|
||||||
"license_expires_at": db_instance.license_expires_at,
|
|
||||||
"hub_api_key": hub_api_key, # Plaintext, only time visible
|
|
||||||
"activated_at": db_instance.activated_at,
|
|
||||||
"last_activation_at": db_instance.last_activation_at,
|
|
||||||
"activation_count": db_instance.activation_count,
|
|
||||||
"region": db_instance.region,
|
|
||||||
"version": db_instance.version,
|
|
||||||
"last_seen_at": db_instance.last_seen_at,
|
|
||||||
"status": db_instance.status,
|
|
||||||
"created_at": db_instance.created_at,
|
|
||||||
"updated_at": db_instance.updated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clients/{client_id}/instances", response_model=list[InstanceBriefResponse])
|
|
||||||
async def list_client_instances(
|
|
||||||
client_id: UUID,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> list[Instance]:
|
|
||||||
"""List all instances for a client."""
|
|
||||||
# Verify client exists
|
|
||||||
client_result = await db.execute(select(Client).where(Client.id == client_id))
|
|
||||||
if client_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Client not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance)
|
|
||||||
.where(Instance.client_id == client_id)
|
|
||||||
.order_by(Instance.created_at.desc())
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances/{instance_id}", response_model=InstanceBriefResponse)
|
|
||||||
async def get_instance(
|
|
||||||
instance_id: str,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> Instance:
|
|
||||||
"""Get a specific instance by its instance_id."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Instance not found",
|
|
||||||
)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/instances/{instance_id}/rotate-license", response_model=dict)
|
|
||||||
async def rotate_license_key(
|
|
||||||
instance_id: str,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Generate a new license key for an instance.
|
|
||||||
|
|
||||||
Invalidates the old key. Returns new key in plaintext (only time visible).
|
|
||||||
"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Instance not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
new_license_key = f"lb_inst_{secrets.token_hex(32)}"
|
|
||||||
instance.license_key_hash = hashlib.sha256(new_license_key.encode()).hexdigest()
|
|
||||||
instance.license_key_prefix = new_license_key[:12]
|
|
||||||
instance.license_issued_at = utc_now()
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"instance_id": instance.instance_id,
|
|
||||||
"license_key": new_license_key,
|
|
||||||
"license_key_prefix": instance.license_key_prefix,
|
|
||||||
"license_issued_at": instance.license_issued_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/instances/{instance_id}/rotate-hub-key", response_model=dict)
|
|
||||||
async def rotate_hub_api_key(
|
|
||||||
instance_id: str,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Generate a new Hub API key for telemetry.
|
|
||||||
|
|
||||||
Invalidates the old key. Returns new key in plaintext (only time visible).
|
|
||||||
"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Instance not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
new_hub_api_key = f"hk_{secrets.token_hex(24)}"
|
|
||||||
instance.hub_api_key_hash = hashlib.sha256(new_hub_api_key.encode()).hexdigest()
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"instance_id": instance.instance_id,
|
|
||||||
"hub_api_key": new_hub_api_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/instances/{instance_id}/suspend", response_model=dict)
|
|
||||||
async def suspend_instance(
|
|
||||||
instance_id: str,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> dict:
|
|
||||||
"""Suspend an instance license (blocks future activations)."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Instance not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
instance.license_status = "suspended"
|
|
||||||
instance.status = "suspended"
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {"instance_id": instance.instance_id, "status": "suspended"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/instances/{instance_id}/reactivate", response_model=dict)
|
|
||||||
async def reactivate_instance(
|
|
||||||
instance_id: str,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> dict:
|
|
||||||
"""Reactivate a suspended instance license."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Instance not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
if instance.license_status == "revoked":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Cannot reactivate a revoked license",
|
|
||||||
)
|
|
||||||
|
|
||||||
instance.license_status = "active"
|
|
||||||
instance.status = "active" if instance.activated_at else "pending"
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {"instance_id": instance.instance_id, "status": instance.status}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_instance(
|
|
||||||
instance_id: str,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
_: AdminKeyDep,
|
|
||||||
) -> None:
|
|
||||||
"""Delete an instance."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Instance).where(Instance.instance_id == instance_id)
|
|
||||||
)
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Instance not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.delete(instance)
|
|
||||||
await db.commit()
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
"""Health check endpoints."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Health"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def health_check() -> dict:
|
|
||||||
"""Basic health check endpoint."""
|
|
||||||
return {"status": "healthy"}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
"""Telemetry endpoint for receiving metrics from orchestrators.
|
|
||||||
|
|
||||||
This endpoint receives aggregated telemetry from orchestrator instances.
|
|
||||||
It validates authentication, stores metrics, and updates instance state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from app.db import AsyncSessionDep
|
|
||||||
from app.models.base import utc_now
|
|
||||||
from app.models.instance import Instance
|
|
||||||
from app.models.telemetry_sample import TelemetrySample
|
|
||||||
from app.schemas.telemetry import TelemetryPayload, TelemetryResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/instances", tags=["Telemetry"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instance_id}/telemetry", response_model=TelemetryResponse)
|
|
||||||
async def receive_telemetry(
|
|
||||||
instance_id: str,
|
|
||||||
payload: TelemetryPayload,
|
|
||||||
db: AsyncSessionDep,
|
|
||||||
hub_api_key: str = Header(..., alias="X-Hub-Api-Key"),
|
|
||||||
) -> TelemetryResponse:
|
|
||||||
"""
|
|
||||||
Receive telemetry from an orchestrator instance.
|
|
||||||
|
|
||||||
Authentication:
|
|
||||||
- Requires valid X-Hub-Api-Key header matching the instance
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- instance_id in path must match payload.instance_id (prevents spoofing)
|
|
||||||
- Instance must exist and be active
|
|
||||||
- Schema uses extra="forbid" to reject unknown fields
|
|
||||||
|
|
||||||
De-duplication:
|
|
||||||
- Uses (instance_id, window_start) unique constraint
|
|
||||||
- Duplicate submissions are silently accepted (idempotent)
|
|
||||||
|
|
||||||
HTTP Semantics:
|
|
||||||
- 200 OK: Telemetry accepted
|
|
||||||
- 400 Bad Request: instance_id mismatch or invalid payload
|
|
||||||
- 401 Unauthorized: Invalid or missing hub_api_key
|
|
||||||
- 403 Forbidden: Instance suspended
|
|
||||||
- 404 Not Found: Instance not found
|
|
||||||
"""
|
|
||||||
# Validate instance_id in path matches payload
|
|
||||||
if instance_id != payload.instance_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail={
|
|
||||||
"error": "instance_id mismatch between path and payload",
|
|
||||||
"code": "instance_id_mismatch",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find instance by instance_id string (e.g., "letsbe-orchestrator")
|
|
||||||
result = await db.execute(select(Instance).where(Instance.instance_id == instance_id))
|
|
||||||
instance = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if instance is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail={"error": "Instance not found", "code": "instance_not_found"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate hub_api_key using constant-time comparison
|
|
||||||
if not instance.hub_api_key_hash:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail={
|
|
||||||
"error": "Instance has no hub_api_key configured",
|
|
||||||
"code": "no_hub_key",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
provided_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
|
|
||||||
if not secrets.compare_digest(provided_hash, instance.hub_api_key_hash):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail={"error": "Invalid hub_api_key", "code": "invalid_hub_key"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check instance status
|
|
||||||
if instance.license_status == "suspended":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail={"error": "Instance suspended", "code": "suspended"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if instance.license_status == "revoked":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail={"error": "Instance revoked", "code": "revoked"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check license expiry
|
|
||||||
now = utc_now()
|
|
||||||
if instance.license_expires_at and instance.license_expires_at < now:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail={"error": "License expired", "code": "expired"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store telemetry sample
|
|
||||||
# Use PostgreSQL upsert to handle duplicates gracefully
|
|
||||||
# Note: instance_id in DB is the UUID (instance.id), not the string instance_id
|
|
||||||
telemetry_data = {
|
|
||||||
"instance_id": instance.id,
|
|
||||||
"window_start": payload.window_start,
|
|
||||||
"window_end": payload.window_end,
|
|
||||||
"uptime_seconds": payload.uptime_seconds,
|
|
||||||
"metrics": payload.metrics.model_dump(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# PostgreSQL INSERT ... ON CONFLICT DO NOTHING
|
|
||||||
# If duplicate (instance_id, window_start), silently ignore
|
|
||||||
stmt = (
|
|
||||||
pg_insert(TelemetrySample)
|
|
||||||
.values(**telemetry_data)
|
|
||||||
.on_conflict_do_nothing(constraint="uq_telemetry_instance_window")
|
|
||||||
)
|
|
||||||
await db.execute(stmt)
|
|
||||||
except IntegrityError:
|
|
||||||
# Fallback for non-PostgreSQL (shouldn't happen in production)
|
|
||||||
logger.warning(
|
|
||||||
"telemetry_duplicate_submission",
|
|
||||||
extra={
|
|
||||||
"instance_id": str(instance_id),
|
|
||||||
"window_start": payload.window_start.isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update instance last_seen_at
|
|
||||||
instance.last_seen_at = now
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"telemetry_received",
|
|
||||||
extra={
|
|
||||||
"instance_id": str(instance_id),
|
|
||||||
"window_start": payload.window_start.isoformat(),
|
|
||||||
"window_end": payload.window_end.isoformat(),
|
|
||||||
"uptime_seconds": payload.uptime_seconds,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return TelemetryResponse(
|
|
||||||
received=True,
|
|
||||||
next_interval_seconds=60,
|
|
||||||
message=None,
|
|
||||||
)
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
"""Hub API schemas."""
|
|
||||||
|
|
||||||
from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate
|
|
||||||
from app.schemas.instance import (
|
|
||||||
ActivationError,
|
|
||||||
ActivationRequest,
|
|
||||||
ActivationResponse,
|
|
||||||
InstanceCreate,
|
|
||||||
InstanceResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ClientCreate",
|
|
||||||
"ClientResponse",
|
|
||||||
"ClientUpdate",
|
|
||||||
"InstanceCreate",
|
|
||||||
"InstanceResponse",
|
|
||||||
"ActivationRequest",
|
|
||||||
"ActivationResponse",
|
|
||||||
"ActivationError",
|
|
||||||
]
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""Client schemas for API serialization."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ClientCreate(BaseModel):
|
|
||||||
"""Schema for creating a new client."""
|
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=255, description="Client/company name")
|
|
||||||
contact_email: Optional[EmailStr] = Field(None, description="Primary contact email")
|
|
||||||
billing_plan: str = Field("free", description="Billing plan")
|
|
||||||
|
|
||||||
|
|
||||||
class ClientUpdate(BaseModel):
|
|
||||||
"""Schema for updating a client."""
|
|
||||||
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
|
||||||
contact_email: Optional[EmailStr] = None
|
|
||||||
billing_plan: Optional[str] = None
|
|
||||||
status: Optional[str] = Field(None, pattern="^(active|suspended|archived)$")
|
|
||||||
|
|
||||||
|
|
||||||
class ClientResponse(BaseModel):
|
|
||||||
"""Schema for client API responses."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
name: str
|
|
||||||
contact_email: Optional[str]
|
|
||||||
billing_plan: str
|
|
||||||
status: str
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"""Instance schemas for API serialization."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceCreate(BaseModel):
|
|
||||||
"""Schema for creating a new instance."""
|
|
||||||
|
|
||||||
instance_id: str = Field(
|
|
||||||
...,
|
|
||||||
min_length=1,
|
|
||||||
max_length=255,
|
|
||||||
description="Unique instance identifier (e.g., 'acme-orchestrator')",
|
|
||||||
)
|
|
||||||
region: Optional[str] = Field(None, max_length=50, description="Deployment region")
|
|
||||||
license_expires_at: Optional[datetime] = Field(
|
|
||||||
None,
|
|
||||||
description="License expiry date (None = perpetual)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceResponse(BaseModel):
|
|
||||||
"""Schema for instance API responses.
|
|
||||||
|
|
||||||
Note: license_key and hub_api_key are ONLY returned on creation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
instance_id: str
|
|
||||||
client_id: UUID
|
|
||||||
|
|
||||||
# License info
|
|
||||||
license_key: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="ONLY returned on creation - store securely!",
|
|
||||||
)
|
|
||||||
license_key_prefix: str
|
|
||||||
license_status: str
|
|
||||||
license_issued_at: datetime
|
|
||||||
license_expires_at: Optional[datetime]
|
|
||||||
|
|
||||||
# Hub API key
|
|
||||||
hub_api_key: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="ONLY returned on creation - store securely!",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Activation state
|
|
||||||
activated_at: Optional[datetime]
|
|
||||||
last_activation_at: Optional[datetime]
|
|
||||||
activation_count: int
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
region: Optional[str]
|
|
||||||
version: Optional[str]
|
|
||||||
last_seen_at: Optional[datetime]
|
|
||||||
status: str
|
|
||||||
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceBriefResponse(BaseModel):
|
|
||||||
"""Brief instance response for listings (no secrets)."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
instance_id: str
|
|
||||||
client_id: UUID
|
|
||||||
license_key_prefix: str
|
|
||||||
license_status: str
|
|
||||||
license_expires_at: Optional[datetime]
|
|
||||||
activated_at: Optional[datetime]
|
|
||||||
activation_count: int
|
|
||||||
region: Optional[str]
|
|
||||||
status: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
# === ACTIVATION SCHEMAS ===
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationRequest(BaseModel):
|
|
||||||
"""
|
|
||||||
Activation request from a client instance.
|
|
||||||
|
|
||||||
PRIVACY: This schema ONLY accepts:
|
|
||||||
- license_key (credential for validation)
|
|
||||||
- instance_id (identifier)
|
|
||||||
|
|
||||||
It NEVER accepts sensitive data fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
license_key: str = Field(..., description="License key (lb_inst_...)")
|
|
||||||
instance_id: str = Field(..., description="Instance identifier")
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationResponse(BaseModel):
|
|
||||||
"""Response to a successful activation."""
|
|
||||||
|
|
||||||
status: str = Field("ok", description="Activation status")
|
|
||||||
instance_id: str
|
|
||||||
hub_api_key: str = Field(
|
|
||||||
...,
|
|
||||||
description="API key for telemetry auth (or 'USE_EXISTING')",
|
|
||||||
)
|
|
||||||
config: dict = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="Optional configuration from Hub",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationError(BaseModel):
|
|
||||||
"""Error response for failed activation."""
|
|
||||||
|
|
||||||
error: str = Field(..., description="Human-readable error message")
|
|
||||||
code: str = Field(
|
|
||||||
...,
|
|
||||||
description="Error code: invalid_license, expired, suspended, instance_not_found",
|
|
||||||
)
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"""Telemetry schemas for orchestrator metrics collection.
|
|
||||||
|
|
||||||
PRIVACY GUARANTEE: These schemas use extra="forbid" to reject
|
|
||||||
unknown fields, preventing accidental PII leaks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
# === Nested Metrics Schemas ===
|
|
||||||
|
|
||||||
|
|
||||||
class AgentMetrics(BaseModel):
|
|
||||||
"""Agent status counts."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
online_count: int = Field(ge=0, description="Agents currently online")
|
|
||||||
offline_count: int = Field(ge=0, description="Agents currently offline")
|
|
||||||
total_count: int = Field(ge=0, description="Total registered agents")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskTypeMetrics(BaseModel):
|
|
||||||
"""Per-task-type metrics."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
count: int = Field(ge=0, description="Number of tasks of this type")
|
|
||||||
avg_duration_ms: Optional[float] = Field(
|
|
||||||
None,
|
|
||||||
ge=0,
|
|
||||||
description="Average duration in milliseconds",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskMetrics(BaseModel):
|
|
||||||
"""Task execution metrics."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
by_status: dict[str, int] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="Task counts by status (completed, failed, running, pending)",
|
|
||||||
)
|
|
||||||
by_type: dict[str, TaskTypeMetrics] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="Task metrics by type (SHELL, FILE_WRITE, etc.)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ServerMetrics(BaseModel):
|
|
||||||
"""Server metrics."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
total_count: int = Field(ge=0, description="Total registered servers")
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryMetrics(BaseModel):
|
|
||||||
"""Top-level metrics container."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
agents: AgentMetrics
|
|
||||||
tasks: TaskMetrics
|
|
||||||
servers: ServerMetrics
|
|
||||||
|
|
||||||
|
|
||||||
# === Request/Response Schemas ===
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryPayload(BaseModel):
|
|
||||||
"""
|
|
||||||
Telemetry payload from an orchestrator instance.
|
|
||||||
|
|
||||||
PRIVACY: This schema deliberately uses extra="forbid" to reject
|
|
||||||
any fields not explicitly defined. This prevents accidental
|
|
||||||
transmission of PII or sensitive data.
|
|
||||||
|
|
||||||
De-duplication: The Hub uses (instance_id, window_start) as a
|
|
||||||
unique constraint to handle duplicate submissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
instance_id: str = Field(..., description="Instance ID string (must match path)")
|
|
||||||
window_start: datetime = Field(..., description="Start of telemetry window")
|
|
||||||
window_end: datetime = Field(..., description="End of telemetry window")
|
|
||||||
uptime_seconds: int = Field(ge=0, description="Orchestrator uptime in seconds")
|
|
||||||
metrics: TelemetryMetrics = Field(..., description="Aggregated metrics")
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryResponse(BaseModel):
|
|
||||||
"""Response to telemetry submission."""
|
|
||||||
|
|
||||||
received: bool = Field(True, description="Whether telemetry was accepted")
|
|
||||||
next_interval_seconds: int = Field(
|
|
||||||
60,
|
|
||||||
description="Suggested interval for next submission",
|
|
||||||
)
|
|
||||||
message: Optional[str] = Field(None, description="Optional status message")
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""Hub services."""
|
|
||||||
|
|
||||||
from app.services.redactor import redact_metadata, validate_tool_name
|
|
||||||
|
|
||||||
__all__ = ["redact_metadata", "validate_tool_name"]
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
"""
|
|
||||||
Strict ALLOW-LIST redaction for telemetry data.
|
|
||||||
|
|
||||||
PRIVACY GUARANTEE: If a field is not explicitly allowed, it is removed.
|
|
||||||
This module ensures NO sensitive data ever reaches the Hub database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
# ONLY these fields can be stored in metadata
|
|
||||||
ALLOWED_METADATA_FIELDS = frozenset({
|
|
||||||
"tool_name",
|
|
||||||
"duration_ms",
|
|
||||||
"status",
|
|
||||||
"error_code",
|
|
||||||
"component",
|
|
||||||
"version",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Patterns that indicate sensitive data (defense in depth)
|
|
||||||
SENSITIVE_PATTERNS = frozenset({
|
|
||||||
"password",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"key",
|
|
||||||
"credential",
|
|
||||||
"auth",
|
|
||||||
"cookie",
|
|
||||||
"session",
|
|
||||||
"bearer",
|
|
||||||
"content",
|
|
||||||
"body",
|
|
||||||
"payload",
|
|
||||||
"data",
|
|
||||||
"file",
|
|
||||||
"env",
|
|
||||||
"environment",
|
|
||||||
"config",
|
|
||||||
"setting",
|
|
||||||
"screenshot",
|
|
||||||
"image",
|
|
||||||
"base64",
|
|
||||||
"binary",
|
|
||||||
"private",
|
|
||||||
"cert",
|
|
||||||
"certificate",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def redact_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Filter metadata to ONLY allowed fields.
|
|
||||||
|
|
||||||
Uses allow-list approach: if not explicitly allowed, it's removed.
|
|
||||||
This provides defense against accidentally storing sensitive data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: Raw metadata from telemetry
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered metadata with only safe fields
|
|
||||||
"""
|
|
||||||
if metadata is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
redacted: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for key, value in metadata.items():
|
|
||||||
# Must be in allow list
|
|
||||||
if key not in ALLOWED_METADATA_FIELDS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Defense in depth: reject if key contains sensitive pattern
|
|
||||||
key_lower = key.lower()
|
|
||||||
if any(pattern in key_lower for pattern in SENSITIVE_PATTERNS):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Only primitive types (no nested objects that could hide data)
|
|
||||||
if isinstance(value, (str, int, float, bool)):
|
|
||||||
# String length limit to prevent large data blobs
|
|
||||||
if isinstance(value, str) and len(value) > 100:
|
|
||||||
continue
|
|
||||||
redacted[key] = value
|
|
||||||
|
|
||||||
return redacted
|
|
||||||
|
|
||||||
|
|
||||||
def validate_tool_name(tool_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate tool name format.
|
|
||||||
|
|
||||||
Tool names must:
|
|
||||||
- Start with a known prefix (sysadmin., browser., gateway.)
|
|
||||||
- Be reasonably short
|
|
||||||
- Not contain suspicious characters
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: Tool name to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid, False otherwise
|
|
||||||
"""
|
|
||||||
# Must match known prefixes
|
|
||||||
valid_prefixes = ("sysadmin.", "browser.", "gateway.", "llm.")
|
|
||||||
if not tool_name.startswith(valid_prefixes):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Length limit
|
|
||||||
if len(tool_name) > 100:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# No suspicious content
|
|
||||||
suspicious_chars = {";", "'", '"', "\\", "\n", "\r", "\t", "\0"}
|
|
||||||
if any(c in tool_name for c in suspicious_chars):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_error_code(error_code: str | None) -> str | None:
|
|
||||||
"""
|
|
||||||
Sanitize an error code to ensure it doesn't contain sensitive data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error_code: Raw error code
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sanitized error code or None if invalid
|
|
||||||
"""
|
|
||||||
if error_code is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Length limit
|
|
||||||
if len(error_code) > 50:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Must be alphanumeric with underscores/dashes
|
|
||||||
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
|
|
||||||
if not all(c in allowed for c in error_code):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return error_code
|
|
||||||
|
|
@ -1,36 +1,38 @@
|
||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
|
||||||
image: code.letsbe.solutions/letsbe/hub:latest
|
|
||||||
container_name: letsbe-hub-api
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=postgresql+asyncpg://hub:hub@db:5432/hub
|
|
||||||
- ADMIN_API_KEY=${ADMIN_API_KEY:-change-me-in-production}
|
|
||||||
- DEBUG=${DEBUG:-false}
|
|
||||||
ports:
|
|
||||||
- "8200:8000"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:16-alpine
|
||||||
container_name: letsbe-hub-db
|
container_name: letsbe-hub-db
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=hub
|
POSTGRES_USER: letsbe_hub
|
||||||
- POSTGRES_PASSWORD=hub
|
POSTGRES_PASSWORD: letsbe_hub_dev
|
||||||
- POSTGRES_DB=hub
|
POSTGRES_DB: letsbe_hub
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- hub-db-data:/var/lib/postgresql/data
|
- hub-db-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U hub"]
|
test: ["CMD-SHELL", "pg_isready -U letsbe_hub -d letsbe_hub"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
hub:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: letsbe-hub-app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://letsbe_hub:letsbe_hub_dev@db:5432/letsbe_hub
|
||||||
|
NEXTAUTH_URL: http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET: dev-secret-change-in-production-min-32-chars
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
hub-db-data:
|
hub-db-data:
|
||||||
name: letsbe-hub-db
|
name: letsbe-hub-db
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '*.letsbe.solutions',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"name": "letsbe-hub",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@prisma/client": "^6.2.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"@tanstack/react-query": "^5.64.2",
|
||||||
|
"@tanstack/react-table": "^8.20.6",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"next": "15.1.4",
|
||||||
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.24.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/react": "^19.0.4",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-next": "15.1.4",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.2.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENUMS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum UserStatus {
|
||||||
|
PENDING_VERIFICATION
|
||||||
|
ACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StaffRole {
|
||||||
|
ADMIN
|
||||||
|
SUPPORT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionPlan {
|
||||||
|
TRIAL
|
||||||
|
STARTER
|
||||||
|
PRO
|
||||||
|
ENTERPRISE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionTier {
|
||||||
|
HUB_DASHBOARD
|
||||||
|
ADVANCED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
TRIAL
|
||||||
|
ACTIVE
|
||||||
|
CANCELED
|
||||||
|
PAST_DUE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
PAYMENT_CONFIRMED
|
||||||
|
AWAITING_SERVER
|
||||||
|
SERVER_READY
|
||||||
|
DNS_PENDING
|
||||||
|
DNS_READY
|
||||||
|
PROVISIONING
|
||||||
|
FULFILLED
|
||||||
|
EMAIL_CONFIGURED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JobStatus {
|
||||||
|
PENDING
|
||||||
|
CLAIMED
|
||||||
|
RUNNING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
DEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogLevel {
|
||||||
|
DEBUG
|
||||||
|
INFO
|
||||||
|
WARN
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER & STAFF MODELS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
name String?
|
||||||
|
company String?
|
||||||
|
status UserStatus @default(PENDING_VERIFICATION)
|
||||||
|
emailVerified DateTime? @map("email_verified")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
subscriptions Subscription[]
|
||||||
|
orders Order[]
|
||||||
|
tokenUsage TokenUsage[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Staff {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
name String
|
||||||
|
role StaffRole @default(SUPPORT)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("staff")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SUBSCRIPTION & BILLING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
plan SubscriptionPlan @default(TRIAL)
|
||||||
|
tier SubscriptionTier @default(HUB_DASHBOARD)
|
||||||
|
tokenLimit Int @default(10000) @map("token_limit")
|
||||||
|
tokensUsed Int @default(0) @map("tokens_used")
|
||||||
|
trialEndsAt DateTime? @map("trial_ends_at")
|
||||||
|
stripeCustomerId String? @map("stripe_customer_id")
|
||||||
|
stripeSubscriptionId String? @map("stripe_subscription_id")
|
||||||
|
status SubscriptionStatus @default(TRIAL)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ORDERS & PROVISIONING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
status OrderStatus @default(PAYMENT_CONFIRMED)
|
||||||
|
tier SubscriptionTier
|
||||||
|
domain String
|
||||||
|
tools String[]
|
||||||
|
configJson Json @map("config_json")
|
||||||
|
|
||||||
|
// Server credentials (entered by staff)
|
||||||
|
serverIp String? @map("server_ip")
|
||||||
|
serverPasswordEncrypted String? @map("server_password_encrypted")
|
||||||
|
sshPort Int @default(22) @map("ssh_port")
|
||||||
|
|
||||||
|
// Generated after provisioning
|
||||||
|
portainerUrl String? @map("portainer_url")
|
||||||
|
dashboardUrl String? @map("dashboard_url")
|
||||||
|
failureReason String? @map("failure_reason")
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
serverReadyAt DateTime? @map("server_ready_at")
|
||||||
|
provisioningStartedAt DateTime? @map("provisioning_started_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
provisioningLogs ProvisioningLog[]
|
||||||
|
jobs ProvisioningJob[]
|
||||||
|
|
||||||
|
@@map("orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProvisioningLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @map("order_id")
|
||||||
|
level LogLevel @default(INFO)
|
||||||
|
message String
|
||||||
|
step String?
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([orderId, timestamp])
|
||||||
|
@@map("provisioning_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JOB QUEUE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model ProvisioningJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @map("order_id")
|
||||||
|
jobType String @map("job_type")
|
||||||
|
status JobStatus @default(PENDING)
|
||||||
|
priority Int @default(0)
|
||||||
|
claimedAt DateTime? @map("claimed_at")
|
||||||
|
claimedBy String? @map("claimed_by")
|
||||||
|
containerName String? @map("container_name")
|
||||||
|
attempt Int @default(1)
|
||||||
|
maxAttempts Int @default(3) @map("max_attempts")
|
||||||
|
nextRetryAt DateTime? @map("next_retry_at")
|
||||||
|
configSnapshot Json @map("config_snapshot")
|
||||||
|
runnerTokenHash String? @map("runner_token_hash")
|
||||||
|
result Json?
|
||||||
|
error String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
logs JobLog[]
|
||||||
|
|
||||||
|
@@index([status, priority, createdAt])
|
||||||
|
@@index([orderId])
|
||||||
|
@@map("provisioning_jobs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model JobLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
jobId String @map("job_id")
|
||||||
|
level LogLevel @default(INFO)
|
||||||
|
message String
|
||||||
|
step String?
|
||||||
|
progress Int?
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
job ProvisioningJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([jobId, timestamp])
|
||||||
|
@@map("job_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOKEN USAGE (AI Tracking)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model TokenUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
instanceId String? @map("instance_id")
|
||||||
|
operation String // chat, analysis, setup
|
||||||
|
tokensInput Int @map("tokens_input")
|
||||||
|
tokensOutput Int @map("tokens_output")
|
||||||
|
model String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@map("token_usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RUNNER TOKENS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model RunnerToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tokenHash String @unique @map("token_hash")
|
||||||
|
name String
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
lastUsed DateTime? @map("last_used")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("runner_tokens")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { PrismaClient, OrderStatus, SubscriptionPlan, SubscriptionTier, SubscriptionStatus, UserStatus, LogLevel } from '@prisma/client'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
const { hash } = bcrypt
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
// Random data helpers
|
||||||
|
const companies = [
|
||||||
|
'Acme Corp', 'TechStart Inc', 'Digital Solutions', 'CloudFirst Ltd',
|
||||||
|
'InnovateTech', 'DataDriven Co', 'SmartBiz Solutions', 'FutureTech Labs',
|
||||||
|
'AgileWorks', 'NextGen Systems', null, null, null
|
||||||
|
]
|
||||||
|
|
||||||
|
const domains = [
|
||||||
|
'acme.letsbe.cloud', 'techstart.letsbe.cloud', 'digital.letsbe.cloud',
|
||||||
|
'cloudfirst.letsbe.cloud', 'innovate.letsbe.cloud', 'datadriven.letsbe.cloud',
|
||||||
|
'smartbiz.letsbe.cloud', 'futuretech.letsbe.cloud', 'agileworks.letsbe.cloud',
|
||||||
|
'nextgen.letsbe.cloud', 'startup.letsbe.cloud', 'enterprise.letsbe.cloud',
|
||||||
|
'demo.letsbe.cloud', 'test.letsbe.cloud', 'dev.letsbe.cloud'
|
||||||
|
]
|
||||||
|
|
||||||
|
const toolSets = {
|
||||||
|
basic: ['nextcloud', 'keycloak'],
|
||||||
|
standard: ['nextcloud', 'keycloak', 'minio', 'poste'],
|
||||||
|
advanced: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser'],
|
||||||
|
full: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser', 'portainer', 'grafana'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const logMessages = {
|
||||||
|
PAYMENT_CONFIRMED: ['Payment received via Stripe', 'Order confirmed'],
|
||||||
|
AWAITING_SERVER: ['Waiting for server allocation', 'Server request submitted to provider'],
|
||||||
|
SERVER_READY: ['Server provisioned', 'SSH access verified', 'Root password received'],
|
||||||
|
DNS_PENDING: ['DNS records submitted', 'Waiting for DNS propagation'],
|
||||||
|
DNS_READY: ['DNS records verified', 'Domain is resolving correctly'],
|
||||||
|
PROVISIONING: [
|
||||||
|
'Starting provisioning process',
|
||||||
|
'Downloading Docker images',
|
||||||
|
'Configuring Nginx reverse proxy',
|
||||||
|
'Installing Keycloak',
|
||||||
|
'Configuring Nextcloud',
|
||||||
|
'Setting up MinIO storage',
|
||||||
|
'Configuring email server',
|
||||||
|
'Running health checks',
|
||||||
|
],
|
||||||
|
FULFILLED: ['Provisioning complete', 'All services healthy', 'Welcome email sent'],
|
||||||
|
EMAIL_CONFIGURED: ['SMTP credentials configured', 'Email sending verified'],
|
||||||
|
FAILED: ['Provisioning failed', 'See error details below'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomDate(daysAgo: number): Date {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() - Math.floor(Math.random() * daysAgo))
|
||||||
|
date.setHours(Math.floor(Math.random() * 24))
|
||||||
|
date.setMinutes(Math.floor(Math.random() * 60))
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomChoice<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting seed...')
|
||||||
|
|
||||||
|
// 1. Create admin user if not exists
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@letsbe.solutions'
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
|
|
||||||
|
const existingAdmin = await prisma.staff.findUnique({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingAdmin) {
|
||||||
|
const passwordHash = await hash(adminPassword, 12)
|
||||||
|
await prisma.staff.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(`Created admin user: ${adminEmail}`)
|
||||||
|
} else {
|
||||||
|
console.log(`Admin user ${adminEmail} already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create support staff
|
||||||
|
const supportEmail = 'support@letsbe.solutions'
|
||||||
|
const existingSupport = await prisma.staff.findUnique({
|
||||||
|
where: { email: supportEmail },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingSupport) {
|
||||||
|
const passwordHash = await hash('support123', 12)
|
||||||
|
await prisma.staff.create({
|
||||||
|
data: {
|
||||||
|
email: supportEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Support Agent',
|
||||||
|
role: 'SUPPORT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(`Created support user: ${supportEmail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create test customers
|
||||||
|
const customerData = [
|
||||||
|
{ email: 'john@acme.com', name: 'John Smith', company: 'Acme Corp', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'sarah@techstart.io', name: 'Sarah Johnson', company: 'TechStart Inc', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'mike@cloudfirst.co', name: 'Mike Davis', company: 'CloudFirst Ltd', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'emma@digital.io', name: 'Emma Wilson', company: 'Digital Solutions', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'david@innovate.co', name: 'David Brown', company: 'InnovateTech', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'lisa@datadriven.io', name: 'Lisa Chen', company: 'DataDriven Co', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'james@smartbiz.com', name: 'James Miller', company: 'SmartBiz Solutions', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'amy@futuretech.io', name: 'Amy Taylor', company: 'FutureTech Labs', status: UserStatus.PENDING_VERIFICATION },
|
||||||
|
{ email: 'robert@agile.co', name: 'Robert Anderson', company: 'AgileWorks', status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'jennifer@nextgen.io', name: 'Jennifer Lee', company: 'NextGen Systems', status: UserStatus.SUSPENDED },
|
||||||
|
{ email: 'freelancer@gmail.com', name: 'Alex Freelancer', company: null, status: UserStatus.ACTIVE },
|
||||||
|
{ email: 'startup@mail.com', name: 'Startup Founder', company: null, status: UserStatus.PENDING_VERIFICATION },
|
||||||
|
]
|
||||||
|
|
||||||
|
const customers: { id: string; email: string }[] = []
|
||||||
|
|
||||||
|
for (const customer of customerData) {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: customer.email },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const passwordHash = await hash('customer123', 12)
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: customer.email,
|
||||||
|
passwordHash,
|
||||||
|
name: customer.name,
|
||||||
|
company: customer.company,
|
||||||
|
status: customer.status,
|
||||||
|
emailVerified: customer.status === UserStatus.ACTIVE ? new Date() : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
customers.push({ id: created.id, email: created.email })
|
||||||
|
console.log(`Created customer: ${customer.email}`)
|
||||||
|
} else {
|
||||||
|
customers.push({ id: existing.id, email: existing.email })
|
||||||
|
console.log(`Customer ${customer.email} already exists`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create subscriptions for customers
|
||||||
|
const subscriptionConfigs = [
|
||||||
|
{ plan: SubscriptionPlan.ENTERPRISE, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.PAST_DUE },
|
||||||
|
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.TRIAL },
|
||||||
|
{ plan: SubscriptionPlan.ENTERPRISE, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.CANCELED },
|
||||||
|
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
|
||||||
|
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.TRIAL },
|
||||||
|
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.TRIAL },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < customers.length; i++) {
|
||||||
|
const customer = customers[i]
|
||||||
|
const config = subscriptionConfigs[i] || subscriptionConfigs[0]
|
||||||
|
|
||||||
|
const existingSub = await prisma.subscription.findFirst({
|
||||||
|
where: { userId: customer.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingSub) {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
userId: customer.id,
|
||||||
|
plan: config.plan,
|
||||||
|
tier: config.tier,
|
||||||
|
status: config.status,
|
||||||
|
tokenLimit: config.plan === SubscriptionPlan.ENTERPRISE ? 100000 :
|
||||||
|
config.plan === SubscriptionPlan.PRO ? 50000 :
|
||||||
|
config.plan === SubscriptionPlan.STARTER ? 20000 : 10000,
|
||||||
|
tokensUsed: Math.floor(Math.random() * 5000),
|
||||||
|
trialEndsAt: config.status === SubscriptionStatus.TRIAL ?
|
||||||
|
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(`Created subscription for: ${customer.email}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create orders with various statuses
|
||||||
|
const orderConfigs = [
|
||||||
|
// Orders in various pipeline stages
|
||||||
|
{ status: OrderStatus.PAYMENT_CONFIRMED, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.PAYMENT_CONFIRMED, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.AWAITING_SERVER, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.AWAITING_SERVER, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.SERVER_READY, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.DNS_PENDING, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.DNS_PENDING, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.DNS_READY, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.DNS_READY, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.PROVISIONING, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.EMAIL_CONFIGURED, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
{ status: OrderStatus.EMAIL_CONFIGURED, tier: SubscriptionTier.ADVANCED },
|
||||||
|
{ status: OrderStatus.FAILED, tier: SubscriptionTier.HUB_DASHBOARD },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < orderConfigs.length; i++) {
|
||||||
|
const config = orderConfigs[i]
|
||||||
|
const customer = customers[i % customers.length]
|
||||||
|
const domain = domains[i % domains.length]
|
||||||
|
|
||||||
|
const existingOrder = await prisma.order.findFirst({
|
||||||
|
where: { domain },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingOrder) {
|
||||||
|
const tools = config.tier === SubscriptionTier.HUB_DASHBOARD
|
||||||
|
? toolSets.full
|
||||||
|
: randomChoice([toolSets.basic, toolSets.standard, toolSets.advanced])
|
||||||
|
|
||||||
|
const createdAt = randomDate(30)
|
||||||
|
const serverStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.SERVER_READY, OrderStatus.DNS_PENDING, OrderStatus.DNS_READY,
|
||||||
|
OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED,
|
||||||
|
OrderStatus.FAILED
|
||||||
|
]
|
||||||
|
const hasServer = serverStatuses.includes(config.status)
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: customer.id,
|
||||||
|
status: config.status,
|
||||||
|
tier: config.tier,
|
||||||
|
domain,
|
||||||
|
tools,
|
||||||
|
configJson: { tools, tier: config.tier, domain },
|
||||||
|
serverIp: hasServer ? `192.168.1.${100 + i}` : null,
|
||||||
|
serverPasswordEncrypted: hasServer ? 'encrypted_placeholder' : null,
|
||||||
|
sshPort: 22,
|
||||||
|
portainerUrl: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
|
||||||
|
? `https://portainer.${domain}` : null,
|
||||||
|
dashboardUrl: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
|
||||||
|
? `https://dashboard.${domain}` : null,
|
||||||
|
failureReason: config.status === OrderStatus.FAILED
|
||||||
|
? 'Connection timeout during Docker installation' : null,
|
||||||
|
createdAt,
|
||||||
|
serverReadyAt: hasServer ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
|
||||||
|
provisioningStartedAt: config.status === OrderStatus.PROVISIONING ||
|
||||||
|
config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
|
||||||
|
? new Date(createdAt.getTime() + 4 * 60 * 60 * 1000) : null,
|
||||||
|
completedAt: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
|
||||||
|
? new Date(createdAt.getTime() + 5 * 60 * 60 * 1000) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add provisioning logs based on status
|
||||||
|
const statusIndex = Object.keys(logMessages).indexOf(config.status)
|
||||||
|
const statusesToLog = Object.keys(logMessages).slice(0, statusIndex + 1) as OrderStatus[]
|
||||||
|
|
||||||
|
let logTime = new Date(createdAt)
|
||||||
|
for (const logStatus of statusesToLog) {
|
||||||
|
const messages = logMessages[logStatus] || []
|
||||||
|
for (const message of messages) {
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
level: logStatus === OrderStatus.FAILED ? LogLevel.ERROR : LogLevel.INFO,
|
||||||
|
message,
|
||||||
|
step: logStatus,
|
||||||
|
timestamp: new Date(logTime),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logTime = new Date(logTime.getTime() + Math.random() * 5 * 60 * 1000) // 0-5 min later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created order: ${domain} (${config.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create a runner token for testing
|
||||||
|
const runnerTokenHash = await hash('test-runner-token', 12)
|
||||||
|
const existingRunner = await prisma.runnerToken.findFirst({
|
||||||
|
where: { name: 'test-runner' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingRunner) {
|
||||||
|
await prisma.runnerToken.create({
|
||||||
|
data: {
|
||||||
|
tokenHash: runnerTokenHash,
|
||||||
|
name: 'test-runner',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created test runner token')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSeed completed successfully!')
|
||||||
|
console.log('\nTest credentials:')
|
||||||
|
console.log(' Admin: admin@letsbe.solutions / admin123')
|
||||||
|
console.log(' Support: support@letsbe.solutions / support123')
|
||||||
|
console.log(' Customers: <email> / customer123')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const callbackUrl = searchParams.get('callbackUrl') || '/'
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [userType, setUserType] = useState<'customer' | 'staff'>('staff')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoginError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
userType,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setLoginError(result.error)
|
||||||
|
} else if (result?.ok) {
|
||||||
|
router.push(userType === 'staff' ? '/admin' : '/')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLoginError('An unexpected error occurred')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold text-center">
|
||||||
|
LetsBe Hub
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
Sign in to your account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{(error || loginError) && (
|
||||||
|
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
||||||
|
{error === 'CredentialsSignin'
|
||||||
|
? 'Invalid email or password'
|
||||||
|
: loginError || error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={userType === 'staff' ? 'default' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setUserType('staff')}
|
||||||
|
>
|
||||||
|
Staff Login
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={userType === 'customer' ? 'default' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setUserType('customer')}
|
||||||
|
>
|
||||||
|
Customer Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,541 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useCustomer } from '@/hooks/use-customers'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { customerKeys } from '@/hooks/use-customers'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
Server,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Edit,
|
||||||
|
Ban,
|
||||||
|
CheckCircle,
|
||||||
|
ExternalLink,
|
||||||
|
CreditCard,
|
||||||
|
Activity,
|
||||||
|
Package,
|
||||||
|
X,
|
||||||
|
Save,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
|
||||||
|
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
|
||||||
|
type OrderStatus = 'PAYMENT_CONFIRMED' | 'AWAITING_SERVER' | 'SERVER_READY' | 'DNS_PENDING' | 'DNS_READY' | 'PROVISIONING' | 'FULFILLED' | 'EMAIL_CONFIGURED' | 'FAILED'
|
||||||
|
|
||||||
|
// Status badge components
|
||||||
|
function UserStatusBadge({ status }: { status: UserStatus }) {
|
||||||
|
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
|
||||||
|
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
||||||
|
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
|
||||||
|
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
}
|
||||||
|
const config = statusConfig[status]
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
|
||||||
|
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
|
||||||
|
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
|
||||||
|
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
||||||
|
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
|
||||||
|
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
|
||||||
|
}
|
||||||
|
const config = statusConfig[status]
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderStatusBadge({ status }: { status: OrderStatus }) {
|
||||||
|
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||||
|
PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' },
|
||||||
|
AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
SERVER_READY: { label: 'Server Ready', className: 'bg-cyan-100 text-cyan-800' },
|
||||||
|
DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' },
|
||||||
|
DNS_READY: { label: 'DNS Ready', className: 'bg-teal-100 text-teal-800' },
|
||||||
|
PROVISIONING: { label: 'Provisioning', className: 'bg-purple-100 text-purple-800' },
|
||||||
|
FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' },
|
||||||
|
EMAIL_CONFIGURED: { label: 'Email Configured', className: 'bg-emerald-100 text-emerald-800' },
|
||||||
|
FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' },
|
||||||
|
}
|
||||||
|
const config = statusConfig[status] || { label: status, className: 'bg-gray-100 text-gray-800' }
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomerDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const customerId = params.id as string
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', company: '' })
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false)
|
||||||
|
|
||||||
|
const { data: customer, isLoading, isError, error, refetch, isFetching } = useCustomer(customerId)
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (customer) {
|
||||||
|
setEditForm({
|
||||||
|
name: customer.name || '',
|
||||||
|
company: customer.company || '',
|
||||||
|
})
|
||||||
|
setIsEditing(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsUpdating(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editForm),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update customer')
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating customer:', err)
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: UserStatus) => {
|
||||||
|
setIsUpdating(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update status')
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating status:', err)
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 customer details...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[50vh]">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-destructive">Failed to load customer</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error instanceof Error ? error.message : 'An error occurred'}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
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-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground">Customer not found</p>
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSubscription = customer.subscriptions?.[0]
|
||||||
|
const totalTokensUsed = customer.totalTokensUsed || 0
|
||||||
|
const tokenUsagePercent = currentSubscription
|
||||||
|
? Math.min((totalTokensUsed / currentSubscription.tokenLimit) * 100, 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{customer.name || customer.email}</h1>
|
||||||
|
<p className="text-muted-foreground">{customer.email}</p>
|
||||||
|
</div>
|
||||||
|
<UserStatusBadge status={customer.status as UserStatus} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{customer.status === 'ACTIVE' ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatusChange('SUSPENDED')}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4 mr-2" />
|
||||||
|
Suspend
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStatusChange('ACTIVE')}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-8 w-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">{customer._count?.orders || 0}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Orders</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="h-8 w-8 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Active Servers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="h-8 w-8 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">{totalTokensUsed.toLocaleString()}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Tokens Used</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-8 w-8 text-purple-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold capitalize">
|
||||||
|
{currentSubscription?.plan.toLowerCase() || 'None'}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Current Plan</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Customer Profile Card */}
|
||||||
|
<Card className="lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
{!isEditing && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleEdit}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company">Company</Label>
|
||||||
|
<Input
|
||||||
|
id="company"
|
||||||
|
value={editForm.company}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, company: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={isUpdating}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<User className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{customer.name || 'No name'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Name</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-50">
|
||||||
|
<Mail className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{customer.email}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-50">
|
||||||
|
<Building2 className="h-6 w-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{customer.company || 'Not set'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Company</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-50">
|
||||||
|
<Calendar className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(customer.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Member Since</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Subscription Card */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Subscription</CardTitle>
|
||||||
|
<CardDescription>Current plan and token usage</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{currentSubscription ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold capitalize">
|
||||||
|
{currentSubscription.plan.toLowerCase()} Plan
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground capitalize">
|
||||||
|
{currentSubscription.tier.replace('_', ' ').toLowerCase()} tier
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SubscriptionBadge status={currentSubscription.status as SubscriptionStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentSubscription.trialEndsAt && (
|
||||||
|
<div className="rounded-lg bg-blue-50 p-4">
|
||||||
|
<p className="text-sm font-medium text-blue-800">
|
||||||
|
Trial ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Token Usage</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{totalTokensUsed.toLocaleString()} / {currentSubscription.tokenLimit.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full overflow-hidden rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${
|
||||||
|
tokenUsagePercent > 90 ? 'bg-red-500' : tokenUsagePercent > 70 ? 'bg-yellow-500' : 'bg-primary'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${tokenUsagePercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(100 - tokenUsagePercent).toFixed(1)}% remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">No active subscription</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders History */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Orders History</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link href={`/admin/orders?customer=${customerId}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
View All
|
||||||
|
<ExternalLink className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{customer.orders && customer.orders.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||||
|
<th className="px-4 py-3 font-medium">Domain</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Tier</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Server IP</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{customer.orders.map((order: {
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
tier: string
|
||||||
|
status: OrderStatus
|
||||||
|
serverIp: string | null
|
||||||
|
createdAt: Date | string
|
||||||
|
}) => (
|
||||||
|
<tr key={order.id} className="border-b hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium">{order.domain}</td>
|
||||||
|
<td className="px-4 py-3 capitalize">
|
||||||
|
{order.tier.replace('_', ' ').toLowerCase()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<OrderStatusBadge status={order.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-sm">
|
||||||
|
{order.serverIp || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{new Date(order.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/admin/orders/${order.id}`}>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">No orders yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useCustomers } from '@/hooks/use-customers'
|
||||||
|
import { UserStatus as ApiUserStatus } from '@/types/api'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
MoreHorizontal,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
Server,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
|
||||||
|
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
company: string | null
|
||||||
|
status: UserStatus
|
||||||
|
subscription: {
|
||||||
|
plan: string
|
||||||
|
tier: string
|
||||||
|
status: SubscriptionStatus
|
||||||
|
tokensUsed: number
|
||||||
|
tokenLimit: number
|
||||||
|
} | null
|
||||||
|
activeServers: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge component
|
||||||
|
function UserStatusBadge({ status }: { status: UserStatus }) {
|
||||||
|
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
|
||||||
|
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
||||||
|
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
|
||||||
|
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
|
||||||
|
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
|
||||||
|
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
|
||||||
|
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
|
||||||
|
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
|
||||||
|
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer table row component
|
||||||
|
function CustomerRow({ customer }: { customer: Customer }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<User className="h-5 w-5 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>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{customer.company ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{customer.company}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<UserStatusBadge status={customer.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{customer.subscription ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium capitalize">{customer.subscription.plan.toLowerCase()}</span>
|
||||||
|
<SubscriptionBadge status={customer.subscription.status} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
{customer.subscription.tier.replace('_', ' ').toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No subscription</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{customer.subscription ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
{customer.subscription.tokensUsed.toLocaleString()} /{' '}
|
||||||
|
{customer.subscription.tokenLimit.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{customer.activeServers}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{new Date(customer.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link href={`/admin/customers/${customer.id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const itemsPerPage = 10
|
||||||
|
|
||||||
|
// Fetch customers from API
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = useCustomers({
|
||||||
|
search: search || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter as ApiUserStatus : undefined,
|
||||||
|
page: currentPage,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map API customers to component format
|
||||||
|
const customers = useMemo<Customer[]>(() => {
|
||||||
|
if (!data?.customers) return []
|
||||||
|
return data.customers.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name || c.email,
|
||||||
|
email: c.email,
|
||||||
|
company: c.company,
|
||||||
|
status: c.status as UserStatus,
|
||||||
|
subscription: c.subscriptions?.[0] ? {
|
||||||
|
plan: c.subscriptions[0].plan,
|
||||||
|
tier: c.subscriptions[0].tier,
|
||||||
|
status: c.subscriptions[0].status as SubscriptionStatus,
|
||||||
|
tokensUsed: 0, // Not included in list response
|
||||||
|
tokenLimit: 0, // Not included in list response
|
||||||
|
} : null,
|
||||||
|
activeServers: c._count?.orders || 0,
|
||||||
|
createdAt: String(c.createdAt),
|
||||||
|
}))
|
||||||
|
}, [data?.customers])
|
||||||
|
|
||||||
|
// Calculate stats from API data
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: data?.pagination?.total || 0,
|
||||||
|
active: customers.filter((c) => c.status === 'ACTIVE').length,
|
||||||
|
trial: customers.filter((c) => c.subscription?.status === 'TRIAL').length,
|
||||||
|
totalServers: customers.reduce((acc, c) => acc + c.activeServers, 0),
|
||||||
|
}), [customers, data?.pagination?.total])
|
||||||
|
|
||||||
|
const totalPages = data?.pagination?.totalPages || 1
|
||||||
|
|
||||||
|
// 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 customers...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[50vh]">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-destructive">Failed to load customers</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error instanceof Error ? error.message : 'An error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Customers</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage customer accounts and subscriptions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Customer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">{stats.total}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Customers</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">{stats.active}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Active</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">{stats.trial}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">On Trial</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-2xl font-bold">{stats.totalServers}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Servers</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>All Customers</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search customers..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setCurrentPage(1) // Reset to first page on search
|
||||||
|
}}
|
||||||
|
className="pl-9 w-full sm:w-64"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{customers.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<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">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t pt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { AdminSidebar } from '@/components/admin/sidebar'
|
||||||
|
import { AdminHeader } from '@/components/admin/header'
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!session) {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect if not a staff member
|
||||||
|
if (session.user.userType !== 'staff') {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<AdminSidebar />
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<AdminHeader />
|
||||||
|
<main className="flex-1 overflow-y-auto bg-gray-50 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,813 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect, useCallback } 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 { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useOrder, useUpdateOrder, useTriggerProvisioning } from '@/hooks/use-orders'
|
||||||
|
import { useProvisioningLogs, StreamedLog } from '@/hooks/use-provisioning-logs'
|
||||||
|
import { OrderStatus, SubscriptionTier, LogLevel } from '@/types/api'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Globe,
|
||||||
|
User,
|
||||||
|
Server,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
Terminal,
|
||||||
|
Zap,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Status badge component
|
||||||
|
function StatusBadge({ status }: { status: OrderStatus }) {
|
||||||
|
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||||
|
PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' },
|
||||||
|
AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
SERVER_READY: { label: 'Server Ready', className: 'bg-purple-100 text-purple-800' },
|
||||||
|
DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' },
|
||||||
|
DNS_READY: { label: 'DNS Ready', className: 'bg-cyan-100 text-cyan-800' },
|
||||||
|
PROVISIONING: { label: 'Provisioning', className: 'bg-indigo-100 text-indigo-800' },
|
||||||
|
FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' },
|
||||||
|
EMAIL_CONFIGURED: { label: 'Complete', className: 'bg-emerald-100 text-emerald-800' },
|
||||||
|
FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server credentials form component
|
||||||
|
function ServerCredentialsForm({
|
||||||
|
initialIp,
|
||||||
|
initialPort,
|
||||||
|
hasCredentials,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
initialIp?: string
|
||||||
|
initialPort?: number
|
||||||
|
hasCredentials: boolean
|
||||||
|
onSubmit: (ip: string, password: string, port: number) => void
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
const [ip, setIp] = useState(initialIp || '')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [port, setPort] = useState(String(initialPort || 22))
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
setTestResult(null)
|
||||||
|
// TODO: Call API to test SSH connection
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
setTestResult({ success: true, message: 'Connection successful! SSH version: OpenSSH_8.4' })
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(ip, password, parseInt(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
Server Credentials
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{hasCredentials
|
||||||
|
? 'Server credentials have been entered. You can update them if needed.'
|
||||||
|
: 'Enter the server credentials received from the hosting provider.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ip">Server IP Address</Label>
|
||||||
|
<Input
|
||||||
|
id="ip"
|
||||||
|
type="text"
|
||||||
|
placeholder="123.45.67.89"
|
||||||
|
value={ip}
|
||||||
|
onChange={(e) => setIp(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="port">SSH Port</Label>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
type="number"
|
||||||
|
placeholder="22"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Root Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Enter root password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required={!hasCredentials}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Password will be encrypted and deleted after provisioning completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-md p-3 ${
|
||||||
|
testResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.success ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm">{testResult.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={!ip || !password || testing}
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Test Connection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || !ip}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save & Mark Ready'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisioning logs component
|
||||||
|
function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
|
||||||
|
logs: Array<{
|
||||||
|
id: string
|
||||||
|
timestamp: Date
|
||||||
|
level: LogLevel
|
||||||
|
step: string | null
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
isLive: boolean
|
||||||
|
isConnected?: boolean
|
||||||
|
onReconnect?: () => void
|
||||||
|
}) {
|
||||||
|
const levelColors: Record<LogLevel, string> = {
|
||||||
|
INFO: 'text-blue-400',
|
||||||
|
WARN: 'text-yellow-400',
|
||||||
|
ERROR: 'text-red-400',
|
||||||
|
DEBUG: 'text-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
const d = new Date(date)
|
||||||
|
return d.toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Terminal className="h-5 w-5" />
|
||||||
|
Provisioning Logs
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Real-time output from the provisioning process</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isLive && (
|
||||||
|
<>
|
||||||
|
{isConnected ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex h-3 w-3">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Live</span>
|
||||||
|
<Wifi className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WifiOff className="h-4 w-4 text-red-500" />
|
||||||
|
<span className="text-sm text-red-500">Disconnected</span>
|
||||||
|
{onReconnect && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onReconnect}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isLive && logs.length > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{logs.length} log{logs.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-96 overflow-y-auto rounded-lg bg-gray-900 p-4 font-mono text-sm" id="log-container">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{isLive && isConnected ? 'Waiting for logs...' : 'No logs available yet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<div key={log.id} className="mb-2">
|
||||||
|
<span className="text-gray-500">[{formatTime(log.timestamp)}]</span>{' '}
|
||||||
|
<span className={levelColors[log.level]}>[{log.level}]</span>{' '}
|
||||||
|
{log.step && <span className="text-purple-400">[{log.step}]</span>}{' '}
|
||||||
|
<span className="text-gray-300">{log.message}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const orderId = params.id as string
|
||||||
|
|
||||||
|
// Fetch order data
|
||||||
|
const {
|
||||||
|
data: order,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = useOrder(orderId)
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateOrder = useUpdateOrder()
|
||||||
|
const triggerProvision = useTriggerProvisioning()
|
||||||
|
|
||||||
|
// Check if we should enable SSE streaming
|
||||||
|
const isProvisioning = order?.status === OrderStatus.PROVISIONING
|
||||||
|
|
||||||
|
// SSE for live log streaming
|
||||||
|
const {
|
||||||
|
logs: streamedLogs,
|
||||||
|
isConnected,
|
||||||
|
isComplete,
|
||||||
|
reconnect,
|
||||||
|
} = useProvisioningLogs({
|
||||||
|
orderId,
|
||||||
|
enabled: isProvisioning,
|
||||||
|
onStatusChange: useCallback((newStatus: OrderStatus) => {
|
||||||
|
// Refetch order data when status changes
|
||||||
|
refetch()
|
||||||
|
}, [refetch]),
|
||||||
|
onComplete: useCallback((success: boolean) => {
|
||||||
|
// Refetch order data when provisioning completes
|
||||||
|
refetch()
|
||||||
|
}, [refetch]),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
const tierLabel = useMemo(() => {
|
||||||
|
if (!order) return ''
|
||||||
|
return order.tier === SubscriptionTier.HUB_DASHBOARD ? 'Hub Dashboard' : 'Control Panel'
|
||||||
|
}, [order?.tier])
|
||||||
|
|
||||||
|
// Merge historical logs with streamed logs, avoiding duplicates
|
||||||
|
const allLogs = useMemo(() => {
|
||||||
|
const historicalLogs = order?.provisioningLogs || []
|
||||||
|
const historicalLogIds = new Set(historicalLogs.map(l => l.id))
|
||||||
|
|
||||||
|
// Add only new streamed logs that aren't in historical
|
||||||
|
const newStreamedLogs = streamedLogs.filter(l => !historicalLogIds.has(l.id))
|
||||||
|
|
||||||
|
// Combine and sort by timestamp
|
||||||
|
const combined = [
|
||||||
|
...historicalLogs.map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
level: l.level,
|
||||||
|
step: l.step,
|
||||||
|
message: l.message,
|
||||||
|
timestamp: new Date(l.timestamp),
|
||||||
|
})),
|
||||||
|
...newStreamedLogs,
|
||||||
|
]
|
||||||
|
|
||||||
|
return combined.sort(
|
||||||
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
)
|
||||||
|
}, [order?.provisioningLogs, streamedLogs])
|
||||||
|
|
||||||
|
// Auto-scroll logs to bottom when new logs come in
|
||||||
|
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) => {
|
||||||
|
await updateOrder.mutateAsync({
|
||||||
|
id: orderId,
|
||||||
|
data: {
|
||||||
|
serverIp: ip,
|
||||||
|
serverPassword: password,
|
||||||
|
sshPort: port,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTriggerProvisioning = async () => {
|
||||||
|
try {
|
||||||
|
await triggerProvision.mutateAsync(orderId)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to trigger provisioning:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 order 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 order</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error instanceof Error ? error.message : 'Order 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCredentialsForm = order.status === OrderStatus.AWAITING_SERVER || order.status === OrderStatus.SERVER_READY
|
||||||
|
const showProvisionButton = order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED
|
||||||
|
const showLogs = order.status === OrderStatus.PROVISIONING ||
|
||||||
|
order.status === OrderStatus.FULFILLED ||
|
||||||
|
order.status === OrderStatus.EMAIL_CONFIGURED ||
|
||||||
|
order.status === OrderStatus.FAILED
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin/orders">
|
||||||
|
<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={order.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">Order #{orderId.slice(0, 8)}...</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>
|
||||||
|
{showProvisionButton && (
|
||||||
|
<Button
|
||||||
|
onClick={handleTriggerProvisioning}
|
||||||
|
disabled={triggerProvision.isPending}
|
||||||
|
>
|
||||||
|
{triggerProvision.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
|
{order.status === OrderStatus.FAILED ? 'Retry Provisioning' : 'Start Provisioning'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{order.portainerUrl && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={order.portainerUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Portainer
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{order.dashboardUrl && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={order.dashboardUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Failure reason banner */}
|
||||||
|
{order.status === OrderStatus.FAILED && order.failureReason && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-800">Provisioning Failed</p>
|
||||||
|
<p className="text-sm text-red-700">{order.failureReason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order info cards */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<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">
|
||||||
|
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-2">
|
||||||
|
<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">
|
||||||
|
{tierLabel}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
Server
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Tools list */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Selected Tools</CardTitle>
|
||||||
|
<CardDescription>Tools to be deployed on this server</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{order.tools.map((tool) => (
|
||||||
|
<span
|
||||||
|
key={tool}
|
||||||
|
className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium capitalize"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Server credentials form (show for AWAITING_SERVER or SERVER_READY status) */}
|
||||||
|
{showCredentialsForm && (
|
||||||
|
<ServerCredentialsForm
|
||||||
|
initialIp={order.serverIp || undefined}
|
||||||
|
initialPort={order.sshPort}
|
||||||
|
hasCredentials={!!order.serverIp}
|
||||||
|
onSubmit={handleCredentialsSubmit}
|
||||||
|
isLoading={updateOrder.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Two column layout for timeline and logs */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<OrderTimeline
|
||||||
|
status={order.status}
|
||||||
|
timestamps={{
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
serverReadyAt: order.serverReadyAt,
|
||||||
|
provisioningStartedAt: order.provisioningStartedAt,
|
||||||
|
completedAt: order.completedAt,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Show logs for provisioning/completed/failed status */}
|
||||||
|
{showLogs && (
|
||||||
|
<ProvisioningLogs
|
||||||
|
logs={allLogs}
|
||||||
|
isLive={isProvisioning}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onReconnect={reconnect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Jobs history */}
|
||||||
|
{order.jobs && order.jobs.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Job History</CardTitle>
|
||||||
|
<CardDescription>Recent provisioning job attempts</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{order.jobs.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${
|
||||||
|
job.status === 'COMPLETED'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: job.status === 'FAILED'
|
||||||
|
? 'bg-red-500'
|
||||||
|
: job.status === 'RUNNING'
|
||||||
|
? 'bg-blue-500'
|
||||||
|
: 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Attempt {job.attempt} of {job.maxAttempts}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(job.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
job.status === 'COMPLETED'
|
||||||
|
? 'text-green-600'
|
||||||
|
: job.status === 'FAILED'
|
||||||
|
? 'text-red-600'
|
||||||
|
: job.status === 'RUNNING'
|
||||||
|
? 'text-blue-600'
|
||||||
|
: 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
{job.error && (
|
||||||
|
<p className="text-xs text-red-500 max-w-xs truncate">{job.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
OrderKanban,
|
||||||
|
OrderPipelineCompact,
|
||||||
|
} from '@/components/admin/order-kanban'
|
||||||
|
import { CreateOrderDialog } from '@/components/admin/create-order-dialog'
|
||||||
|
import type { Order as OrderCardType, OrderStatus, OrderTier } from '@/components/admin/order-card'
|
||||||
|
import { useOrders } from '@/hooks/use-orders'
|
||||||
|
import { OrderStatus as ApiOrderStatus, SubscriptionTier } from '@/types/api'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
RefreshCw,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// View modes
|
||||||
|
type ViewMode = 'kanban' | 'list'
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
interface FilterOptions {
|
||||||
|
search: string
|
||||||
|
tier: OrderTier | 'all'
|
||||||
|
status: OrderStatus | 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map API tier to component tier
|
||||||
|
function mapTier(tier: SubscriptionTier): OrderTier {
|
||||||
|
return tier === 'HUB_DASHBOARD' ? 'hub-dashboard' : 'control-panel'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map API tier back for filtering
|
||||||
|
function mapTierToApi(tier: OrderTier | 'all'): SubscriptionTier | undefined {
|
||||||
|
if (tier === 'all') return undefined
|
||||||
|
return tier === 'hub-dashboard' ? SubscriptionTier.HUB_DASHBOARD : SubscriptionTier.ADVANCED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map API order to component order format
|
||||||
|
function mapApiOrderToCardOrder(apiOrder: {
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
tier: SubscriptionTier
|
||||||
|
status: ApiOrderStatus
|
||||||
|
serverIp: string | null
|
||||||
|
failureReason: string | null
|
||||||
|
createdAt: Date | string
|
||||||
|
updatedAt: Date | string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
company: string | null
|
||||||
|
}
|
||||||
|
}): OrderCardType {
|
||||||
|
return {
|
||||||
|
id: apiOrder.id,
|
||||||
|
domain: apiOrder.domain,
|
||||||
|
customerName: apiOrder.user.name || apiOrder.user.company || apiOrder.user.email,
|
||||||
|
customerEmail: apiOrder.user.email,
|
||||||
|
tier: mapTier(apiOrder.tier),
|
||||||
|
status: apiOrder.status as OrderStatus,
|
||||||
|
createdAt: new Date(apiOrder.createdAt),
|
||||||
|
updatedAt: new Date(apiOrder.updatedAt),
|
||||||
|
serverIp: apiOrder.serverIp || undefined,
|
||||||
|
failureReason: apiOrder.failureReason || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('kanban')
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<FilterOptions>({
|
||||||
|
search: '',
|
||||||
|
tier: 'all',
|
||||||
|
status: 'all',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch orders from API
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = useOrders({
|
||||||
|
search: filters.search || undefined,
|
||||||
|
tier: mapTierToApi(filters.tier),
|
||||||
|
status: filters.status !== 'all' ? filters.status as ApiOrderStatus : undefined,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map API orders to component format
|
||||||
|
const orders = useMemo(() => {
|
||||||
|
if (!data?.orders) return []
|
||||||
|
return data.orders.map(mapApiOrderToCardOrder)
|
||||||
|
}, [data?.orders])
|
||||||
|
|
||||||
|
// Handle order action
|
||||||
|
const handleOrderAction = (order: OrderCardType, action: string) => {
|
||||||
|
console.log(`Action "${action}" triggered for order:`, order)
|
||||||
|
// Navigate to order detail page for actions
|
||||||
|
router.push(`/admin/orders/${order.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle view order details
|
||||||
|
const handleViewDetails = (order: OrderCardType) => {
|
||||||
|
router.push(`/admin/orders/${order.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle refresh
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle export
|
||||||
|
const handleExport = () => {
|
||||||
|
// TODO: Implement CSV export
|
||||||
|
console.log('Exporting orders...')
|
||||||
|
alert('Export functionality coming soon!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<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 orders...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<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 orders</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error instanceof Error ? error.message : 'An error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Order Pipeline</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage and track customer provisioning orders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setIsCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Order
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{/* Search and filters */}
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by domain, customer..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({ ...prev, search: e.target.value }))
|
||||||
|
}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier filter */}
|
||||||
|
<select
|
||||||
|
value={filters.tier}
|
||||||
|
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
|
||||||
|
variant={viewMode === 'kanban' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-r-none"
|
||||||
|
onClick={() => setViewMode('kanban')}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-l-none"
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters indicator */}
|
||||||
|
{(filters.search || filters.tier !== 'all' || filters.status !== 'all') && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Showing {orders.length} orders
|
||||||
|
{data?.pagination && ` of ${data.pagination.total}`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ search: '', tier: 'all', status: 'all' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{orders.length === 0 && (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground">No orders found</p>
|
||||||
|
{(filters.search || filters.tier !== 'all' || filters.status !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setFilters({ search: '', tier: 'all', status: 'all' })}
|
||||||
|
>
|
||||||
|
Clear filters to see all orders
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
{orders.length > 0 && (
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{viewMode === 'kanban' ? (
|
||||||
|
<OrderKanban
|
||||||
|
orders={orders}
|
||||||
|
onAction={handleOrderAction}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OrderPipelineCompact
|
||||||
|
orders={orders}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Order Dialog */}
|
||||||
|
<CreateOrderDialog
|
||||||
|
open={isCreateDialogOpen}
|
||||||
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
|
onSuccess={() => refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDashboardStats } from '@/hooks/use-stats'
|
||||||
|
import {
|
||||||
|
ShoppingCart,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Stats card component
|
||||||
|
function StatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
description: string
|
||||||
|
icon: React.ElementType
|
||||||
|
isLoading?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order status badge
|
||||||
|
function OrderStatusBadge({ status }: { status: string }) {
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
PAYMENT_CONFIRMED: 'bg-blue-100 text-blue-800',
|
||||||
|
AWAITING_SERVER: 'bg-yellow-100 text-yellow-800',
|
||||||
|
SERVER_READY: 'bg-purple-100 text-purple-800',
|
||||||
|
DNS_PENDING: 'bg-orange-100 text-orange-800',
|
||||||
|
DNS_READY: 'bg-cyan-100 text-cyan-800',
|
||||||
|
PROVISIONING: 'bg-indigo-100 text-indigo-800',
|
||||||
|
FULFILLED: 'bg-green-100 text-green-800',
|
||||||
|
EMAIL_CONFIGURED: 'bg-emerald-100 text-emerald-800',
|
||||||
|
FAILED: 'bg-red-100 text-red-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
PAYMENT_CONFIRMED: 'Payment Confirmed',
|
||||||
|
AWAITING_SERVER: 'Awaiting Server',
|
||||||
|
SERVER_READY: 'Server Ready',
|
||||||
|
DNS_PENDING: 'DNS Pending',
|
||||||
|
DNS_READY: 'DNS Ready',
|
||||||
|
PROVISIONING: 'Provisioning',
|
||||||
|
FULFILLED: 'Fulfilled',
|
||||||
|
EMAIL_CONFIGURED: 'Complete',
|
||||||
|
FAILED: 'Failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
statusStyles[status] || 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabels[status] || status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time since
|
||||||
|
function formatTimeSince(date: Date | string): string {
|
||||||
|
const now = new Date()
|
||||||
|
const then = new Date(date)
|
||||||
|
const diffMs = now.getTime() - then.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now'
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`
|
||||||
|
return then.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent orders component
|
||||||
|
function RecentOrders({ orders, isLoading }: {
|
||||||
|
orders: Array<{
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
status: string
|
||||||
|
createdAt: Date | string
|
||||||
|
user: { name: string | null; email: string; company: string | null }
|
||||||
|
}>
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Recent Orders</CardTitle>
|
||||||
|
<CardDescription>Latest customer provisioning orders</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/orders">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
View All <ArrowRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<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 className="h-6 w-20 bg-muted animate-pulse rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No orders yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<Link
|
||||||
|
key={order.id}
|
||||||
|
href={`/admin/orders/${order.id}`}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">{order.domain}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{order.user.name || order.user.company || order.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatTimeSince(order.createdAt)}
|
||||||
|
</span>
|
||||||
|
<OrderStatusBadge status={order.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline overview component
|
||||||
|
function PipelineOverview({ stats, isLoading }: {
|
||||||
|
stats: {
|
||||||
|
pending: number
|
||||||
|
inProgress: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
} | null
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
const stages = [
|
||||||
|
{ name: 'Payment & Server', count: stats?.pending || 0, icon: Clock, color: 'text-yellow-500' },
|
||||||
|
{ name: 'Provisioning', count: stats?.inProgress || 0, icon: TrendingUp, color: 'text-indigo-500' },
|
||||||
|
{ name: 'Completed', count: stats?.completed || 0, icon: CheckCircle, color: 'text-green-500' },
|
||||||
|
{ name: 'Failed', count: stats?.failed || 0, icon: AlertCircle, color: 'text-red-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Order Pipeline</CardTitle>
|
||||||
|
<CardDescription>Orders by current stage</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<div key={stage.name} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<stage.icon className={`h-5 w-5 ${stage.color}`} />
|
||||||
|
<span className="text-sm">{stage.name}</span>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-5 w-8 bg-muted animate-pulse rounded" />
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{stage.count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<Link href="/admin/orders">
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
View Pipeline
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const { data: stats, isLoading, isError, refetch, isFetching } = useDashboardStats()
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[50vh]">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-destructive">Failed to load dashboard</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Could not fetch statistics</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Overview of your LetsBe Hub platform
|
||||||
|
</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 grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatsCard
|
||||||
|
title="Total Orders"
|
||||||
|
value={stats?.orders.total || 0}
|
||||||
|
description="All time orders"
|
||||||
|
icon={ShoppingCart}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Active Customers"
|
||||||
|
value={stats?.customers.active || 0}
|
||||||
|
description="Verified customers"
|
||||||
|
icon={Users}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Completed Deployments"
|
||||||
|
value={stats?.orders.completed || 0}
|
||||||
|
description="Successfully provisioned"
|
||||||
|
icon={Server}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Pending Actions"
|
||||||
|
value={stats?.orders.pending || 0}
|
||||||
|
description="Orders needing attention"
|
||||||
|
icon={Clock}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content grid */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<RecentOrders
|
||||||
|
orders={stats?.recentOrders || []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
<PipelineOverview
|
||||||
|
stats={stats?.orders ? {
|
||||||
|
pending: stats.orders.pending,
|
||||||
|
inProgress: stats.orders.inProgress,
|
||||||
|
completed: stats.orders.completed,
|
||||||
|
failed: stats.orders.failed,
|
||||||
|
} : null}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useServers, ServerStatus } from '@/hooks/use-servers'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
Globe,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Cpu,
|
||||||
|
Package,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Status badge component
|
||||||
|
function ServerStatusBadge({ status }: { status: ServerStatus }) {
|
||||||
|
const config: Record<ServerStatus, { label: string; className: string; icon: React.ReactNode }> = {
|
||||||
|
online: {
|
||||||
|
label: 'Online',
|
||||||
|
className: 'bg-green-100 text-green-800',
|
||||||
|
icon: <CheckCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
provisioning: {
|
||||||
|
label: 'Provisioning',
|
||||||
|
className: 'bg-blue-100 text-blue-800',
|
||||||
|
icon: <Clock className="h-3 w-3 animate-pulse" />,
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
label: 'Offline',
|
||||||
|
className: 'bg-red-100 text-red-800',
|
||||||
|
icon: <XCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: 'Pending',
|
||||||
|
className: 'bg-yellow-100 text-yellow-800',
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = config[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${statusConfig.className}`}>
|
||||||
|
{statusConfig.icon}
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server card component
|
||||||
|
function ServerCard({ server }: { server: {
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
tier: string
|
||||||
|
serverStatus: ServerStatus
|
||||||
|
serverIp: string
|
||||||
|
sshPort: number
|
||||||
|
tools: string[]
|
||||||
|
createdAt: Date | string
|
||||||
|
customer: {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
company: string | null
|
||||||
|
}
|
||||||
|
}}) {
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
|
||||||
|
server.serverStatus === 'online' ? 'bg-green-100' :
|
||||||
|
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 className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{server.domain}</h3>
|
||||||
|
<ServerStatusBadge status={server.serverStatus} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground font-mono">{server.serverIp}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/admin/orders/${server.id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{server.customer.name || server.customer.email}</p>
|
||||||
|
{server.customer.company && (
|
||||||
|
<p className="text-xs text-muted-foreground">{server.customer.company}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="capitalize">{server.tier.replace('_', ' ').toLowerCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>SSH Port: {server.sshPort}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{new Date(server.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Tools ({server.tools.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{server.tools.map((tool) => (
|
||||||
|
<span
|
||||||
|
key={tool}
|
||||||
|
className="px-2 py-0.5 text-xs bg-gray-100 rounded-full"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServersPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ServerStatus | 'all'>('all')
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const itemsPerPage = 12
|
||||||
|
|
||||||
|
// Fetch servers from API
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = useServers({
|
||||||
|
search: search || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
page: currentPage,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate stats from data
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const servers = data?.servers || []
|
||||||
|
return {
|
||||||
|
total: data?.pagination?.total || 0,
|
||||||
|
online: servers.filter((s) => s.serverStatus === 'online').length,
|
||||||
|
provisioning: servers.filter((s) => s.serverStatus === 'provisioning').length,
|
||||||
|
offline: servers.filter((s) => s.serverStatus === 'offline').length,
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const totalPages = data?.pagination?.totalPages || 1
|
||||||
|
|
||||||
|
// 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 servers...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[50vh]">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-destructive">Failed to load servers</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{error instanceof Error ? error.message : 'An error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Servers</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage deployed infrastructure servers
|
||||||
|
</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="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-8 w-8 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">{stats.provisioning}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Provisioning</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>All Servers</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{data?.pagination?.total || 0} server{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by domain, IP..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
className="pl-9 w-full sm:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value as ServerStatus | 'all')
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="provisioning">Provisioning</option>
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data?.servers && data.servers.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Server grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.servers.map((server) => (
|
||||||
|
<ServerCard key={server.id} server={server} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { handlers } from '@/lib/auth'
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { UserStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/customers/[id]
|
||||||
|
* Get customer details with orders and subscriptions
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: customerId } = await params
|
||||||
|
|
||||||
|
const customer = await prisma.user.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
include: {
|
||||||
|
subscriptions: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { provisioningLogs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokenUsage: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
orders: true,
|
||||||
|
subscriptions: true,
|
||||||
|
tokenUsage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total token usage
|
||||||
|
const totalTokensUsed = customer.tokenUsage.reduce(
|
||||||
|
(acc, usage) => acc + usage.tokensInput + usage.tokensOutput,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get current subscription's token limit
|
||||||
|
const currentSubscription = customer.subscriptions[0]
|
||||||
|
const tokenLimit = currentSubscription?.tokenLimit || 0
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...customer,
|
||||||
|
totalTokensUsed,
|
||||||
|
tokenLimit,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting customer:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get customer' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/admin/customers/[id]
|
||||||
|
* Update customer details
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: customerId } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Validate customer exists
|
||||||
|
const existingCustomer = await prisma.user.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingCustomer) {
|
||||||
|
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData: {
|
||||||
|
name?: string
|
||||||
|
company?: string
|
||||||
|
status?: UserStatus
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
updateData.name = body.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.company !== undefined) {
|
||||||
|
updateData.company = body.company
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
updateData.status = body.status as UserStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await prisma.user.update({
|
||||||
|
where: { id: customerId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
subscriptions: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
orders: true,
|
||||||
|
subscriptions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(customer)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update customer' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { UserStatus, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/customers
|
||||||
|
* List all customers with optional filters
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const status = searchParams.get('status') as UserStatus | null
|
||||||
|
const search = searchParams.get('search')
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50')
|
||||||
|
|
||||||
|
const where: Prisma.UserWhereInput = {}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ email: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ company: { contains: search, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [customers, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
company: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
subscriptions: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plan: true,
|
||||||
|
tier: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
orders: true,
|
||||||
|
subscriptions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
customers,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing customers:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list customers' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OrderStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]/logs/stream
|
||||||
|
* Stream provisioning logs via Server-Sent Events
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return new Response('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: orderId } = await params
|
||||||
|
|
||||||
|
// Verify order exists
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return new Response('Order not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SSE response
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
let lastLogId: string | null = null
|
||||||
|
let isActive = true
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
// Send initial connection message
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Poll for new logs
|
||||||
|
const poll = async () => {
|
||||||
|
if (!isActive) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current order status
|
||||||
|
const currentOrder = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentOrder) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Order not found' })}\n\n`)
|
||||||
|
)
|
||||||
|
controller.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query for new logs
|
||||||
|
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) {
|
||||||
|
// Update last seen log ID
|
||||||
|
lastLogId = newLogs[newLogs.length - 1].id
|
||||||
|
|
||||||
|
// Send each log as an event
|
||||||
|
for (const log of newLogs) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: log\ndata: ${JSON.stringify({
|
||||||
|
id: log.id,
|
||||||
|
level: log.level,
|
||||||
|
step: log.step,
|
||||||
|
message: log.message,
|
||||||
|
timestamp: log.timestamp.toISOString(),
|
||||||
|
})}\n\n`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send status update
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if provisioning is complete
|
||||||
|
const terminalStatuses: OrderStatus[] = [
|
||||||
|
OrderStatus.FULFILLED,
|
||||||
|
OrderStatus.EMAIL_CONFIGURED,
|
||||||
|
OrderStatus.FAILED,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (terminalStatuses.includes(currentOrder.status)) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: complete\ndata: ${JSON.stringify({
|
||||||
|
status: currentOrder.status,
|
||||||
|
success: currentOrder.status !== OrderStatus.FAILED
|
||||||
|
})}\n\n`)
|
||||||
|
)
|
||||||
|
controller.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue polling if still provisioning
|
||||||
|
setTimeout(poll, 2000) // Poll every 2 seconds
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSE polling error:', err)
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Polling error' })}\n\n`)
|
||||||
|
)
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
poll()
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
isActive = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up SSE stream:', error)
|
||||||
|
return new Response('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OrderStatus } from '@prisma/client'
|
||||||
|
import { jobService } from '@/lib/services/job-service'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders/[id]/provision
|
||||||
|
* Trigger provisioning 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
|
||||||
|
|
||||||
|
// Check if order exists and is ready for provisioning
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate order status - can only provision from DNS_READY or FAILED
|
||||||
|
const validStatuses: OrderStatus[] = [OrderStatus.DNS_READY, OrderStatus.FAILED]
|
||||||
|
if (!validStatuses.includes(order.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Cannot provision order in status ${order.status}. Must be DNS_READY or FAILED.`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate server credentials
|
||||||
|
if (!order.serverIp || !order.serverPasswordEncrypted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server credentials not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create provisioning job
|
||||||
|
const result = await jobService.createJobForOrder(orderId)
|
||||||
|
const { jobId } = JSON.parse(result)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Provisioning job created',
|
||||||
|
jobId,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error triggering provisioning:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to trigger provisioning' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OrderStatus } from '@prisma/client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders/[id]
|
||||||
|
* Get order details
|
||||||
|
*/
|
||||||
|
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 },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provisioningLogs: {
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
attempt: true,
|
||||||
|
maxAttempts: true,
|
||||||
|
createdAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
error: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(order)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting order:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get order' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/admin/orders/[id]
|
||||||
|
* Update order (status, server credentials, etc.)
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Find existing order
|
||||||
|
const existingOrder = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingOrder) {
|
||||||
|
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data
|
||||||
|
const updateData: {
|
||||||
|
status?: OrderStatus
|
||||||
|
serverIp?: string
|
||||||
|
serverPasswordEncrypted?: string
|
||||||
|
sshPort?: number
|
||||||
|
serverReadyAt?: Date
|
||||||
|
failureReason?: string
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
// Handle status update
|
||||||
|
if (body.status) {
|
||||||
|
updateData.status = body.status as OrderStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle server credentials
|
||||||
|
if (body.serverIp) {
|
||||||
|
updateData.serverIp = body.serverIp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.serverPassword) {
|
||||||
|
// Encrypt the password before storing
|
||||||
|
// TODO: Use proper encryption with environment-based key
|
||||||
|
const encrypted = encryptPassword(body.serverPassword)
|
||||||
|
updateData.serverPasswordEncrypted = encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.sshPort) {
|
||||||
|
updateData.sshPort = body.sshPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server credentials are being set and status is AWAITING_SERVER, move to SERVER_READY
|
||||||
|
if (
|
||||||
|
(body.serverIp || body.serverPassword) &&
|
||||||
|
existingOrder.status === OrderStatus.AWAITING_SERVER
|
||||||
|
) {
|
||||||
|
updateData.status = OrderStatus.SERVER_READY
|
||||||
|
updateData.serverReadyAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order
|
||||||
|
const order = await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(order)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating order:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update order' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to encrypt password
|
||||||
|
function encryptPassword(password: string): string {
|
||||||
|
// TODO: Implement proper encryption using environment-based key
|
||||||
|
// For now, use a simple encryption for development
|
||||||
|
const key = crypto.scryptSync(
|
||||||
|
process.env.ENCRYPTION_KEY || 'dev-key-change-in-production',
|
||||||
|
'salt',
|
||||||
|
32
|
||||||
|
)
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
|
||||||
|
let encrypted = cipher.update(password, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
return iv.toString('hex') + ':' + encrypted
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OrderStatus, SubscriptionTier, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/orders
|
||||||
|
* List all orders with optional filters
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
// Check authentication and authorization
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const status = searchParams.get('status') as OrderStatus | null
|
||||||
|
const tier = searchParams.get('tier')
|
||||||
|
const search = searchParams.get('search')
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50')
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: Prisma.OrderWhereInput = {}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tier && Object.values(SubscriptionTier).includes(tier as SubscriptionTier)) {
|
||||||
|
where.tier = tier as SubscriptionTier
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ domain: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ user: { email: { contains: search, mode: 'insensitive' } } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get orders with pagination
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { provisioningLogs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing orders:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list orders' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/orders
|
||||||
|
* Create a new order (admin-initiated)
|
||||||
|
*/
|
||||||
|
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 { userId, domain, tier, tools } = body
|
||||||
|
|
||||||
|
if (!userId || !domain || !tier || !tools) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'userId, domain, tier, and tools are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user exists
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
domain,
|
||||||
|
tier,
|
||||||
|
tools,
|
||||||
|
status: OrderStatus.PAYMENT_CONFIRMED,
|
||||||
|
configJson: { tools, tier, domain },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(order, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating order:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create order' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OrderStatus, Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/servers
|
||||||
|
* List all servers (orders with server credentials)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
const search = searchParams.get('search')
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50')
|
||||||
|
|
||||||
|
// Build where clause - only orders with serverIp are considered servers
|
||||||
|
const where: Prisma.OrderWhereInput = {
|
||||||
|
serverIp: { not: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by server status (derived from order status)
|
||||||
|
if (status === 'online') {
|
||||||
|
where.status = { in: [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] }
|
||||||
|
} else if (status === 'provisioning') {
|
||||||
|
where.status = { in: [OrderStatus.PROVISIONING, OrderStatus.DNS_READY, OrderStatus.SERVER_READY] }
|
||||||
|
} else if (status === 'offline') {
|
||||||
|
where.status = OrderStatus.FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by domain or serverIp
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ domain: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ serverIp: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ user: { name: { contains: search, mode: 'insensitive' } } },
|
||||||
|
{ user: { company: { contains: search, mode: 'insensitive' } } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
domain: true,
|
||||||
|
tier: true,
|
||||||
|
status: true,
|
||||||
|
serverIp: true,
|
||||||
|
sshPort: true,
|
||||||
|
tools: true,
|
||||||
|
createdAt: true,
|
||||||
|
serverReadyAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Map orders to server format
|
||||||
|
const servers = orders.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
domain: order.domain,
|
||||||
|
tier: order.tier,
|
||||||
|
orderStatus: order.status,
|
||||||
|
serverStatus: deriveServerStatus(order.status),
|
||||||
|
serverIp: order.serverIp,
|
||||||
|
sshPort: order.sshPort,
|
||||||
|
tools: order.tools,
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
serverReadyAt: order.serverReadyAt,
|
||||||
|
completedAt: order.completedAt,
|
||||||
|
customer: order.user,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
servers,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing servers:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list servers' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveServerStatus(orderStatus: OrderStatus): 'online' | 'provisioning' | 'offline' | 'pending' {
|
||||||
|
switch (orderStatus) {
|
||||||
|
case OrderStatus.FULFILLED:
|
||||||
|
case OrderStatus.EMAIL_CONFIGURED:
|
||||||
|
return 'online'
|
||||||
|
case OrderStatus.PROVISIONING:
|
||||||
|
case OrderStatus.DNS_READY:
|
||||||
|
case OrderStatus.SERVER_READY:
|
||||||
|
return 'provisioning'
|
||||||
|
case OrderStatus.FAILED:
|
||||||
|
return 'offline'
|
||||||
|
default:
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { OrderStatus, SubscriptionPlan, SubscriptionTier, UserStatus, SubscriptionStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/stats
|
||||||
|
* Get dashboard statistics
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.userType !== 'staff') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order counts by status
|
||||||
|
const ordersByStatus = await prisma.order.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderStatusCounts: Record<OrderStatus, number> = Object.fromEntries(
|
||||||
|
Object.values(OrderStatus).map((status) => [status, 0])
|
||||||
|
) as Record<OrderStatus, number>
|
||||||
|
|
||||||
|
ordersByStatus.forEach((item) => {
|
||||||
|
orderStatusCounts[item.status] = item._count.status
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate order statistics
|
||||||
|
const pendingStatuses = [
|
||||||
|
OrderStatus.PAYMENT_CONFIRMED,
|
||||||
|
OrderStatus.AWAITING_SERVER,
|
||||||
|
OrderStatus.SERVER_READY,
|
||||||
|
OrderStatus.DNS_PENDING,
|
||||||
|
OrderStatus.DNS_READY,
|
||||||
|
]
|
||||||
|
const inProgressStatuses = [OrderStatus.PROVISIONING]
|
||||||
|
const completedStatuses = [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED]
|
||||||
|
const failedStatuses = [OrderStatus.FAILED]
|
||||||
|
|
||||||
|
const ordersPending = pendingStatuses.reduce(
|
||||||
|
(sum, status) => sum + orderStatusCounts[status],
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const ordersInProgress = inProgressStatuses.reduce(
|
||||||
|
(sum, status) => sum + orderStatusCounts[status],
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const ordersCompleted = completedStatuses.reduce(
|
||||||
|
(sum, status) => sum + orderStatusCounts[status],
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const ordersFailed = failedStatuses.reduce(
|
||||||
|
(sum, status) => sum + orderStatusCounts[status],
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get customer counts by status
|
||||||
|
const customersByStatus = await prisma.user.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const customerStatusCounts: Record<UserStatus, number> = Object.fromEntries(
|
||||||
|
Object.values(UserStatus).map((status) => [status, 0])
|
||||||
|
) as Record<UserStatus, number>
|
||||||
|
|
||||||
|
customersByStatus.forEach((item) => {
|
||||||
|
customerStatusCounts[item.status] = item._count.status
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get subscription counts
|
||||||
|
const subscriptionsByPlan = await prisma.subscription.groupBy({
|
||||||
|
by: ['plan'],
|
||||||
|
_count: { plan: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscriptionsByTier = await prisma.subscription.groupBy({
|
||||||
|
by: ['tier'],
|
||||||
|
_count: { tier: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscriptionsByStatusRaw = await prisma.subscription.groupBy({
|
||||||
|
by: ['status'],
|
||||||
|
_count: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const planCounts: Record<SubscriptionPlan, number> = Object.fromEntries(
|
||||||
|
Object.values(SubscriptionPlan).map((plan) => [plan, 0])
|
||||||
|
) as Record<SubscriptionPlan, number>
|
||||||
|
|
||||||
|
subscriptionsByPlan.forEach((item) => {
|
||||||
|
planCounts[item.plan] = item._count.plan
|
||||||
|
})
|
||||||
|
|
||||||
|
const tierCounts: Record<SubscriptionTier, number> = Object.fromEntries(
|
||||||
|
Object.values(SubscriptionTier).map((tier) => [tier, 0])
|
||||||
|
) as Record<SubscriptionTier, number>
|
||||||
|
|
||||||
|
subscriptionsByTier.forEach((item) => {
|
||||||
|
tierCounts[item.tier] = item._count.tier
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscriptionStatusCounts: Record<SubscriptionStatus, number> = Object.fromEntries(
|
||||||
|
Object.values(SubscriptionStatus).map((status) => [status, 0])
|
||||||
|
) as Record<SubscriptionStatus, number>
|
||||||
|
|
||||||
|
subscriptionsByStatusRaw.forEach((item) => {
|
||||||
|
subscriptionStatusCounts[item.status] = item._count.status
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get recent orders
|
||||||
|
const recentOrders = await prisma.order.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { provisioningLogs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const ordersTotal = Object.values(orderStatusCounts).reduce((a, b) => a + b, 0)
|
||||||
|
const customersTotal = Object.values(customerStatusCounts).reduce((a, b) => a + b, 0)
|
||||||
|
const subscriptionsTotal = Object.values(planCounts).reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: {
|
||||||
|
total: ordersTotal,
|
||||||
|
pending: ordersPending,
|
||||||
|
inProgress: ordersInProgress,
|
||||||
|
completed: ordersCompleted,
|
||||||
|
failed: ordersFailed,
|
||||||
|
byStatus: orderStatusCounts,
|
||||||
|
},
|
||||||
|
customers: {
|
||||||
|
total: customersTotal,
|
||||||
|
active: customerStatusCounts[UserStatus.ACTIVE],
|
||||||
|
suspended: customerStatusCounts[UserStatus.SUSPENDED],
|
||||||
|
pending: customerStatusCounts[UserStatus.PENDING_VERIFICATION],
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
total: subscriptionsTotal,
|
||||||
|
trial: subscriptionStatusCounts[SubscriptionStatus.TRIAL],
|
||||||
|
active: subscriptionStatusCounts[SubscriptionStatus.ACTIVE],
|
||||||
|
byPlan: planCounts,
|
||||||
|
byTier: tierCounts,
|
||||||
|
},
|
||||||
|
recentOrders,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting dashboard stats:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get dashboard stats' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { jobService } from '@/lib/services/job-service'
|
||||||
|
|
||||||
|
// Verify runner token from header
|
||||||
|
async function verifyRunnerAuth(
|
||||||
|
request: NextRequest,
|
||||||
|
jobId: string
|
||||||
|
): Promise<{ authorized: boolean; error?: string }> {
|
||||||
|
const runnerToken = request.headers.get('X-Runner-Token')
|
||||||
|
|
||||||
|
if (!runnerToken) {
|
||||||
|
return { authorized: false, error: 'Missing X-Runner-Token header' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await jobService.verifyRunnerToken(jobId, runnerToken)
|
||||||
|
if (!isValid) {
|
||||||
|
return { authorized: false, error: 'Invalid runner token' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authorized: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/jobs/[id]/logs
|
||||||
|
* Get job logs
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: jobId } = await params
|
||||||
|
|
||||||
|
const auth = await verifyRunnerAuth(request, jobId)
|
||||||
|
if (!auth.authorized) {
|
||||||
|
return NextResponse.json({ error: auth.error }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: filter logs since a timestamp
|
||||||
|
const sinceParam = request.nextUrl.searchParams.get('since')
|
||||||
|
const since = sinceParam ? new Date(sinceParam) : undefined
|
||||||
|
|
||||||
|
const logs = await jobService.getLogs(jobId, since)
|
||||||
|
|
||||||
|
return NextResponse.json({ logs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting job logs:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get job logs' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/jobs/[id]/logs
|
||||||
|
* Add a log entry
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: jobId } = await params
|
||||||
|
|
||||||
|
const auth = await verifyRunnerAuth(request, jobId)
|
||||||
|
if (!auth.authorized) {
|
||||||
|
return NextResponse.json({ error: auth.error }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { level, message, step, progress } = body
|
||||||
|
|
||||||
|
if (!level || !message) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'level and message are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['info', 'warn', 'error'].includes(level)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'level must be info, warn, or error' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await jobService.addLog(
|
||||||
|
jobId,
|
||||||
|
level as 'info' | 'warn' | 'error',
|
||||||
|
message,
|
||||||
|
step,
|
||||||
|
progress
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding job log:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to add job log' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { jobService } from '@/lib/services/job-service'
|
||||||
|
|
||||||
|
// Verify runner token from header
|
||||||
|
async function verifyRunnerAuth(
|
||||||
|
request: NextRequest,
|
||||||
|
jobId: string
|
||||||
|
): Promise<{ authorized: boolean; error?: string }> {
|
||||||
|
const runnerToken = request.headers.get('X-Runner-Token')
|
||||||
|
|
||||||
|
if (!runnerToken) {
|
||||||
|
return { authorized: false, error: 'Missing X-Runner-Token header' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await jobService.verifyRunnerToken(jobId, runnerToken)
|
||||||
|
if (!isValid) {
|
||||||
|
return { authorized: false, error: 'Invalid runner token' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authorized: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/jobs/[id]
|
||||||
|
* Get job status
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: jobId } = await params
|
||||||
|
|
||||||
|
const auth = await verifyRunnerAuth(request, jobId)
|
||||||
|
if (!auth.authorized) {
|
||||||
|
return NextResponse.json({ error: auth.error }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await jobService.getJobStatus(jobId)
|
||||||
|
if (!status) {
|
||||||
|
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(status)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting job status:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get job status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/jobs/[id]
|
||||||
|
* Update job status (complete or fail)
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: jobId } = await params
|
||||||
|
|
||||||
|
const auth = await verifyRunnerAuth(request, jobId)
|
||||||
|
if (!auth.authorized) {
|
||||||
|
return NextResponse.json({ error: auth.error }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { status, error, result } = body
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
await jobService.completeJob(jobId, result)
|
||||||
|
return NextResponse.json({ success: true, message: 'Job completed successfully' })
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
if (!error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error message required for failed status' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const retryInfo = await jobService.failJob(jobId, error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
willRetry: retryInfo.willRetry,
|
||||||
|
nextRetryAt: retryInfo.nextRetryAt?.toISOString(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid status. Must be "completed" or "failed"' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating job status:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update job status' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
import { Providers } from '@/components/providers'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'LetsBe Hub',
|
||||||
|
description: 'Central platform for LetsBe Cloud management',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.userType === 'staff') {
|
||||||
|
redirect('/admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer users get redirected to their dashboard (future)
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,473 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useCreateOrder } from '@/hooks/use-orders'
|
||||||
|
import { useCustomers } from '@/hooks/use-customers'
|
||||||
|
import { SubscriptionTier } from '@/types/api'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
Globe,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface CreateOrderDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'customer' | 'domain' | 'tier' | 'tools' | 'review'
|
||||||
|
|
||||||
|
const TOOLS_BY_TIER: Record<SubscriptionTier, string[]> = {
|
||||||
|
HUB_DASHBOARD: ['nextcloud', 'keycloak', 'minio', 'poste'],
|
||||||
|
ADVANCED: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser', 'portainer', 'grafana'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_LABELS: Record<string, { name: string; description: string }> = {
|
||||||
|
nextcloud: { name: 'Nextcloud', description: 'File sync & collaboration' },
|
||||||
|
keycloak: { name: 'Keycloak', description: 'Identity & access management' },
|
||||||
|
minio: { name: 'MinIO', description: 'S3-compatible object storage' },
|
||||||
|
poste: { name: 'Poste.io', description: 'Email server' },
|
||||||
|
n8n: { name: 'n8n', description: 'Workflow automation' },
|
||||||
|
filebrowser: { name: 'File Browser', description: 'Web-based file manager' },
|
||||||
|
portainer: { name: 'Portainer', description: 'Container management' },
|
||||||
|
grafana: { name: 'Grafana', description: 'Monitoring dashboards' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateOrderDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateOrderDialogProps) {
|
||||||
|
const [step, setStep] = useState<Step>('customer')
|
||||||
|
const [customerSearch, setCustomerSearch] = useState('')
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState<{
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
company: string | null
|
||||||
|
} | null>(null)
|
||||||
|
const [domain, setDomain] = useState('')
|
||||||
|
const [tier, setTier] = useState<SubscriptionTier>(SubscriptionTier.HUB_DASHBOARD)
|
||||||
|
const [selectedTools, setSelectedTools] = useState<string[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: customersData, isLoading: isLoadingCustomers } = useCustomers({
|
||||||
|
search: customerSearch || undefined,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createOrder = useCreateOrder()
|
||||||
|
|
||||||
|
// Reset state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setStep('customer')
|
||||||
|
setCustomerSearch('')
|
||||||
|
setSelectedCustomer(null)
|
||||||
|
setDomain('')
|
||||||
|
setTier(SubscriptionTier.HUB_DASHBOARD)
|
||||||
|
setSelectedTools([])
|
||||||
|
setError(null)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Auto-select default tools when tier changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedTools(TOOLS_BY_TIER[tier])
|
||||||
|
}, [tier])
|
||||||
|
|
||||||
|
const validateDomain = (domain: string): boolean => {
|
||||||
|
const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
|
return domainRegex.test(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (step === 'customer') {
|
||||||
|
if (!selectedCustomer) {
|
||||||
|
setError('Please select a customer')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStep('domain')
|
||||||
|
} else if (step === 'domain') {
|
||||||
|
if (!domain) {
|
||||||
|
setError('Please enter a domain name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!validateDomain(domain)) {
|
||||||
|
setError('Please enter a valid domain name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStep('tier')
|
||||||
|
} else if (step === 'tier') {
|
||||||
|
setStep('tools')
|
||||||
|
} else if (step === 'tools') {
|
||||||
|
if (selectedTools.length === 0) {
|
||||||
|
setError('Please select at least one tool')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStep('review')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setError(null)
|
||||||
|
if (step === 'domain') setStep('customer')
|
||||||
|
else if (step === 'tier') setStep('domain')
|
||||||
|
else if (step === 'tools') setStep('tier')
|
||||||
|
else if (step === 'review') setStep('tools')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedCustomer) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createOrder.mutateAsync({
|
||||||
|
userId: selectedCustomer.id,
|
||||||
|
domain,
|
||||||
|
tier,
|
||||||
|
tools: selectedTools,
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create order')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTool = (tool: string) => {
|
||||||
|
setSelectedTools((prev) =>
|
||||||
|
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: { key: Step; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ key: 'customer', label: 'Customer', icon: <User className="h-4 w-4" /> },
|
||||||
|
{ key: 'domain', label: 'Domain', icon: <Globe className="h-4 w-4" /> },
|
||||||
|
{ key: 'tier', label: 'Tier', icon: <Layers className="h-4 w-4" /> },
|
||||||
|
{ key: 'tools', label: 'Tools', icon: <Package className="h-4 w-4" /> },
|
||||||
|
{ key: 'review', label: 'Review', icon: <Check className="h-4 w-4" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentStepIndex = steps.findIndex((s) => s.key === step)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Order</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new infrastructure order for a customer
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Steps indicator */}
|
||||||
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
|
{steps.map((s, index) => (
|
||||||
|
<div key={s.key} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center h-8 w-8 rounded-full border-2 transition-colors ${
|
||||||
|
index <= currentStepIndex
|
||||||
|
? 'border-primary bg-primary text-white'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.icon}
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`w-12 h-0.5 mx-2 ${
|
||||||
|
index < currentStepIndex ? 'bg-primary' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="min-h-[300px] py-4">
|
||||||
|
{step === 'customer' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Search Customer</Label>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name, email, or company..."
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||||
|
{isLoadingCustomers ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : customersData?.customers && customersData.customers.length > 0 ? (
|
||||||
|
customersData.customers.map((customer) => (
|
||||||
|
<button
|
||||||
|
key={customer.id}
|
||||||
|
onClick={() => setSelectedCustomer(customer)}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||||
|
selectedCustomer?.id === customer.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<User className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium">{customer.name || customer.email}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{customer.email}</span>
|
||||||
|
{customer.company && (
|
||||||
|
<>
|
||||||
|
<span>-</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Building2 className="h-3 w-3" />
|
||||||
|
{customer.company}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedCustomer?.id === customer.id && (
|
||||||
|
<Check className="h-5 w-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{customerSearch ? 'No customers found' : 'Start typing to search customers'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'domain' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="domain">Domain Name</Label>
|
||||||
|
<Input
|
||||||
|
id="domain"
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value.toLowerCase())}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Enter the primary domain for this deployment. Services will be configured as subdomains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCustomer && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm font-medium">Customer</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedCustomer.name || selectedCustomer.email}
|
||||||
|
{selectedCustomer.company && ` - ${selectedCustomer.company}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'tier' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Select Tier</Label>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setTier(SubscriptionTier.HUB_DASHBOARD)}
|
||||||
|
className={`p-4 rounded-lg border-2 text-left transition-colors ${
|
||||||
|
tier === SubscriptionTier.HUB_DASHBOARD
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Hub Dashboard</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Essential tools: Nextcloud, Keycloak, MinIO, Poste
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{tier === SubscriptionTier.HUB_DASHBOARD && (
|
||||||
|
<Check className="h-5 w-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTier(SubscriptionTier.ADVANCED)}
|
||||||
|
className={`p-4 rounded-lg border-2 text-left transition-colors ${
|
||||||
|
tier === SubscriptionTier.ADVANCED
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Advanced</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
All tools including n8n, File Browser, Portainer, Grafana
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{tier === SubscriptionTier.ADVANCED && (
|
||||||
|
<Check className="h-5 w-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'tools' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Select Tools</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose which tools to deploy. All tools are pre-selected based on your tier.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 max-h-64 overflow-y-auto">
|
||||||
|
{TOOLS_BY_TIER[tier].map((tool) => {
|
||||||
|
const toolInfo = TOOL_LABELS[tool]
|
||||||
|
const isSelected = selectedTools.includes(tool)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool}
|
||||||
|
onClick={() => toggleTool(tool)}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-5 w-5 rounded border-2 flex items-center justify-center ${
|
||||||
|
isSelected ? 'border-primary bg-primary' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && <Check className="h-3 w-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="font-medium">{toolInfo.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{toolInfo.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'review' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Customer</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedCustomer?.name || selectedCustomer?.email}
|
||||||
|
{selectedCustomer?.company && ` - ${selectedCustomer.company}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Domain</p>
|
||||||
|
<p className="font-medium">{domain}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Tier</p>
|
||||||
|
<p className="font-medium capitalize">
|
||||||
|
{tier.replace('_', ' ').toLowerCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Tools ({selectedTools.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{selectedTools.map((tool) => (
|
||||||
|
<span
|
||||||
|
key={tool}
|
||||||
|
className="px-2 py-1 text-xs font-medium bg-primary/10 text-primary rounded"
|
||||||
|
>
|
||||||
|
{TOOL_LABELS[tool]?.name || tool}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={step === 'customer' || createOrder.isPending}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
{step === 'review' ? (
|
||||||
|
<Button onClick={handleSubmit} disabled={createOrder.isPending}>
|
||||||
|
{createOrder.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Create Order
|
||||||
|
<Check className="h-4 w-4 ml-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleNext}>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { Bell, Search } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
export function AdminHeader() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background px-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex flex-1 items-center gap-4">
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search orders, customers..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-primary-foreground">
|
||||||
|
{session?.user?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<p className="text-sm font-medium">{session?.user?.name || 'Admin'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{session?.user?.role === 'ADMIN' ? 'Administrator' : 'Support'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
Server,
|
||||||
|
Mail,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Order status type
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'PAYMENT_CONFIRMED'
|
||||||
|
| 'AWAITING_SERVER'
|
||||||
|
| 'SERVER_READY'
|
||||||
|
| 'DNS_PENDING'
|
||||||
|
| 'DNS_READY'
|
||||||
|
| 'PROVISIONING'
|
||||||
|
| 'FULFILLED'
|
||||||
|
| 'EMAIL_CONFIGURED'
|
||||||
|
| 'FAILED'
|
||||||
|
|
||||||
|
// Order tier type
|
||||||
|
export type OrderTier = 'hub-dashboard' | 'control-panel'
|
||||||
|
|
||||||
|
// Order interface
|
||||||
|
export interface Order {
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
customerName: string
|
||||||
|
customerEmail: string
|
||||||
|
tier: OrderTier
|
||||||
|
status: OrderStatus
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
serverId?: string
|
||||||
|
serverIp?: string
|
||||||
|
failureReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status configuration
|
||||||
|
const statusConfig: Record<
|
||||||
|
OrderStatus,
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
bgColor: string
|
||||||
|
borderColor: string
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
PAYMENT_CONFIRMED: {
|
||||||
|
label: 'Payment Confirmed',
|
||||||
|
color: 'text-blue-700',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200',
|
||||||
|
},
|
||||||
|
AWAITING_SERVER: {
|
||||||
|
label: 'Awaiting Server',
|
||||||
|
color: 'text-yellow-700',
|
||||||
|
bgColor: 'bg-yellow-50',
|
||||||
|
borderColor: 'border-yellow-200',
|
||||||
|
},
|
||||||
|
SERVER_READY: {
|
||||||
|
label: 'Server Ready',
|
||||||
|
color: 'text-purple-700',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200',
|
||||||
|
},
|
||||||
|
DNS_PENDING: {
|
||||||
|
label: 'DNS Pending',
|
||||||
|
color: 'text-orange-700',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
borderColor: 'border-orange-200',
|
||||||
|
},
|
||||||
|
DNS_READY: {
|
||||||
|
label: 'DNS Ready',
|
||||||
|
color: 'text-cyan-700',
|
||||||
|
bgColor: 'bg-cyan-50',
|
||||||
|
borderColor: 'border-cyan-200',
|
||||||
|
},
|
||||||
|
PROVISIONING: {
|
||||||
|
label: 'Provisioning',
|
||||||
|
color: 'text-indigo-700',
|
||||||
|
bgColor: 'bg-indigo-50',
|
||||||
|
borderColor: 'border-indigo-200',
|
||||||
|
},
|
||||||
|
FULFILLED: {
|
||||||
|
label: 'Fulfilled',
|
||||||
|
color: 'text-green-700',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200',
|
||||||
|
},
|
||||||
|
EMAIL_CONFIGURED: {
|
||||||
|
label: 'Complete',
|
||||||
|
color: 'text-emerald-700',
|
||||||
|
bgColor: 'bg-emerald-50',
|
||||||
|
borderColor: 'border-emerald-200',
|
||||||
|
},
|
||||||
|
FAILED: {
|
||||||
|
label: 'Failed',
|
||||||
|
color: 'text-red-700',
|
||||||
|
bgColor: 'bg-red-50',
|
||||||
|
borderColor: 'border-red-200',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier configuration
|
||||||
|
const tierConfig: Record<OrderTier, { label: string; color: string }> = {
|
||||||
|
'hub-dashboard': {
|
||||||
|
label: 'Hub Dashboard',
|
||||||
|
color: 'bg-blue-100 text-blue-800',
|
||||||
|
},
|
||||||
|
'control-panel': {
|
||||||
|
label: 'Control Panel',
|
||||||
|
color: 'bg-purple-100 text-purple-800',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action button configuration based on status
|
||||||
|
function getActionConfig(status: OrderStatus): {
|
||||||
|
label: string
|
||||||
|
icon: React.ElementType
|
||||||
|
variant: 'default' | 'outline' | 'secondary' | 'destructive'
|
||||||
|
} | null {
|
||||||
|
switch (status) {
|
||||||
|
case 'PAYMENT_CONFIRMED':
|
||||||
|
return { label: 'Provision Server', icon: Server, variant: 'default' }
|
||||||
|
case 'AWAITING_SERVER':
|
||||||
|
return { label: 'Check Status', icon: RefreshCw, variant: 'outline' }
|
||||||
|
case 'SERVER_READY':
|
||||||
|
return { label: 'Setup DNS', icon: Globe, variant: 'default' }
|
||||||
|
case 'DNS_PENDING':
|
||||||
|
return { label: 'Verify DNS', icon: RefreshCw, variant: 'outline' }
|
||||||
|
case 'DNS_READY':
|
||||||
|
return { label: 'Start Provisioning', icon: Play, variant: 'default' }
|
||||||
|
case 'PROVISIONING':
|
||||||
|
return { label: 'View Progress', icon: RefreshCw, variant: 'outline' }
|
||||||
|
case 'FULFILLED':
|
||||||
|
return { label: 'Configure Email', icon: Mail, variant: 'default' }
|
||||||
|
case 'EMAIL_CONFIGURED':
|
||||||
|
return null // No action needed - complete
|
||||||
|
case 'FAILED':
|
||||||
|
return { label: 'Retry', icon: RefreshCw, variant: 'destructive' }
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format time since creation
|
||||||
|
function formatTimeSince(date: Date): string {
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now'
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderCardProps {
|
||||||
|
order: Order
|
||||||
|
onAction?: (order: Order, action: string) => void
|
||||||
|
onViewDetails?: (order: Order) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderCard({ order, onAction, onViewDetails }: OrderCardProps) {
|
||||||
|
const config = statusConfig[order.status]
|
||||||
|
const tierConf = tierConfig[order.tier]
|
||||||
|
const actionConfig = getActionConfig(order.status)
|
||||||
|
|
||||||
|
const handleAction = () => {
|
||||||
|
if (onAction && actionConfig) {
|
||||||
|
onAction(order, actionConfig.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewDetails = () => {
|
||||||
|
if (onViewDetails) {
|
||||||
|
onViewDetails(order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-200 hover:shadow-md cursor-pointer border-l-4',
|
||||||
|
config.borderColor
|
||||||
|
)}
|
||||||
|
onClick={handleViewDetails}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{/* Domain and tier */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="font-medium text-sm truncate">{order.domain}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
|
||||||
|
tierConf.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tierConf.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<User className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{order.customerName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time since creation */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
|
||||||
|
<Clock className="h-3 w-3 shrink-0" />
|
||||||
|
<span>{formatTimeSince(order.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server info (if available) */}
|
||||||
|
{order.serverIp && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
|
||||||
|
<Server className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="font-mono">{order.serverIp}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failure reason (if failed) */}
|
||||||
|
{order.status === 'FAILED' && order.failureReason && (
|
||||||
|
<div className="flex items-start gap-2 text-xs text-red-600 bg-red-50 rounded p-2 mb-3">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span className="line-clamp-2">{order.failureReason}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md w-fit',
|
||||||
|
config.bgColor,
|
||||||
|
config.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{order.status === 'EMAIL_CONFIGURED' ? (
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
) : order.status === 'FAILED' ? (
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
) : null}
|
||||||
|
{config.label}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{(actionConfig || onViewDetails) && (
|
||||||
|
<CardFooter className="p-4 pt-0 flex gap-2">
|
||||||
|
{actionConfig && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={actionConfig.variant}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAction()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<actionConfig.icon className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
{actionConfig.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleViewDetails()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact version for smaller displays
|
||||||
|
export function OrderCardCompact({
|
||||||
|
order,
|
||||||
|
onAction,
|
||||||
|
onViewDetails,
|
||||||
|
}: OrderCardProps) {
|
||||||
|
const config = statusConfig[order.status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between p-3 rounded-lg border bg-card hover:shadow-sm transition-all cursor-pointer',
|
||||||
|
config.borderColor,
|
||||||
|
'border-l-4'
|
||||||
|
)}
|
||||||
|
onClick={() => onViewDetails?.(order)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{order.domain}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{order.customerName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatTimeSince(order.createdAt)}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { OrderCard, type Order, type OrderStatus } from './order-card'
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Server,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
CheckCircle2,
|
||||||
|
Zap,
|
||||||
|
Package,
|
||||||
|
Mail,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Column configuration
|
||||||
|
interface ColumnConfig {
|
||||||
|
id: OrderStatus
|
||||||
|
title: string
|
||||||
|
icon: React.ElementType
|
||||||
|
color: string
|
||||||
|
bgColor: string
|
||||||
|
borderColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'PAYMENT_CONFIRMED',
|
||||||
|
title: 'Payment Confirmed',
|
||||||
|
icon: CreditCard,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'AWAITING_SERVER',
|
||||||
|
title: 'Awaiting Server',
|
||||||
|
icon: Server,
|
||||||
|
color: 'text-yellow-600',
|
||||||
|
bgColor: 'bg-yellow-50',
|
||||||
|
borderColor: 'border-yellow-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SERVER_READY',
|
||||||
|
title: 'Server Ready',
|
||||||
|
icon: HardDrive,
|
||||||
|
color: 'text-purple-600',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DNS_PENDING',
|
||||||
|
title: 'DNS Pending',
|
||||||
|
icon: Globe,
|
||||||
|
color: 'text-orange-600',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
borderColor: 'border-orange-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DNS_READY',
|
||||||
|
title: 'DNS Ready',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-cyan-600',
|
||||||
|
bgColor: 'bg-cyan-50',
|
||||||
|
borderColor: 'border-cyan-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'PROVISIONING',
|
||||||
|
title: 'Provisioning',
|
||||||
|
icon: Zap,
|
||||||
|
color: 'text-indigo-600',
|
||||||
|
bgColor: 'bg-indigo-50',
|
||||||
|
borderColor: 'border-indigo-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'FULFILLED',
|
||||||
|
title: 'Fulfilled',
|
||||||
|
icon: Package,
|
||||||
|
color: 'text-green-600',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'EMAIL_CONFIGURED',
|
||||||
|
title: 'Complete',
|
||||||
|
icon: Mail,
|
||||||
|
color: 'text-emerald-600',
|
||||||
|
bgColor: 'bg-emerald-50',
|
||||||
|
borderColor: 'border-emerald-200',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Failed column is separate
|
||||||
|
const failedColumn: ColumnConfig = {
|
||||||
|
id: 'FAILED',
|
||||||
|
title: 'Failed',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bgColor: 'bg-red-50',
|
||||||
|
borderColor: 'border-red-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KanbanColumnProps {
|
||||||
|
column: ColumnConfig
|
||||||
|
orders: Order[]
|
||||||
|
onAction?: (order: Order, action: string) => void
|
||||||
|
onViewDetails?: (order: Order) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanColumn({
|
||||||
|
column,
|
||||||
|
orders,
|
||||||
|
onAction,
|
||||||
|
onViewDetails,
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const Icon = column.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-72 shrink-0">
|
||||||
|
{/* Column header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-t-lg border-b-2',
|
||||||
|
column.bgColor,
|
||||||
|
column.borderColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', column.color)} />
|
||||||
|
<h3 className={cn('font-medium text-sm', column.color)}>
|
||||||
|
{column.title}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full',
|
||||||
|
column.bgColor,
|
||||||
|
column.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{orders.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 p-2 space-y-3 min-h-[200px] rounded-b-lg border border-t-0',
|
||||||
|
'bg-gray-50/50',
|
||||||
|
column.borderColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||||
|
No orders
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
orders.map((order) => (
|
||||||
|
<OrderCard
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
onAction={onAction}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderKanbanProps {
|
||||||
|
orders: Order[]
|
||||||
|
onAction?: (order: Order, action: string) => void
|
||||||
|
onViewDetails?: (order: Order) => void
|
||||||
|
showFailedColumn?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderKanban({
|
||||||
|
orders,
|
||||||
|
onAction,
|
||||||
|
onViewDetails,
|
||||||
|
showFailedColumn = true,
|
||||||
|
}: OrderKanbanProps) {
|
||||||
|
// Group orders by status
|
||||||
|
const ordersByStatus = useMemo(() => {
|
||||||
|
const grouped: Record<OrderStatus, Order[]> = {
|
||||||
|
PAYMENT_CONFIRMED: [],
|
||||||
|
AWAITING_SERVER: [],
|
||||||
|
SERVER_READY: [],
|
||||||
|
DNS_PENDING: [],
|
||||||
|
DNS_READY: [],
|
||||||
|
PROVISIONING: [],
|
||||||
|
FULFILLED: [],
|
||||||
|
EMAIL_CONFIGURED: [],
|
||||||
|
FAILED: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.forEach((order) => {
|
||||||
|
if (grouped[order.status]) {
|
||||||
|
grouped[order.status].push(order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort orders within each column by creation date (newest first)
|
||||||
|
Object.keys(grouped).forEach((status) => {
|
||||||
|
grouped[status as OrderStatus].sort(
|
||||||
|
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}, [orders])
|
||||||
|
|
||||||
|
// Count of failed orders
|
||||||
|
const failedCount = ordersByStatus.FAILED.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Main Kanban board */}
|
||||||
|
<div className="flex-1 overflow-x-auto pb-4">
|
||||||
|
<div className="flex gap-4 min-w-max p-1">
|
||||||
|
{/* Main pipeline columns */}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
orders={ordersByStatus[column.id]}
|
||||||
|
onAction={onAction}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Failed column (separate) */}
|
||||||
|
{showFailedColumn && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KanbanColumn
|
||||||
|
column={failedColumn}
|
||||||
|
orders={ordersByStatus.FAILED}
|
||||||
|
onAction={onAction}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex items-center gap-4 pt-4 border-t bg-white">
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Total:</span>
|
||||||
|
<span className="text-muted-foreground">{orders.length} orders</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">In Progress:</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{orders.filter(
|
||||||
|
(o) =>
|
||||||
|
o.status !== 'EMAIL_CONFIGURED' && o.status !== 'FAILED'
|
||||||
|
).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Completed:</span>
|
||||||
|
<span className="text-green-600">
|
||||||
|
{ordersByStatus.EMAIL_CONFIGURED.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">Failed:</span>
|
||||||
|
<span className="text-red-600">{failedCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact horizontal pipeline view (for smaller screens)
|
||||||
|
export function OrderPipelineCompact({
|
||||||
|
orders,
|
||||||
|
onViewDetails,
|
||||||
|
}: {
|
||||||
|
orders: Order[]
|
||||||
|
onViewDetails?: (order: Order) => void
|
||||||
|
}) {
|
||||||
|
const ordersByStatus = useMemo(() => {
|
||||||
|
const grouped: Record<OrderStatus, Order[]> = {
|
||||||
|
PAYMENT_CONFIRMED: [],
|
||||||
|
AWAITING_SERVER: [],
|
||||||
|
SERVER_READY: [],
|
||||||
|
DNS_PENDING: [],
|
||||||
|
DNS_READY: [],
|
||||||
|
PROVISIONING: [],
|
||||||
|
FULFILLED: [],
|
||||||
|
EMAIL_CONFIGURED: [],
|
||||||
|
FAILED: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.forEach((order) => {
|
||||||
|
if (grouped[order.status]) {
|
||||||
|
grouped[order.status].push(order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}, [orders])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Pipeline summary */}
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto pb-2">
|
||||||
|
{columns.map((column, idx) => {
|
||||||
|
const Icon = column.icon
|
||||||
|
const count = ordersByStatus[column.id].length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={column.id} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium',
|
||||||
|
column.bgColor,
|
||||||
|
column.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{column.title}</span>
|
||||||
|
<span className="font-bold">{count}</span>
|
||||||
|
</div>
|
||||||
|
{idx < columns.length - 1 && (
|
||||||
|
<div className="w-4 h-px bg-gray-300 mx-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders list grouped by status */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{columns.map((column) => {
|
||||||
|
const columnOrders = ordersByStatus[column.id]
|
||||||
|
if (columnOrders.length === 0) return null
|
||||||
|
|
||||||
|
const Icon = column.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={column.id}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon className={cn('h-4 w-4', column.color)} />
|
||||||
|
<h3 className="font-medium text-sm">{column.title}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({columnOrders.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{columnOrders.map((order) => (
|
||||||
|
<OrderCard
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Failed orders */}
|
||||||
|
{ordersByStatus.FAILED.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||||
|
<h3 className="font-medium text-sm text-red-600">Failed</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({ordersByStatus.FAILED.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{ordersByStatus.FAILED.map((order) => (
|
||||||
|
<OrderCard
|
||||||
|
key={order.id}
|
||||||
|
order={order}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
ShoppingCart,
|
||||||
|
BarChart3,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Server,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
href: '/admin',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orders',
|
||||||
|
href: '/admin/orders',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Customers',
|
||||||
|
href: '/admin/customers',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Servers',
|
||||||
|
href: '/admin/servers',
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Analytics',
|
||||||
|
href: '/admin/analytics',
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
href: '/admin/settings',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-64 flex-col bg-gray-900">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-16 items-center px-6">
|
||||||
|
<Link href="/admin" className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
|
||||||
|
<span className="text-primary-foreground font-bold text-sm">LB</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-white">LetsBe Hub</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href ||
|
||||||
|
(item.href !== '/admin' && pathname.startsWith(item.href))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5 shrink-0',
|
||||||
|
isActive ? 'text-primary' : 'text-gray-400 group-hover:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<div className="border-t border-gray-800 p-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start gap-3 text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||||
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { SessionProvider } from 'next-auth/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>{children}</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLHeadingElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogContentProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogHeaderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogTitleProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogDescriptionProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogFooterProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContext = React.createContext<{
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}>({ onOpenChange: () => {} })
|
||||||
|
|
||||||
|
export function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={{ onOpenChange }}>
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
{/* Content wrapper */}
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogContent({ children, className = '' }: DialogContentProps) {
|
||||||
|
const { onOpenChange } = React.useContext(DialogContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative z-50 w-full max-w-lg rounded-lg border bg-white p-6 shadow-lg ${className}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader({ children, className = '' }: DialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle({ children, className = '' }: DialogTitleProps) {
|
||||||
|
return (
|
||||||
|
<h2 className={`text-lg font-semibold leading-none tracking-tight ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({ children, className = '' }: DialogDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-sm text-muted-foreground ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter({ children, className = '' }: DialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getCustomers, getCustomer } from '@/lib/api/admin'
|
||||||
|
import type { CustomerFilters } from '@/types/api'
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const customerKeys = {
|
||||||
|
all: ['customers'] as const,
|
||||||
|
lists: () => [...customerKeys.all, 'list'] as const,
|
||||||
|
list: (filters: CustomerFilters) => [...customerKeys.lists(), filters] as const,
|
||||||
|
details: () => [...customerKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...customerKeys.details(), id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export function useCustomers(filters: CustomerFilters = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: customerKeys.list(filters),
|
||||||
|
queryFn: () => getCustomers(filters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomer(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: customerKeys.detail(id),
|
||||||
|
queryFn: () => getCustomer(id),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
getOrders,
|
||||||
|
getOrder,
|
||||||
|
createOrder,
|
||||||
|
updateOrder,
|
||||||
|
triggerProvisioning,
|
||||||
|
} from '@/lib/api/admin'
|
||||||
|
import type {
|
||||||
|
OrderFilters,
|
||||||
|
UpdateOrderPayload,
|
||||||
|
CreateOrderPayload,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const orderKeys = {
|
||||||
|
all: ['orders'] as const,
|
||||||
|
lists: () => [...orderKeys.all, 'list'] as const,
|
||||||
|
list: (filters: OrderFilters) => [...orderKeys.lists(), filters] as const,
|
||||||
|
details: () => [...orderKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...orderKeys.details(), id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export function useOrders(filters: OrderFilters = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: orderKeys.list(filters),
|
||||||
|
queryFn: () => getOrders(filters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrder(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: orderKeys.detail(id),
|
||||||
|
queryFn: () => getOrder(id),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOrder() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateOrderPayload) => createOrder(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateOrder() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateOrderPayload }) =>
|
||||||
|
updateOrder(id, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: orderKeys.detail(variables.id) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTriggerProvisioning() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (orderId: string) => triggerProvisioning(orderId),
|
||||||
|
onSuccess: (_, orderId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { LogLevel, OrderStatus } from '@/types/api'
|
||||||
|
|
||||||
|
export interface StreamedLog {
|
||||||
|
id: string
|
||||||
|
level: LogLevel
|
||||||
|
step: string | null
|
||||||
|
message: string
|
||||||
|
timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProvisioningLogsOptions {
|
||||||
|
orderId: string
|
||||||
|
enabled?: boolean
|
||||||
|
onStatusChange?: (status: OrderStatus) => void
|
||||||
|
onComplete?: (success: boolean) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProvisioningLogsResult {
|
||||||
|
logs: StreamedLog[]
|
||||||
|
isConnected: boolean
|
||||||
|
isComplete: boolean
|
||||||
|
currentStatus: OrderStatus | null
|
||||||
|
error: Error | null
|
||||||
|
reconnect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProvisioningLogs({
|
||||||
|
orderId,
|
||||||
|
enabled = true,
|
||||||
|
onStatusChange,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: UseProvisioningLogsOptions): UseProvisioningLogsResult {
|
||||||
|
const [logs, setLogs] = useState<StreamedLog[]>([])
|
||||||
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
const [currentStatus, setCurrentStatus] = useState<OrderStatus | null>(null)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!enabled || !orderId) return
|
||||||
|
|
||||||
|
// Close existing connection
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear reconnect timeout
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setIsComplete(false)
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`/api/v1/admin/orders/${orderId}/logs/stream`)
|
||||||
|
eventSourceRef.current = eventSource
|
||||||
|
|
||||||
|
eventSource.addEventListener('connected', (event) => {
|
||||||
|
setIsConnected(true)
|
||||||
|
setError(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('log', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
const log: StreamedLog = {
|
||||||
|
id: data.id,
|
||||||
|
level: data.level,
|
||||||
|
step: data.step,
|
||||||
|
message: data.message,
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogs((prev) => {
|
||||||
|
// Avoid duplicates
|
||||||
|
if (prev.some((l) => l.id === log.id)) return prev
|
||||||
|
return [...prev, log]
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse log event:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('status', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.status !== currentStatus) {
|
||||||
|
setCurrentStatus(data.status as OrderStatus)
|
||||||
|
onStatusChange?.(data.status as OrderStatus)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse status event:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('complete', (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
setIsComplete(true)
|
||||||
|
setCurrentStatus(data.status as OrderStatus)
|
||||||
|
onComplete?.(data.success)
|
||||||
|
eventSource.close()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse complete event:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (event) => {
|
||||||
|
if (eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
setIsConnected(false)
|
||||||
|
|
||||||
|
// Only set error if not complete
|
||||||
|
if (!isComplete) {
|
||||||
|
const err = new Error('Connection to log stream lost')
|
||||||
|
setError(err)
|
||||||
|
onError?.(err)
|
||||||
|
|
||||||
|
// Attempt to reconnect after 5 seconds
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
connect()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setIsConnected(false)
|
||||||
|
}
|
||||||
|
}, [orderId, enabled, currentStatus, isComplete, onStatusChange, onComplete, onError])
|
||||||
|
|
||||||
|
// Connect on mount / when orderId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && orderId) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
}
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [orderId, enabled, connect])
|
||||||
|
|
||||||
|
const reconnect = useCallback(() => {
|
||||||
|
setLogs([])
|
||||||
|
connect()
|
||||||
|
}, [connect])
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
isConnected,
|
||||||
|
isComplete,
|
||||||
|
currentStatus,
|
||||||
|
error,
|
||||||
|
reconnect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { apiGet } from '@/lib/api/client'
|
||||||
|
import type { SubscriptionTier, OrderStatus, UserSummary } from '@/types/api'
|
||||||
|
|
||||||
|
export type ServerStatus = 'online' | 'provisioning' | 'offline' | 'pending'
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
id: string
|
||||||
|
domain: string
|
||||||
|
tier: SubscriptionTier
|
||||||
|
orderStatus: OrderStatus
|
||||||
|
serverStatus: ServerStatus
|
||||||
|
serverIp: string
|
||||||
|
sshPort: number
|
||||||
|
tools: string[]
|
||||||
|
createdAt: Date
|
||||||
|
serverReadyAt: Date | null
|
||||||
|
completedAt: Date | null
|
||||||
|
customer: UserSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServersResponse {
|
||||||
|
servers: Server[]
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerFilters {
|
||||||
|
status?: ServerStatus
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// API call
|
||||||
|
async function getServers(filters: ServerFilters = {}): Promise<ServersResponse> {
|
||||||
|
return apiGet<ServersResponse>('/api/v1/admin/servers', {
|
||||||
|
params: {
|
||||||
|
status: filters.status,
|
||||||
|
search: filters.search,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const serverKeys = {
|
||||||
|
all: ['servers'] as const,
|
||||||
|
lists: () => [...serverKeys.all, 'list'] as const,
|
||||||
|
list: (filters: ServerFilters) => [...serverKeys.lists(), filters] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook
|
||||||
|
export function useServers(filters: ServerFilters = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: serverKeys.list(filters),
|
||||||
|
queryFn: () => getServers(filters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getDashboardStats } from '@/lib/api/admin'
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const statsKeys = {
|
||||||
|
all: ['stats'] as const,
|
||||||
|
dashboard: () => [...statsKeys.all, 'dashboard'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export function useDashboardStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: statsKeys.dashboard(),
|
||||||
|
queryFn: getDashboardStats,
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
refetchInterval: 60 * 1000, // 1 minute
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { apiGet, apiPost, apiPatch, apiDelete } from './client'
|
||||||
|
import type {
|
||||||
|
Order,
|
||||||
|
OrderDetail,
|
||||||
|
OrdersResponse,
|
||||||
|
OrderFilters,
|
||||||
|
UpdateOrderPayload,
|
||||||
|
CreateOrderPayload,
|
||||||
|
ProvisioningResult,
|
||||||
|
CustomerSummary,
|
||||||
|
Customer,
|
||||||
|
CustomersResponse,
|
||||||
|
CustomerFilters,
|
||||||
|
DashboardStats,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1/admin'
|
||||||
|
|
||||||
|
// Orders API
|
||||||
|
export async function getOrders(filters: OrderFilters = {}): Promise<OrdersResponse> {
|
||||||
|
return apiGet<OrdersResponse>(`${API_BASE}/orders`, {
|
||||||
|
params: {
|
||||||
|
status: filters.status,
|
||||||
|
tier: filters.tier,
|
||||||
|
search: filters.search,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrder(id: string): Promise<OrderDetail> {
|
||||||
|
return apiGet<OrderDetail>(`${API_BASE}/orders/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrder(data: CreateOrderPayload): Promise<Order> {
|
||||||
|
return apiPost<Order>(`${API_BASE}/orders`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrder(id: string, data: UpdateOrderPayload): Promise<Order> {
|
||||||
|
return apiPatch<Order>(`${API_BASE}/orders/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerProvisioning(orderId: string): Promise<ProvisioningResult> {
|
||||||
|
return apiPost<ProvisioningResult>(`${API_BASE}/orders/${orderId}/provision`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customers API
|
||||||
|
export async function getCustomers(filters: CustomerFilters = {}): Promise<CustomersResponse> {
|
||||||
|
return apiGet<CustomersResponse>(`${API_BASE}/customers`, {
|
||||||
|
params: {
|
||||||
|
status: filters.status,
|
||||||
|
search: filters.search,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomer(id: string): Promise<Customer> {
|
||||||
|
return apiGet<Customer>(`${API_BASE}/customers/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard Stats API
|
||||||
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
|
return apiGet<DashboardStats>(`${API_BASE}/stats`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public statusText: string,
|
||||||
|
public data?: unknown
|
||||||
|
) {
|
||||||
|
super(`API Error: ${status} ${statusText}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchOptions extends RequestInit {
|
||||||
|
params?: Record<string, string | number | boolean | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch {
|
||||||
|
// Response is not JSON
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, response.statusText, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty responses
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) {
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(text) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||||
|
const url = new URL(path, window.location.origin)
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||||
|
const { params, ...fetchOptions } = options
|
||||||
|
const url = buildUrl(path, params)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
|
...fetchOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
return handleResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(path: string, data?: unknown, options: FetchOptions = {}): Promise<T> {
|
||||||
|
const { params, ...fetchOptions } = options
|
||||||
|
const url = buildUrl(path, params)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...fetchOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
return handleResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch<T>(path: string, data?: unknown, options: FetchOptions = {}): Promise<T> {
|
||||||
|
const { params, ...fetchOptions } = options
|
||||||
|
const url = buildUrl(path, params)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...fetchOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
return handleResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||||
|
const { params, ...fetchOptions } = options
|
||||||
|
const url = buildUrl(path, params)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...fetchOptions.headers,
|
||||||
|
},
|
||||||
|
...fetchOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
return handleResponse<T>(response)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import NextAuth from 'next-auth'
|
||||||
|
import Credentials from 'next-auth/providers/credentials'
|
||||||
|
import { compare } from 'bcryptjs'
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt',
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/login',
|
||||||
|
error: '/login',
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: 'credentials',
|
||||||
|
credentials: {
|
||||||
|
email: { label: 'Email', type: 'email' },
|
||||||
|
password: { label: 'Password', type: 'password' },
|
||||||
|
userType: { label: 'User Type', type: 'text' }, // 'customer' or 'staff'
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password || !credentials?.userType) {
|
||||||
|
throw new Error('Missing credentials')
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = credentials.email as string
|
||||||
|
const password = credentials.password as string
|
||||||
|
const userType = credentials.userType as 'customer' | 'staff'
|
||||||
|
|
||||||
|
if (userType === 'customer') {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: {
|
||||||
|
subscriptions: {
|
||||||
|
where: { status: { not: 'CANCELED' } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status === 'SUSPENDED') {
|
||||||
|
throw new Error('Account suspended')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await compare(password, user.passwordHash)
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new Error('Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
userType: 'customer' as const,
|
||||||
|
company: user.company,
|
||||||
|
subscription: user.subscriptions[0] || null,
|
||||||
|
}
|
||||||
|
} else if (userType === 'staff') {
|
||||||
|
const staff = await prisma.staff.findUnique({
|
||||||
|
where: { email },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!staff) {
|
||||||
|
throw new Error('Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await compare(password, staff.passwordHash)
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new Error('Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: staff.id,
|
||||||
|
email: staff.email,
|
||||||
|
name: staff.name,
|
||||||
|
userType: 'staff' as const,
|
||||||
|
role: staff.role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid user type')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id
|
||||||
|
token.userType = user.userType
|
||||||
|
if (user.userType === 'staff') {
|
||||||
|
token.role = user.role
|
||||||
|
}
|
||||||
|
if (user.userType === 'customer') {
|
||||||
|
token.company = user.company
|
||||||
|
token.subscription = user.subscription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token) {
|
||||||
|
session.user.id = token.id as string
|
||||||
|
session.user.userType = token.userType as 'customer' | 'staff'
|
||||||
|
if (token.userType === 'staff') {
|
||||||
|
session.user.role = token.role as 'ADMIN' | 'SUPPORT'
|
||||||
|
}
|
||||||
|
if (token.userType === 'customer') {
|
||||||
|
session.user.company = token.company as string | undefined
|
||||||
|
session.user.subscription = token.subscription as {
|
||||||
|
id: string
|
||||||
|
plan: string
|
||||||
|
tier: string
|
||||||
|
status: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
async authorized({ auth, request }) {
|
||||||
|
const isLoggedIn = !!auth?.user
|
||||||
|
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin')
|
||||||
|
const isApiAdminRoute = request.nextUrl.pathname.startsWith('/api/v1/admin')
|
||||||
|
|
||||||
|
if (isAdminRoute || isApiAdminRoute) {
|
||||||
|
if (!isLoggedIn) return false
|
||||||
|
return auth.user.userType === 'staff'
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
|
|
||||||
|
export default prisma
|
||||||
|
|
@ -0,0 +1,413 @@
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { JobStatus, OrderStatus, LogLevel } from '@prisma/client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const toLogLevel = (level: 'info' | 'warn' | 'error'): LogLevel => {
|
||||||
|
const map: Record<'info' | 'warn' | 'error', LogLevel> = {
|
||||||
|
info: LogLevel.INFO,
|
||||||
|
warn: LogLevel.WARN,
|
||||||
|
error: LogLevel.ERROR,
|
||||||
|
}
|
||||||
|
return map[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry delays in seconds: 1min, 5min, 15min
|
||||||
|
const RETRY_DELAYS = [60, 300, 900]
|
||||||
|
|
||||||
|
export interface JobConfig {
|
||||||
|
server: {
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
rootPassword: string
|
||||||
|
}
|
||||||
|
customer: string
|
||||||
|
domain: string
|
||||||
|
companyName: string
|
||||||
|
licenseKey: string
|
||||||
|
dashboardTier: string
|
||||||
|
tools: string[]
|
||||||
|
keycloak?: {
|
||||||
|
realm: string
|
||||||
|
clients: Array<{ clientId: string; public: boolean }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JobService {
|
||||||
|
/**
|
||||||
|
* Create a new provisioning job for an order
|
||||||
|
*/
|
||||||
|
async createJobForOrder(orderId: string): Promise<string> {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
subscriptions: {
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new Error(`Order ${orderId} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.serverIp || !order.serverPasswordEncrypted) {
|
||||||
|
throw new Error(`Order ${orderId} missing server credentials`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config snapshot
|
||||||
|
const configSnapshot: JobConfig = {
|
||||||
|
server: {
|
||||||
|
ip: order.serverIp,
|
||||||
|
port: order.sshPort,
|
||||||
|
rootPassword: this.decryptPassword(order.serverPasswordEncrypted),
|
||||||
|
},
|
||||||
|
customer: order.user.email.split('@')[0],
|
||||||
|
domain: order.domain,
|
||||||
|
companyName: order.user.company || order.user.name || 'Customer',
|
||||||
|
licenseKey: await this.generateLicenseKey(order.id),
|
||||||
|
dashboardTier: order.tier,
|
||||||
|
tools: order.tools,
|
||||||
|
keycloak: {
|
||||||
|
realm: 'letsbe',
|
||||||
|
clients: [{ clientId: 'dashboard', public: true }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate runner token
|
||||||
|
const runnerToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const runnerTokenHash = crypto.createHash('sha256').update(runnerToken).digest('hex')
|
||||||
|
|
||||||
|
const job = await prisma.provisioningJob.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
jobType: 'provision',
|
||||||
|
configSnapshot: configSnapshot as object,
|
||||||
|
runnerTokenHash,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: OrderStatus.PROVISIONING,
|
||||||
|
provisioningStartedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return job ID with runner token (token is only returned once)
|
||||||
|
return JSON.stringify({ jobId: job.id, runnerToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the next available job for processing
|
||||||
|
* Uses SELECT FOR UPDATE SKIP LOCKED pattern for concurrent workers
|
||||||
|
*/
|
||||||
|
async claimNextJob(workerId: string): Promise<{
|
||||||
|
jobId: string
|
||||||
|
config: JobConfig
|
||||||
|
runnerToken: string
|
||||||
|
} | null> {
|
||||||
|
// Use a transaction with row-level locking
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Find next pending job, skip locked rows
|
||||||
|
const jobs = await tx.$queryRaw<Array<{ id: string }>>`
|
||||||
|
SELECT id FROM "ProvisioningJob"
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
|
||||||
|
ORDER BY priority DESC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
`
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = jobs[0].id
|
||||||
|
|
||||||
|
// Generate new runner token for this claim
|
||||||
|
const runnerToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const runnerTokenHash = crypto.createHash('sha256').update(runnerToken).digest('hex')
|
||||||
|
|
||||||
|
// Update job as claimed
|
||||||
|
const job = await tx.provisioningJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: JobStatus.RUNNING,
|
||||||
|
claimedAt: new Date(),
|
||||||
|
claimedBy: workerId,
|
||||||
|
runnerTokenHash,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
config: job.configSnapshot as unknown as JobConfig,
|
||||||
|
runnerToken,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a runner token for a job
|
||||||
|
*/
|
||||||
|
async verifyRunnerToken(jobId: string, token: string): Promise<boolean> {
|
||||||
|
const job = await prisma.provisioningJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
select: { runnerTokenHash: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!job || !job.runnerTokenHash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedHash = crypto.createHash('sha256').update(token).digest('hex')
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(job.runnerTokenHash),
|
||||||
|
Buffer.from(providedHash)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a log entry for a job
|
||||||
|
*/
|
||||||
|
async addLog(
|
||||||
|
jobId: string,
|
||||||
|
level: 'info' | 'warn' | 'error',
|
||||||
|
message: string,
|
||||||
|
step?: string,
|
||||||
|
progress?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const dbLevel = toLogLevel(level)
|
||||||
|
|
||||||
|
await prisma.jobLog.create({
|
||||||
|
data: {
|
||||||
|
jobId,
|
||||||
|
level: dbLevel,
|
||||||
|
message,
|
||||||
|
step,
|
||||||
|
progress,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also create a provisioning log on the order for easy access
|
||||||
|
const job = await prisma.provisioningJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
select: { orderId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
await prisma.provisioningLog.create({
|
||||||
|
data: {
|
||||||
|
orderId: job.orderId,
|
||||||
|
level: dbLevel,
|
||||||
|
message,
|
||||||
|
step,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logs for a job
|
||||||
|
*/
|
||||||
|
async getLogs(jobId: string, since?: Date): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
timestamp: Date
|
||||||
|
level: string
|
||||||
|
message: string
|
||||||
|
step: string | null
|
||||||
|
progress: number | null
|
||||||
|
}>> {
|
||||||
|
const where: { jobId: string; timestamp?: { gt: Date } } = { jobId }
|
||||||
|
if (since) {
|
||||||
|
where.timestamp = { gt: since }
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.jobLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
timestamp: true,
|
||||||
|
level: true,
|
||||||
|
message: true,
|
||||||
|
step: true,
|
||||||
|
progress: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a job successfully
|
||||||
|
*/
|
||||||
|
async completeJob(jobId: string, result?: object): Promise<void> {
|
||||||
|
const job = await prisma.provisioningJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: JobStatus.COMPLETED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
result: result || {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: job.orderId },
|
||||||
|
data: {
|
||||||
|
status: OrderStatus.FULFILLED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
// Clear sensitive data
|
||||||
|
serverPasswordEncrypted: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail a job - will retry if attempts remaining
|
||||||
|
*/
|
||||||
|
async failJob(jobId: string, error: string): Promise<{ willRetry: boolean; nextRetryAt?: Date }> {
|
||||||
|
const job = await prisma.provisioningJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
throw new Error(`Job ${jobId} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttempt = job.attempt + 1
|
||||||
|
const willRetry = nextAttempt <= job.maxAttempts
|
||||||
|
|
||||||
|
if (willRetry) {
|
||||||
|
// Schedule retry with exponential backoff
|
||||||
|
const delaySeconds = RETRY_DELAYS[Math.min(job.attempt - 1, RETRY_DELAYS.length - 1)]
|
||||||
|
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000)
|
||||||
|
|
||||||
|
await prisma.provisioningJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: JobStatus.PENDING,
|
||||||
|
attempt: nextAttempt,
|
||||||
|
nextRetryAt,
|
||||||
|
claimedAt: null,
|
||||||
|
claimedBy: null,
|
||||||
|
runnerTokenHash: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.addLog(jobId, 'warn', `Job failed, will retry (attempt ${nextAttempt}/${job.maxAttempts}): ${error}`, 'retry')
|
||||||
|
|
||||||
|
return { willRetry: true, nextRetryAt }
|
||||||
|
} else {
|
||||||
|
// Max retries exceeded - mark as dead
|
||||||
|
await prisma.provisioningJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: JobStatus.DEAD,
|
||||||
|
completedAt: new Date(),
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update order status to failed
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: job.orderId },
|
||||||
|
data: {
|
||||||
|
status: OrderStatus.FAILED,
|
||||||
|
failureReason: error,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.addLog(jobId, 'error', `Job failed permanently after ${job.maxAttempts} attempts: ${error}`, 'dead')
|
||||||
|
|
||||||
|
return { willRetry: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job status
|
||||||
|
*/
|
||||||
|
async getJobStatus(jobId: string): Promise<{
|
||||||
|
status: JobStatus
|
||||||
|
attempt: number
|
||||||
|
maxAttempts: number
|
||||||
|
progress?: number
|
||||||
|
error?: string
|
||||||
|
} | null> {
|
||||||
|
const job = await prisma.provisioningJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
attempt: true,
|
||||||
|
maxAttempts: true,
|
||||||
|
error: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest progress from logs
|
||||||
|
const latestLog = await prisma.jobLog.findFirst({
|
||||||
|
where: { jobId, progress: { not: null } },
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
select: { progress: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
progress: latestLog?.progress || undefined,
|
||||||
|
error: job.error || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending job count
|
||||||
|
*/
|
||||||
|
async getPendingJobCount(): Promise<number> {
|
||||||
|
return prisma.provisioningJob.count({
|
||||||
|
where: {
|
||||||
|
status: JobStatus.PENDING,
|
||||||
|
OR: [
|
||||||
|
{ nextRetryAt: null },
|
||||||
|
{ nextRetryAt: { lte: new Date() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get running job count
|
||||||
|
*/
|
||||||
|
async getRunningJobCount(): Promise<number> {
|
||||||
|
return prisma.provisioningJob.count({
|
||||||
|
where: { status: JobStatus.RUNNING },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
private decryptPassword(encrypted: string): string {
|
||||||
|
// TODO: Implement proper decryption using environment-based key
|
||||||
|
// For now, return as-is (in production, use crypto.createDecipheriv)
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateLicenseKey(orderId: string): Promise<string> {
|
||||||
|
// Generate a unique license key for this order
|
||||||
|
const hash = crypto.createHash('sha256').update(orderId + Date.now()).digest('hex')
|
||||||
|
return `lb_inst_${hash.slice(0, 40)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const jobService = new JobService()
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
import {
|
||||||
|
OrderStatus,
|
||||||
|
SubscriptionTier,
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionStatus,
|
||||||
|
UserStatus,
|
||||||
|
LogLevel,
|
||||||
|
JobStatus,
|
||||||
|
} from '@prisma/client'
|
||||||
|
|
||||||
|
// Re-export enums for use as both types and values
|
||||||
|
export {
|
||||||
|
OrderStatus,
|
||||||
|
SubscriptionTier,
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionStatus,
|
||||||
|
UserStatus,
|
||||||
|
LogLevel,
|
||||||
|
JobStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
// User types
|
||||||
|
export interface UserSummary {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
company: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User extends UserSummary {
|
||||||
|
status: UserStatus
|
||||||
|
emailVerified: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription types
|
||||||
|
export interface Subscription {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
plan: SubscriptionPlan
|
||||||
|
tier: SubscriptionTier
|
||||||
|
tokenLimit: number
|
||||||
|
tokensUsed: number
|
||||||
|
trialEndsAt: Date | null
|
||||||
|
stripeCustomerId: string | null
|
||||||
|
stripeSubscriptionId: string | null
|
||||||
|
status: SubscriptionStatus
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisioning log types
|
||||||
|
export interface ProvisioningLog {
|
||||||
|
id: string
|
||||||
|
orderId: string
|
||||||
|
level: LogLevel
|
||||||
|
message: string
|
||||||
|
step: string | null
|
||||||
|
timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job types
|
||||||
|
export interface JobSummary {
|
||||||
|
id: string
|
||||||
|
status: JobStatus
|
||||||
|
attempt: number
|
||||||
|
maxAttempts: number
|
||||||
|
createdAt: Date
|
||||||
|
completedAt: Date | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order types
|
||||||
|
export interface Order {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
status: OrderStatus
|
||||||
|
tier: SubscriptionTier
|
||||||
|
domain: string
|
||||||
|
tools: string[]
|
||||||
|
configJson: Record<string, unknown>
|
||||||
|
serverIp: string | null
|
||||||
|
sshPort: number
|
||||||
|
portainerUrl: string | null
|
||||||
|
dashboardUrl: string | null
|
||||||
|
failureReason: string | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
serverReadyAt: Date | null
|
||||||
|
provisioningStartedAt: Date | null
|
||||||
|
completedAt: Date | null
|
||||||
|
user: UserSummary
|
||||||
|
_count?: {
|
||||||
|
provisioningLogs: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderDetail extends Omit<Order, '_count'> {
|
||||||
|
provisioningLogs: ProvisioningLog[]
|
||||||
|
jobs: JobSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer types (User with subscriptions and orders)
|
||||||
|
export interface Customer extends User {
|
||||||
|
subscriptions: Subscription[]
|
||||||
|
orders: Order[]
|
||||||
|
_count: {
|
||||||
|
orders: number
|
||||||
|
subscriptions: number
|
||||||
|
tokenUsage?: number
|
||||||
|
}
|
||||||
|
// Computed fields from API
|
||||||
|
totalTokensUsed?: number
|
||||||
|
tokenLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerSummary extends UserSummary {
|
||||||
|
status: UserStatus
|
||||||
|
createdAt: Date
|
||||||
|
_count: {
|
||||||
|
orders: number
|
||||||
|
subscriptions: number
|
||||||
|
}
|
||||||
|
subscriptions: {
|
||||||
|
id: string
|
||||||
|
plan: SubscriptionPlan
|
||||||
|
tier: SubscriptionTier
|
||||||
|
status: SubscriptionStatus
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrdersResponse extends PaginatedResponse<Order> {
|
||||||
|
orders: Order[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomersResponse extends PaginatedResponse<CustomerSummary> {
|
||||||
|
customers: CustomerSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard stats types
|
||||||
|
export interface DashboardStats {
|
||||||
|
orders: {
|
||||||
|
total: number
|
||||||
|
pending: number
|
||||||
|
inProgress: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
byStatus: Record<OrderStatus, number>
|
||||||
|
}
|
||||||
|
customers: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
suspended: number
|
||||||
|
pending: number
|
||||||
|
}
|
||||||
|
subscriptions: {
|
||||||
|
total: number
|
||||||
|
trial: number
|
||||||
|
active: number
|
||||||
|
byPlan: Record<SubscriptionPlan, number>
|
||||||
|
byTier: Record<SubscriptionTier, number>
|
||||||
|
}
|
||||||
|
recentOrders: Order[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// API filter types
|
||||||
|
export interface OrderFilters {
|
||||||
|
status?: OrderStatus
|
||||||
|
tier?: SubscriptionTier
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFilters {
|
||||||
|
status?: UserStatus
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation types
|
||||||
|
export interface UpdateOrderPayload {
|
||||||
|
status?: OrderStatus
|
||||||
|
serverIp?: string
|
||||||
|
serverPassword?: string
|
||||||
|
sshPort?: number
|
||||||
|
failureReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderPayload {
|
||||||
|
userId: string
|
||||||
|
domain: string
|
||||||
|
tier: SubscriptionTier
|
||||||
|
tools: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisioningResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
jobId?: string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { DefaultSession, DefaultUser } from 'next-auth'
|
||||||
|
import { DefaultJWT } from '@auth/core/jwt'
|
||||||
|
|
||||||
|
declare module 'next-auth' {
|
||||||
|
interface User extends DefaultUser {
|
||||||
|
userType: 'customer' | 'staff'
|
||||||
|
role?: 'ADMIN' | 'SUPPORT'
|
||||||
|
company?: string | null
|
||||||
|
subscription?: {
|
||||||
|
id: string
|
||||||
|
plan: string
|
||||||
|
tier: string
|
||||||
|
status: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session extends DefaultSession {
|
||||||
|
user: User & {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@auth/core/jwt' {
|
||||||
|
interface JWT extends DefaultJWT {
|
||||||
|
id: string
|
||||||
|
userType: 'customer' | 'staff'
|
||||||
|
role?: 'ADMIN' | 'SUPPORT'
|
||||||
|
company?: string | null
|
||||||
|
subscription?: {
|
||||||
|
id: string
|
||||||
|
plan: string
|
||||||
|
tier: string
|
||||||
|
status: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"target": "ES2022"
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue