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:
30
src/hooks/use-customers.ts
Normal file
30
src/hooks/use-customers.ts
Normal 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
76
src/hooks/use-orders.ts
Normal 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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
168
src/hooks/use-provisioning-logs.ts
Normal file
168
src/hooks/use-provisioning-logs.ts
Normal 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
66
src/hooks/use-servers.ts
Normal 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
20
src/hooks/use-stats.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user