feat: Complete rewrite as Next.js admin dashboard
Some checks failed
Build and Push Docker Image / test (push) Failing after 34s
Build and Push Docker Image / build (push) Has been skipped

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:
2026-01-06 12:35:01 +01:00
parent 02fc18f009
commit a79b79efd2
85 changed files with 19070 additions and 1869 deletions

View File

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

76
src/hooks/use-orders.ts Normal file
View File

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

View File

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

66
src/hooks/use-servers.ts Normal file
View File

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

20
src/hooks/use-stats.ts Normal file
View File

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