Complete Hub Admin Dashboard with analytics, settings, and enterprise features
Some checks failed
Build and Push Docker Image / lint-and-typecheck (push) Failing after 2m10s
Build and Push Docker Image / build (push) Has been skipped

Major additions:
- Analytics dashboard with charts (line, bar, donut)
- Enterprise client monitoring with container management
- Staff management with 2FA support
- Profile management and settings pages
- Netcup server integration
- DNS verification panel
- Portainer integration
- Container logs and health monitoring
- Automation controls for orders

New API endpoints:
- /api/v1/admin/analytics
- /api/v1/admin/enterprise-clients
- /api/v1/admin/netcup
- /api/v1/admin/settings
- /api/v1/admin/staff
- /api/v1/profile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 12:33:11 +01:00
parent 60493cfbdd
commit 92092760a7
234 changed files with 52896 additions and 2425 deletions

View File

@@ -0,0 +1,90 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { apiGet } from '@/lib/api/client'
// Types
export type TimeRange = '7d' | '30d' | '90d'
export interface AnalyticsOverview {
totalOrders: number
ordersTrend: number
activeCustomers: number
customersTrend: number
activeSubscriptions: number
successRate: number
}
export interface DayCount {
date: string
count: number
}
export interface DayTokens {
date: string
tokens: number
}
export interface OperationTokens {
operation: string
tokens: number
}
export interface TopConsumer {
userId: string
name: string
tokens: number
}
export interface RecentFailure {
orderId: string
domain: string
date: string
reason: string
}
export interface AnalyticsData {
range: TimeRange
overview: AnalyticsOverview
orders: {
byDay: DayCount[]
byStatus: Record<string, number>
}
customers: {
growthByDay: DayCount[]
byPlan: Record<string, number>
byTier: Record<string, number>
}
tokens: {
usageByDay: DayTokens[]
byOperation: OperationTokens[]
topConsumers: TopConsumer[]
}
provisioning: {
successRate: number
byAutomation: Record<string, number>
recentFailures: RecentFailure[]
}
}
// Query keys
export const analyticsKeys = {
all: ['analytics'] as const,
dashboard: (range: TimeRange) => [...analyticsKeys.all, 'dashboard', range] as const,
}
// API function
async function getAnalytics(range: TimeRange): Promise<AnalyticsData> {
return apiGet<AnalyticsData>('/api/v1/admin/analytics', {
params: { range },
})
}
// Hook
export function useAnalytics(range: TimeRange = '30d') {
return useQuery({
queryKey: analyticsKeys.dashboard(range),
queryFn: () => getAnalytics(range),
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
})
}

View File

@@ -0,0 +1,44 @@
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiPatch } from '@/lib/api/client'
import { orderKeys } from './use-orders'
// Types
export interface AutomationChangeResponse {
success: boolean
message: string
order: {
id: string
automationMode: string
automationPausedAt: string | null
automationPausedReason: string | null
}
}
export type AutomationAction = 'auto' | 'manual' | 'pause' | 'resume'
// API call
async function changeAutomationMode(
orderId: string,
action: AutomationAction
): Promise<AutomationChangeResponse> {
return apiPatch<AutomationChangeResponse>(
`/api/v1/admin/orders/${orderId}/automation`,
{ action }
)
}
// Hook
export function useChangeAutomationMode() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ orderId, action }: { orderId: string; action: AutomationAction }) =>
changeAutomationMode(orderId, action),
onSuccess: (_, { orderId }) => {
// Invalidate order to get updated automation state
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) })
},
})
}

View File

@@ -1,8 +1,8 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { getCustomers, getCustomer } from '@/lib/api/admin'
import type { CustomerFilters } from '@/types/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getCustomers, getCustomer, createCustomer, deleteCustomer } from '@/lib/api/admin'
import type { CustomerFilters, CreateCustomerPayload } from '@/types/api'
// Query keys
export const customerKeys = {
@@ -28,3 +28,26 @@ export function useCustomer(id: string) {
enabled: !!id,
})
}
export function useCreateCustomer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateCustomerPayload) => createCustomer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: customerKeys.lists() })
},
})
}
export function useDeleteCustomer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (customerId: string) => deleteCustomer(customerId),
onSuccess: () => {
// Invalidate all customer lists since the customer is now gone
queryClient.invalidateQueries({ queryKey: customerKeys.lists() })
},
})
}

107
src/hooks/use-dns.ts Normal file
View File

@@ -0,0 +1,107 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiGet, apiPost } from '@/lib/api/client'
import { orderKeys } from './use-orders'
// Types
export interface DnsRecord {
id: string
subdomain: string
fullDomain: string
expectedIp: string
resolvedIp: string | null
status: 'PENDING' | 'VERIFIED' | 'MISMATCH' | 'NOT_FOUND' | 'ERROR' | 'SKIPPED'
errorMessage: string | null
checkedAt: string | null
}
export interface DnsVerification {
id: string
orderId: string
wildcardPassed: boolean
manualOverride: boolean
allPassed: boolean
totalSubdomains: number
passedCount: number
lastCheckedAt: string | null
verifiedAt: string | null
records: DnsRecord[]
}
export interface DnsStatusResponse {
verification: DnsVerification | null
requiredSubdomains: string[]
serverIp: string | null
domain: string | null
}
export interface DnsVerifyResponse {
success: boolean
verification: DnsVerification
allPassed: boolean
message: string
}
export interface DnsSkipResponse {
success: boolean
message: string
}
// Query keys
export const dnsKeys = {
all: ['dns'] as const,
verification: (orderId: string) => [...dnsKeys.all, 'verification', orderId] as const,
}
// API calls
async function getDnsStatus(orderId: string): Promise<DnsStatusResponse> {
return apiGet<DnsStatusResponse>(`/api/v1/admin/orders/${orderId}/dns`)
}
async function triggerDnsVerification(orderId: string): Promise<DnsVerifyResponse> {
return apiPost<DnsVerifyResponse>(`/api/v1/admin/orders/${orderId}/dns/verify`, {})
}
async function skipDnsVerification(orderId: string): Promise<DnsSkipResponse> {
return apiPost<DnsSkipResponse>(`/api/v1/admin/orders/${orderId}/dns/skip`, {})
}
// Hooks
export function useDnsVerification(orderId: string) {
return useQuery({
queryKey: dnsKeys.verification(orderId),
queryFn: () => getDnsStatus(orderId),
enabled: !!orderId,
refetchInterval: false, // Manual refresh only
})
}
export function useTriggerDnsVerification() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (orderId: string) => triggerDnsVerification(orderId),
onSuccess: (_, orderId) => {
// Invalidate DNS verification cache
queryClient.invalidateQueries({ queryKey: dnsKeys.verification(orderId) })
// Also invalidate order to get updated status
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) })
},
})
}
export function useSkipDnsVerification() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (orderId: string) => skipDnsVerification(orderId),
onSuccess: (_, orderId) => {
// Invalidate DNS verification cache
queryClient.invalidateQueries({ queryKey: dnsKeys.verification(orderId) })
// Also invalidate order to get updated status
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) })
},
})
}

View File

@@ -0,0 +1,562 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getEnterpriseClients,
getEnterpriseClient,
createEnterpriseClient,
updateEnterpriseClient,
deleteEnterpriseClient,
getClientServers,
getClientServer,
addClientServer,
updateClientServer,
removeClientServer,
testServerPortainerConnection,
performServerAction,
requestVerificationCode,
getErrorRules,
createErrorRule,
updateErrorRule,
deleteErrorRule,
getDetectedErrors,
acknowledgeError,
getClientStats,
getServerStats,
collectServerStats,
getServerContainers,
getContainer,
getContainerLogs,
performContainerAction,
removeContainer,
getErrorDashboard,
getContainerEvents,
acknowledgeContainerEvents,
getErrorSummary,
getNotificationSettings,
updateNotificationSettings,
} from '@/lib/api/admin'
import type { StatsRange, ContainerEventFilters, UpdateNotificationSettingsPayload } from '@/lib/api/admin'
import type {
CreateEnterpriseClientPayload,
UpdateEnterpriseClientPayload,
AddServerPayload,
UpdateServerPayload,
ServerActionPayload,
CreateErrorRulePayload,
UpdateErrorRulePayload,
ErrorFilters,
} from '@/types/api'
// ============================================================================
// Query Keys
// ============================================================================
export const enterpriseClientKeys = {
all: ['enterprise-clients'] as const,
lists: () => [...enterpriseClientKeys.all, 'list'] as const,
list: () => [...enterpriseClientKeys.lists()] as const,
details: () => [...enterpriseClientKeys.all, 'detail'] as const,
detail: (id: string) => [...enterpriseClientKeys.details(), id] as const,
servers: (clientId: string) => [...enterpriseClientKeys.detail(clientId), 'servers'] as const,
server: (clientId: string, serverId: string) => [...enterpriseClientKeys.servers(clientId), serverId] as const,
serverStats: (clientId: string, serverId: string, range: StatsRange) =>
[...enterpriseClientKeys.server(clientId, serverId), 'stats', range] as const,
clientStats: (clientId: string, range: StatsRange) =>
[...enterpriseClientKeys.detail(clientId), 'stats', range] as const,
errorRules: (clientId: string) => [...enterpriseClientKeys.detail(clientId), 'error-rules'] as const,
errors: (clientId: string, filters?: ErrorFilters) => [...enterpriseClientKeys.detail(clientId), 'errors', filters] as const,
containers: (clientId: string, serverId: string) =>
[...enterpriseClientKeys.server(clientId, serverId), 'containers'] as const,
container: (clientId: string, serverId: string, containerId: string) =>
[...enterpriseClientKeys.containers(clientId, serverId), containerId] as const,
containerLogs: (clientId: string, serverId: string, containerId: string, tail: number) =>
[...enterpriseClientKeys.container(clientId, serverId, containerId), 'logs', tail] as const,
// Error tracking keys
errorDashboard: (clientId: string) => [...enterpriseClientKeys.detail(clientId), 'error-dashboard'] as const,
containerEvents: (clientId: string, filters?: ContainerEventFilters) =>
[...enterpriseClientKeys.detail(clientId), 'container-events', filters] as const,
errorSummary: () => [...enterpriseClientKeys.all, 'error-summary'] as const,
// Notification settings key
notifications: (clientId: string) => [...enterpriseClientKeys.detail(clientId), 'notifications'] as const,
}
// ============================================================================
// Client Hooks
// ============================================================================
export function useEnterpriseClients() {
return useQuery({
queryKey: enterpriseClientKeys.list(),
queryFn: getEnterpriseClients,
})
}
export function useEnterpriseClient(id: string) {
return useQuery({
queryKey: enterpriseClientKeys.detail(id),
queryFn: () => getEnterpriseClient(id),
enabled: !!id,
})
}
export function useCreateEnterpriseClient() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateEnterpriseClientPayload) => createEnterpriseClient(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.lists() })
},
})
}
export function useUpdateEnterpriseClient() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateEnterpriseClientPayload }) =>
updateEnterpriseClient(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.detail(id) })
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.lists() })
},
})
}
export function useDeleteEnterpriseClient() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteEnterpriseClient(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.lists() })
},
})
}
// ============================================================================
// Server Hooks
// ============================================================================
export function useClientServers(clientId: string) {
return useQuery({
queryKey: enterpriseClientKeys.servers(clientId),
queryFn: () => getClientServers(clientId),
enabled: !!clientId,
})
}
export function useClientServer(clientId: string, serverId: string) {
return useQuery({
queryKey: enterpriseClientKeys.server(clientId, serverId),
queryFn: () => getClientServer(clientId, serverId),
enabled: !!clientId && !!serverId,
})
}
export function useAddServerToClient() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, data }: { clientId: string; data: AddServerPayload }) =>
addClientServer(clientId, data),
onSuccess: (_, { clientId }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.servers(clientId) })
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.detail(clientId) })
},
})
}
export function useUpdateClientServer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
clientId,
serverId,
data,
}: {
clientId: string
serverId: string
data: UpdateServerPayload
}) => updateClientServer(clientId, serverId, data),
onSuccess: (_, { clientId, serverId }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.server(clientId, serverId) })
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.servers(clientId) })
},
})
}
export function useRemoveServerFromClient() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, serverId }: { clientId: string; serverId: string }) =>
removeClientServer(clientId, serverId),
onSuccess: (_, { clientId }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.servers(clientId) })
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.detail(clientId) })
},
})
}
export function useTestPortainerConnection() {
return useMutation({
mutationFn: ({
clientId,
serverId,
credentials,
}: {
clientId: string
serverId: string
credentials: { portainerUrl: string; portainerUsername: string; portainerPassword: string }
}) => testServerPortainerConnection(clientId, serverId, credentials),
})
}
// ============================================================================
// Server Action Hooks
// ============================================================================
export function useServerAction() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
clientId,
serverId,
action,
}: {
clientId: string
serverId: string
action: ServerActionPayload
}) => performServerAction(clientId, serverId, action),
onSuccess: (_, { clientId, serverId }) => {
// Refresh server status after action
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.server(clientId, serverId) })
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.servers(clientId) })
},
})
}
export function useRequestVerificationCode() {
return useMutation({
mutationFn: ({
clientId,
serverId,
action,
}: {
clientId: string
serverId: string
action: 'WIPE' | 'REINSTALL'
}) => requestVerificationCode(clientId, serverId, action),
})
}
// ============================================================================
// Error Rule Hooks
// ============================================================================
export function useErrorRules(clientId: string) {
return useQuery({
queryKey: enterpriseClientKeys.errorRules(clientId),
queryFn: () => getErrorRules(clientId),
enabled: !!clientId,
})
}
export function useCreateErrorRule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, data }: { clientId: string; data: CreateErrorRulePayload }) =>
createErrorRule(clientId, data),
onSuccess: (_, { clientId }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.errorRules(clientId) })
},
})
}
export function useUpdateErrorRule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
clientId,
ruleId,
data,
}: {
clientId: string
ruleId: string
data: UpdateErrorRulePayload
}) => updateErrorRule(clientId, ruleId, data),
onSuccess: (_, { clientId }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.errorRules(clientId) })
},
})
}
export function useDeleteErrorRule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, ruleId }: { clientId: string; ruleId: string }) =>
deleteErrorRule(clientId, ruleId),
onSuccess: (_, { clientId }) => {
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.errorRules(clientId) })
},
})
}
// ============================================================================
// Detected Error Hooks
// ============================================================================
export function useDetectedErrors(clientId: string, filters?: ErrorFilters) {
return useQuery({
queryKey: enterpriseClientKeys.errors(clientId, filters),
queryFn: () => getDetectedErrors(clientId, filters),
enabled: !!clientId,
})
}
export function useAcknowledgeError() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, errorId }: { clientId: string; errorId: string }) =>
acknowledgeError(clientId, errorId),
onSuccess: (_, { clientId }) => {
// Invalidate all error queries for this client
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.detail(clientId),
predicate: (query) => {
const key = query.queryKey
return Array.isArray(key) && key.includes('errors')
},
})
// Also refresh the client detail to update error count
queryClient.invalidateQueries({ queryKey: enterpriseClientKeys.detail(clientId) })
},
})
}
// ============================================================================
// Stats Hooks
// ============================================================================
export function useClientStatsOverview(clientId: string, range: StatsRange = '24h') {
return useQuery({
queryKey: enterpriseClientKeys.clientStats(clientId, range),
queryFn: () => getClientStats(clientId, range),
enabled: !!clientId,
refetchInterval: 60000, // Refresh every minute
})
}
export function useServerStatsHistory(
clientId: string,
serverId: string,
range: StatsRange = '24h'
) {
return useQuery({
queryKey: enterpriseClientKeys.serverStats(clientId, serverId, range),
queryFn: () => getServerStats(clientId, serverId, range),
enabled: !!clientId && !!serverId,
refetchInterval: 30000, // Refresh every 30 seconds
})
}
export function useCollectServerStats() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, serverId }: { clientId: string; serverId: string }) =>
collectServerStats(clientId, serverId),
onSuccess: (_, { clientId, serverId }) => {
// Invalidate all stats queries for this server
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.server(clientId, serverId),
})
},
})
}
// ============================================================================
// Container Hooks
// ============================================================================
export function useServerContainers(clientId: string, serverId: string, all: boolean = true) {
return useQuery({
queryKey: enterpriseClientKeys.containers(clientId, serverId),
queryFn: () => getServerContainers(clientId, serverId, all),
enabled: !!clientId && !!serverId,
refetchInterval: 10000, // Refresh every 10 seconds
})
}
export function useContainer(clientId: string, serverId: string, containerId: string) {
return useQuery({
queryKey: enterpriseClientKeys.container(clientId, serverId, containerId),
queryFn: () => getContainer(clientId, serverId, containerId),
enabled: !!clientId && !!serverId && !!containerId,
})
}
export function useContainerLogs(
clientId: string,
serverId: string,
containerId: string,
tail: number = 500
) {
return useQuery({
queryKey: enterpriseClientKeys.containerLogs(clientId, serverId, containerId, tail),
queryFn: () => getContainerLogs(clientId, serverId, containerId, tail),
enabled: !!clientId && !!serverId && !!containerId,
})
}
export function useContainerAction() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
clientId,
serverId,
containerId,
action,
}: {
clientId: string
serverId: string
containerId: string
action: 'start' | 'stop' | 'restart'
}) => performContainerAction(clientId, serverId, containerId, action),
onSuccess: (_, { clientId, serverId }) => {
// Invalidate container list to refresh status
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.containers(clientId, serverId),
})
},
})
}
export function useRemoveContainer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
clientId,
serverId,
containerId,
force,
}: {
clientId: string
serverId: string
containerId: string
force?: boolean
}) => removeContainer(clientId, serverId, containerId, force),
onSuccess: (_, { clientId, serverId }) => {
// Invalidate container list
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.containers(clientId, serverId),
})
},
})
}
// ============================================================================
// Error Dashboard Hooks
// ============================================================================
export function useErrorDashboard(clientId: string) {
return useQuery({
queryKey: enterpriseClientKeys.errorDashboard(clientId),
queryFn: () => getErrorDashboard(clientId),
enabled: !!clientId,
refetchInterval: 60000, // Refresh every minute
})
}
// ============================================================================
// Container Events Hooks
// ============================================================================
export function useContainerEvents(clientId: string, filters?: ContainerEventFilters) {
return useQuery({
queryKey: enterpriseClientKeys.containerEvents(clientId, filters),
queryFn: () => getContainerEvents(clientId, filters),
enabled: !!clientId,
refetchInterval: 30000, // Refresh every 30 seconds
})
}
export function useAcknowledgeContainerEvents() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ clientId, eventIds }: { clientId: string; eventIds: string[] }) =>
acknowledgeContainerEvents(clientId, eventIds),
onSuccess: (_, { clientId }) => {
// Invalidate container events queries for this client
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.detail(clientId),
predicate: (query) => {
const key = query.queryKey
return Array.isArray(key) && key.includes('container-events')
},
})
// Also refresh the error dashboard
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.errorDashboard(clientId),
})
// Also refresh the global error summary
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.errorSummary(),
})
},
})
}
// ============================================================================
// All Clients Error Summary Hooks
// ============================================================================
export function useAllClientsErrorSummary() {
return useQuery({
queryKey: enterpriseClientKeys.errorSummary(),
queryFn: getErrorSummary,
refetchInterval: 60000, // Refresh every minute
})
}
// ============================================================================
// Notification Settings Hooks
// ============================================================================
export function useNotificationSettings(clientId: string) {
return useQuery({
queryKey: enterpriseClientKeys.notifications(clientId),
queryFn: () => getNotificationSettings(clientId),
enabled: !!clientId,
})
}
export function useUpdateNotificationSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
clientId,
data,
}: {
clientId: string
data: UpdateNotificationSettingsPayload
}) => updateNotificationSettings(clientId, data),
onSuccess: (_, { clientId }) => {
queryClient.invalidateQueries({
queryKey: enterpriseClientKeys.notifications(clientId),
})
},
})
}

836
src/hooks/use-netcup.ts Normal file
View File

@@ -0,0 +1,836 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useRef, useEffect } from 'react'
import { apiGet, apiPost } from '@/lib/api/client'
// Types
export interface NetcupServer {
id: string
name: string
nickname?: string
state: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET' | 'POWEROFF' | 'UNKNOWN'
primaryIpv4?: string
primaryIpv6?: string
hostname?: string
cpuCores?: number
ramGb?: number
diskGb?: number
cpuUsage?: number
ramUsage?: number
diskUsage?: number
}
export interface NetcupAuthStatus {
authenticated: boolean
expiresAt: string | null
}
export interface DeviceAuthResponse {
success: boolean
sessionId: string
userCode: string
verificationUri: string
verificationUriComplete: string
expiresIn: number
interval: number
}
export interface PollResponse {
success: boolean
status: 'pending' | 'authenticated'
message?: string
}
export interface ImageFlavour {
id: string
name: string
description?: string
category: string
}
export interface RescueCredentials {
password: string
expiresAt: string
}
export type PowerAction = 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET' | 'POWEROFF'
// Metrics types
export interface MetricsDataPoint {
timestamp: string
value: number
}
export interface CpuMetrics {
dataPoints: MetricsDataPoint[]
average: number
max: number
}
export interface DiskMetrics {
readBps: MetricsDataPoint[]
writeBps: MetricsDataPoint[]
readIops: MetricsDataPoint[]
writeIops: MetricsDataPoint[]
}
export interface NetworkMetrics {
rxBps: MetricsDataPoint[]
txBps: MetricsDataPoint[]
}
export interface ServerMetrics {
cpu: CpuMetrics
disk: DiskMetrics
network: NetworkMetrics
period: string
}
// Snapshot types
export interface Snapshot {
name: string
createdAt: string
size?: number
description?: string
}
// Portainer status types
export interface PortainerStatus {
available: boolean
version?: string
instanceId?: string | null
needsSetup?: boolean
error?: string
}
// Query keys
export const netcupKeys = {
all: ['netcup'] as const,
auth: () => [...netcupKeys.all, 'auth'] as const,
servers: () => [...netcupKeys.all, 'servers'] as const,
serversList: (liveInfo = true) => [...netcupKeys.servers(), 'list', { liveInfo }] as const,
server: (id: string) => [...netcupKeys.servers(), id] as const,
serverWithLive: (id: string) => [...netcupKeys.server(id), 'live'] as const,
imageFlavours: (id: string) => [...netcupKeys.server(id), 'flavours'] as const,
metrics: (id: string, hours?: number) => [...netcupKeys.server(id), 'metrics', hours ?? 24] as const,
snapshots: (id: string) => [...netcupKeys.server(id), 'snapshots'] as const,
portainerStatus: (ip: string) => [...netcupKeys.all, 'portainer', ip] as const,
task: (taskId: string) => [...netcupKeys.all, 'task', taskId] as const,
}
// API calls
async function getAuthStatus(): Promise<NetcupAuthStatus> {
return apiGet<NetcupAuthStatus>('/api/v1/admin/netcup/auth')
}
async function initiateAuth(): Promise<DeviceAuthResponse> {
return apiPost<DeviceAuthResponse>('/api/v1/admin/netcup/auth', {
action: 'initiate',
})
}
async function pollAuth(sessionId: string): Promise<PollResponse> {
return apiPost<PollResponse>('/api/v1/admin/netcup/auth', {
action: 'poll',
sessionId,
})
}
async function disconnect(): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>('/api/v1/admin/netcup/auth', {
action: 'disconnect',
})
}
async function getServers(liveInfo = true): Promise<{ servers: NetcupServer[]; count: number }> {
return apiGet<{ servers: NetcupServer[]; count: number }>('/api/v1/admin/netcup/servers', {
params: { liveInfo },
})
}
async function getServer(id: string, liveInfo = false): Promise<NetcupServer> {
return apiGet<NetcupServer>(`/api/v1/admin/netcup/servers/${id}`, {
params: { liveInfo },
})
}
async function performPowerAction(
serverId: string,
powerAction: PowerAction
): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/v1/admin/netcup/servers/${serverId}`, {
action: 'power',
powerAction,
})
}
async function reinstallServer(
serverId: string,
imageFlavour: string
): Promise<{ success: boolean; taskId: string }> {
return apiPost<{ success: boolean; taskId: string }>(
`/api/v1/admin/netcup/servers/${serverId}`,
{ action: 'reinstall', imageFlavour }
)
}
async function activateRescue(
serverId: string
): Promise<{ success: boolean; credentials: RescueCredentials }> {
return apiPost<{ success: boolean; credentials: RescueCredentials }>(
`/api/v1/admin/netcup/servers/${serverId}`,
{ action: 'rescue', rescueAction: 'activate' }
)
}
async function deactivateRescue(serverId: string): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/v1/admin/netcup/servers/${serverId}`, {
action: 'rescue',
rescueAction: 'deactivate',
})
}
async function updateHostname(
serverId: string,
hostname: string
): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/v1/admin/netcup/servers/${serverId}`, {
action: 'hostname',
hostname,
})
}
async function updateNickname(
serverId: string,
nickname: string
): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>(`/api/v1/admin/netcup/servers/${serverId}`, {
action: 'nickname',
nickname,
})
}
async function getImageFlavours(
serverId: string
): Promise<{ success: boolean; flavours: ImageFlavour[] }> {
return apiPost<{ success: boolean; flavours: ImageFlavour[] }>(
`/api/v1/admin/netcup/servers/${serverId}`,
{ action: 'imageFlavours' }
)
}
// Metrics API calls
async function getServerMetrics(
serverId: string,
hours = 24
): Promise<ServerMetrics> {
return apiGet<ServerMetrics>(`/api/v1/admin/netcup/servers/${serverId}/metrics`, {
params: { hours },
})
}
// Snapshot API calls
async function getSnapshots(
serverId: string
): Promise<{ snapshots: Snapshot[]; count: number }> {
return apiGet<{ snapshots: Snapshot[]; count: number }>(
`/api/v1/admin/netcup/servers/${serverId}/snapshots`
)
}
async function createSnapshot(
serverId: string,
name?: string
): Promise<{ success: boolean; message: string; snapshot?: { name: string } }> {
return apiPost<{ success: boolean; message: string; snapshot?: { name: string } }>(
`/api/v1/admin/netcup/servers/${serverId}/snapshots`,
{ action: 'create', name }
)
}
async function deleteSnapshot(
serverId: string,
name: string
): Promise<{ success: boolean; message: string }> {
return apiPost<{ success: boolean; message: string }>(
`/api/v1/admin/netcup/servers/${serverId}/snapshots`,
{ action: 'delete', name }
)
}
async function revertSnapshot(
serverId: string,
name: string
): Promise<{ success: boolean; message: string; taskId?: string }> {
return apiPost<{ success: boolean; message: string; taskId?: string }>(
`/api/v1/admin/netcup/servers/${serverId}/snapshots`,
{ action: 'revert', name }
)
}
async function checkCanCreateSnapshot(
serverId: string
): Promise<{ possible: boolean; reason?: string }> {
return apiPost<{ possible: boolean; reason?: string }>(
`/api/v1/admin/netcup/servers/${serverId}/snapshots`,
{ action: 'check' }
)
}
async function checkPortainerStatus(
ip: string,
port = '9443'
): Promise<PortainerStatus> {
return apiGet<PortainerStatus>('/api/v1/admin/portainer/ping', {
params: { ip, port },
})
}
// Task status types
export interface TaskStatus {
taskId: string
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'UNKNOWN'
progress?: number
message?: string
result?: unknown
error?: string
}
async function getTaskStatus(taskId: string): Promise<TaskStatus> {
return apiGet<TaskStatus>(`/api/v1/admin/netcup/tasks/${taskId}`)
}
// Hooks
export function useNetcupAuth() {
return useQuery({
queryKey: netcupKeys.auth(),
queryFn: getAuthStatus,
staleTime: 1000 * 60, // 1 minute
})
}
export function useNetcupServers(liveInfo = true) {
return useQuery({
queryKey: netcupKeys.serversList(liveInfo),
queryFn: () => getServers(liveInfo),
// Live info takes longer, so increase stale time
staleTime: liveInfo ? 1000 * 30 : 1000 * 10, // 30s with live info, 10s without
})
}
export function useNetcupServer(id: string, liveInfo = false) {
return useQuery({
queryKey: liveInfo ? netcupKeys.serverWithLive(id) : netcupKeys.server(id),
queryFn: () => getServer(id, liveInfo),
enabled: !!id,
})
}
export function useNetcupImageFlavours(serverId: string) {
return useQuery({
queryKey: netcupKeys.imageFlavours(serverId),
queryFn: () => getImageFlavours(serverId),
enabled: !!serverId,
})
}
export function useInitiateNetcupAuth() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: initiateAuth,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: netcupKeys.auth() })
},
})
}
export function usePollNetcupAuth() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: pollAuth,
onSuccess: (data) => {
if (data.status === 'authenticated') {
queryClient.invalidateQueries({ queryKey: netcupKeys.auth() })
// Invalidate all server-related queries
queryClient.invalidateQueries({ queryKey: netcupKeys.servers() })
}
},
})
}
export function useDisconnectNetcup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: disconnect,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: netcupKeys.auth() })
// Invalidate all server-related queries
queryClient.invalidateQueries({ queryKey: netcupKeys.servers() })
},
})
}
// Track pending power actions and their expected final states
const pendingPowerActions = new Map<string, { expectedState: 'ON' | 'OFF'; startTime: number }>()
// Track servers that are being reinstalled (minimum 5 minutes)
const REINSTALL_MIN_DURATION_MS = 5 * 60 * 1000 // 5 minutes
const pendingReinstalls = new Map<string, { startTime: number }>()
// Mark a server as being reinstalled
export function markServerReinstalling(serverId: string): void {
pendingReinstalls.set(serverId, { startTime: Date.now() })
}
// Clear reinstall state for a server
export function clearServerReinstalling(serverId: string): void {
pendingReinstalls.delete(serverId)
}
// Check if a server is currently being reinstalled
export function isServerReinstalling(serverId: string): boolean {
const pending = pendingReinstalls.get(serverId)
if (!pending) return false
const elapsed = Date.now() - pending.startTime
if (elapsed >= REINSTALL_MIN_DURATION_MS) {
pendingReinstalls.delete(serverId)
return false
}
return true
}
export function useNetcupPowerAction() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, action }: { serverId: string; action: PowerAction }) =>
performPowerAction(serverId, action),
onMutate: async ({ serverId, action }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: netcupKeys.server(serverId) })
await queryClient.cancelQueries({ queryKey: netcupKeys.servers() })
// Snapshot previous values
const previousServer = queryClient.getQueryData<NetcupServer>(netcupKeys.server(serverId))
const previousServerLive = queryClient.getQueryData<NetcupServer>(netcupKeys.serverWithLive(serverId))
// Determine the transitional state to show and expected final state
let transitionalState: string
let expectedState: 'ON' | 'OFF'
switch (action) {
case 'ON':
transitionalState = 'POWERON' // Shows "Powering On"
expectedState = 'ON'
break
case 'OFF':
transitionalState = 'POWEROFF' // Shows "Shutting Down"
expectedState = 'OFF'
break
case 'POWERCYCLE':
case 'RESET':
transitionalState = action // Shows "Restarting" or "Hard Resetting"
expectedState = 'ON' // Server should come back ON
break
case 'POWEROFF':
transitionalState = action // Shows "Shutting Down"
expectedState = 'OFF'
break
default:
transitionalState = action
expectedState = 'ON'
}
// Track this power action
pendingPowerActions.set(serverId, { expectedState, startTime: Date.now() })
// Optimistically update the server state
if (previousServer) {
queryClient.setQueryData<NetcupServer>(netcupKeys.server(serverId), {
...previousServer,
state: transitionalState as NetcupServer['state'],
})
}
if (previousServerLive) {
queryClient.setQueryData<NetcupServer>(netcupKeys.serverWithLive(serverId), {
...previousServerLive,
state: transitionalState as NetcupServer['state'],
})
}
// Optimistically update server lists
queryClient.setQueriesData<{ servers: NetcupServer[]; count: number } | undefined>(
{ queryKey: netcupKeys.servers() },
(old) => {
if (!old?.servers) return old
return {
...old,
servers: old.servers.map((s) =>
s.id === serverId ? { ...s, state: transitionalState as NetcupServer['state'] } : s
),
}
}
)
return { previousServer, previousServerLive }
},
onError: (_, { serverId }, context) => {
// Rollback on error and clear pending action
pendingPowerActions.delete(serverId)
if (context?.previousServer) {
queryClient.setQueryData(netcupKeys.server(serverId), context.previousServer)
}
if (context?.previousServerLive) {
queryClient.setQueryData(netcupKeys.serverWithLive(serverId), context.previousServerLive)
}
},
onSettled: async (_, __, { serverId }) => {
// Don't invalidate - let polling handle state updates
// The pending action will be cleared when we reach the expected state
},
})
}
// Check if we should update the UI with a new state from the API
export function shouldUpdateServerState(serverId: string, newState: string): boolean {
// Check if server is being reinstalled - don't update state during reinstall
if (isServerReinstalling(serverId)) {
return false // Keep showing REINSTALLING state
}
// Check for pending power actions
const pending = pendingPowerActions.get(serverId)
if (!pending) return true // No pending action, always update
// If we've been waiting more than 2 minutes, give up and update
if (Date.now() - pending.startTime > 120000) {
pendingPowerActions.delete(serverId)
return true
}
// Only update if we've reached the expected final state
if (newState === pending.expectedState) {
pendingPowerActions.delete(serverId)
return true
}
// Don't update - keep showing transitional state
return false
}
export function useNetcupReinstall() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
serverId,
imageFlavour,
}: {
serverId: string
imageFlavour: string
}) => reinstallServer(serverId, imageFlavour),
onMutate: async ({ serverId }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: netcupKeys.server(serverId) })
// Mark server as reinstalling - this prevents state updates for 5 minutes
markServerReinstalling(serverId)
// Snapshot previous values
const previousServer = queryClient.getQueryData<NetcupServer>(netcupKeys.server(serverId))
const previousServerLive = queryClient.getQueryData<NetcupServer>(netcupKeys.serverWithLive(serverId))
// Optimistically update to REINSTALLING state
if (previousServer) {
queryClient.setQueryData<NetcupServer>(netcupKeys.server(serverId), {
...previousServer,
state: 'REINSTALLING' as NetcupServer['state'],
})
}
if (previousServerLive) {
queryClient.setQueryData<NetcupServer>(netcupKeys.serverWithLive(serverId), {
...previousServerLive,
state: 'REINSTALLING' as NetcupServer['state'],
})
}
// Update server lists
queryClient.setQueriesData<{ servers: NetcupServer[]; count: number } | undefined>(
{ queryKey: netcupKeys.servers() },
(old) => {
if (!old?.servers) return old
return {
...old,
servers: old.servers.map((s) =>
s.id === serverId ? { ...s, state: 'REINSTALLING' as NetcupServer['state'] } : s
),
}
}
)
return { previousServer, previousServerLive }
},
onError: (_, { serverId }, context) => {
// Clear reinstall state on error
clearServerReinstalling(serverId)
// Rollback optimistic update
if (context?.previousServer) {
queryClient.setQueryData(netcupKeys.server(serverId), context.previousServer)
}
if (context?.previousServerLive) {
queryClient.setQueryData(netcupKeys.serverWithLive(serverId), context.previousServerLive)
}
},
// Don't invalidate on settled - let the pendingReinstalls timer handle when to refresh
// The shouldUpdateServerState check will prevent premature state updates
})
}
export function useNetcupRescue() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
serverId,
activate,
}: {
serverId: string
activate: boolean
}) => (activate ? activateRescue(serverId) : deactivateRescue(serverId)),
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({ queryKey: netcupKeys.server(serverId) })
},
})
}
export function useNetcupUpdateHostname() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, hostname }: { serverId: string; hostname: string }) =>
updateHostname(serverId, hostname),
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({ queryKey: netcupKeys.server(serverId) })
// Invalidate all server lists
queryClient.invalidateQueries({ queryKey: netcupKeys.servers() })
},
})
}
export function useNetcupUpdateNickname() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, nickname }: { serverId: string; nickname: string }) =>
updateNickname(serverId, nickname),
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({ queryKey: netcupKeys.server(serverId) })
// Invalidate all server lists
queryClient.invalidateQueries({ queryKey: netcupKeys.servers() })
},
})
}
// Live Status Fetch Hook
export function useFetchLiveStatus() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (serverId: string) => getServer(serverId, true),
onSuccess: (data, serverId) => {
// Check if we should update based on pending power actions
if (!shouldUpdateServerState(serverId, data.state)) {
return // Don't update - keep showing transitional state
}
// Update server in cache with live data
queryClient.setQueryData(netcupKeys.serverWithLive(serverId), data)
queryClient.setQueryData(netcupKeys.server(serverId), data)
// Update all server lists with the new status (both live and non-live cache keys)
queryClient.setQueriesData<{ servers: NetcupServer[]; count: number } | undefined>(
{ queryKey: netcupKeys.servers() },
(old) => {
if (!old?.servers) return old
return {
...old,
servers: old.servers.map((s) =>
s.id === serverId ? { ...s, ...data } : s
),
}
}
)
},
})
}
// Helper to check if server is in transitional state
export function isTransitionalState(state: string): boolean {
return ['POWERCYCLE', 'RESET', 'POWEROFF', 'POWERON', 'REINSTALLING'].includes(state)
}
// Metrics Hooks
export function useServerMetrics(serverId: string, hours = 24, enabled = true) {
return useQuery({
queryKey: netcupKeys.metrics(serverId, hours),
queryFn: () => getServerMetrics(serverId, hours),
enabled: !!serverId && enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
})
}
export function useRefreshMetrics() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, hours = 24 }: { serverId: string; hours?: number }) =>
getServerMetrics(serverId, hours),
onSuccess: (data, { serverId, hours = 24 }) => {
queryClient.setQueryData(netcupKeys.metrics(serverId, hours), data)
},
})
}
// Snapshot Hooks
export function useServerSnapshots(serverId: string, enabled = true) {
return useQuery({
queryKey: netcupKeys.snapshots(serverId),
queryFn: () => getSnapshots(serverId),
enabled: !!serverId && enabled,
})
}
export function useCreateSnapshot() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, name }: { serverId: string; name?: string }) =>
createSnapshot(serverId, name),
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({ queryKey: netcupKeys.snapshots(serverId) })
},
})
}
export function useDeleteSnapshot() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, name }: { serverId: string; name: string }) =>
deleteSnapshot(serverId, name),
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({ queryKey: netcupKeys.snapshots(serverId) })
},
})
}
export function useRevertSnapshot() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ serverId, name }: { serverId: string; name: string }) =>
revertSnapshot(serverId, name),
onSuccess: (_, { serverId }) => {
queryClient.invalidateQueries({ queryKey: netcupKeys.snapshots(serverId) })
queryClient.invalidateQueries({ queryKey: netcupKeys.server(serverId) })
},
})
}
export function useCheckCanCreateSnapshot() {
return useMutation({
mutationFn: (serverId: string) => checkCanCreateSnapshot(serverId),
})
}
// Portainer Status Hook
export function usePortainerStatus(ip: string | undefined, enabled = true) {
return useQuery({
queryKey: netcupKeys.portainerStatus(ip || ''),
queryFn: () => checkPortainerStatus(ip!),
enabled: !!ip && enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
retry: false, // Don't retry on failure - server might just not have Portainer
})
}
// Task Status Hook - polls for long-running operations like reinstall
export function useTaskStatus(
taskId: string | null | undefined,
options?: {
onCompleted?: () => void
onFailed?: (error: string) => void
pollInterval?: number
}
) {
const queryClient = useQueryClient()
const { onCompleted, onFailed, pollInterval = 5000 } = options || {}
// Track previous status to detect changes (prevent repeated callback calls)
const prevStatusRef = useRef<string | undefined>(undefined)
const callbacksFiredRef = useRef<{ completed: boolean; failed: boolean }>({ completed: false, failed: false })
const query = useQuery({
queryKey: netcupKeys.task(taskId || ''),
queryFn: () => getTaskStatus(taskId!),
enabled: !!taskId,
refetchInterval: (q) => {
const data = q.state.data
// Stop polling when task is done
if (data?.status === 'COMPLETED' || data?.status === 'FAILED') {
return false
}
return pollInterval
},
refetchIntervalInBackground: true,
})
// Handle callbacks via useEffect - only fire once when status changes to terminal state
useEffect(() => {
const currentStatus = query.data?.status
// Reset callback tracking when taskId changes
if (!taskId) {
prevStatusRef.current = undefined
callbacksFiredRef.current = { completed: false, failed: false }
return
}
// Only process if status actually changed
if (currentStatus && currentStatus !== prevStatusRef.current) {
prevStatusRef.current = currentStatus
// Fire onCompleted exactly once when task completes
if (currentStatus === 'COMPLETED' && onCompleted && !callbacksFiredRef.current.completed) {
callbacksFiredRef.current.completed = true
onCompleted()
// Note: We do NOT invalidate server queries here - let the page handle state
// The reinstall minimum duration logic in the page will control when to refresh
}
// Fire onFailed exactly once when task fails
if (currentStatus === 'FAILED' && onFailed && !callbacksFiredRef.current.failed) {
callbacksFiredRef.current.failed = true
onFailed(query.data?.error || 'Task failed')
}
}
}, [taskId, query.data?.status, query.data?.error, onCompleted, onFailed])
// Reset refs when taskId changes
useEffect(() => {
prevStatusRef.current = undefined
callbacksFiredRef.current = { completed: false, failed: false }
}, [taskId])
return query
}

View File

@@ -7,6 +7,7 @@ import {
createOrder,
updateOrder,
triggerProvisioning,
deleteOrder,
} from '@/lib/api/admin'
import type {
OrderFilters,
@@ -74,3 +75,15 @@ export function useTriggerProvisioning() {
},
})
}
export function useDeleteOrder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (orderId: string) => deleteOrder(orderId),
onSuccess: () => {
// Invalidate all order lists since the order is now gone
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
},
})
}

331
src/hooks/use-portainer.ts Normal file
View File

@@ -0,0 +1,331 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Types
export interface PortainerCredentials {
url: string | null
username: string | null
password: string | null
syncedAt: string | null
isConfigured: boolean
}
export interface Container {
id: string
shortId: string
name: string
image: string
state: string
status: string
created: number
ports: Array<{
private: number
public?: number
type: string
}>
}
export interface ContainerDetails extends Container {
config: {
hostname: string
image: string
workingDir: string
env: string[]
}
hostConfig: {
restartPolicy: {
Name: string
MaximumRetryCount: number
}
}
networks: Record<string, { IPAddress: string }>
}
export interface ContainerStats {
cpuPercent: number
memoryUsage: number // bytes
memoryLimit: number // bytes
memoryPercent: number
networkRx: number // bytes
networkTx: number // bytes
}
// Query keys
export const portainerKeys = {
all: ['portainer'] as const,
credentials: (orderId: string) => [...portainerKeys.all, 'credentials', orderId] as const,
containers: (orderId: string) => [...portainerKeys.all, 'containers', orderId] as const,
containerStats: (orderId: string) => [...portainerKeys.all, 'containerStats', orderId] as const,
container: (orderId: string, containerId: string) =>
[...portainerKeys.containers(orderId), containerId] as const,
containerLogs: (orderId: string, containerId: string) =>
[...portainerKeys.container(orderId, containerId), 'logs'] as const,
singleContainerStats: (orderId: string, containerId: string) =>
[...portainerKeys.container(orderId, containerId), 'stats'] as const,
}
// Fetch functions
async function fetchCredentials(orderId: string): Promise<PortainerCredentials> {
const response = await fetch(`/api/v1/admin/orders/${orderId}/portainer`)
if (!response.ok) {
throw new Error('Failed to fetch Portainer credentials')
}
return response.json()
}
async function fetchContainers(orderId: string): Promise<Container[]> {
const response = await fetch(`/api/v1/admin/orders/${orderId}/containers`)
if (!response.ok) {
throw new Error('Failed to fetch containers')
}
return response.json()
}
async function fetchContainerDetails(
orderId: string,
containerId: string
): Promise<ContainerDetails> {
const response = await fetch(`/api/v1/admin/orders/${orderId}/containers/${containerId}`)
if (!response.ok) {
throw new Error('Failed to fetch container details')
}
return response.json()
}
async function fetchContainerLogs(
orderId: string,
containerId: string,
tail: number = 100
): Promise<string> {
const response = await fetch(
`/api/v1/admin/orders/${orderId}/containers/${containerId}/logs?tail=${tail}`
)
if (!response.ok) {
throw new Error('Failed to fetch container logs')
}
const data = await response.json()
return data.logs
}
async function fetchContainerStats(
orderId: string
): Promise<Record<string, ContainerStats>> {
const response = await fetch(`/api/v1/admin/orders/${orderId}/containers/stats`)
if (!response.ok) {
throw new Error('Failed to fetch container stats')
}
return response.json()
}
async function fetchSingleContainerStats(
orderId: string,
containerId: string
): Promise<ContainerStats | null> {
const response = await fetch(`/api/v1/admin/orders/${orderId}/containers/${containerId}/stats`)
if (response.status === 404) {
return null // Container not running
}
if (!response.ok) {
throw new Error('Failed to fetch container stats')
}
return response.json()
}
// Hooks
/**
* Fetch Portainer credentials for an order
* @param orderId - The order ID
* @param pollUntilConfigured - If true, poll every 5 seconds until credentials are configured
*/
export function usePortainerCredentials(orderId: string, pollUntilConfigured: boolean = false) {
return useQuery({
queryKey: portainerKeys.credentials(orderId),
queryFn: () => fetchCredentials(orderId),
enabled: !!orderId,
// Poll every 5 seconds if credentials aren't configured yet and polling is enabled
refetchInterval: (query) => {
if (!pollUntilConfigured) return false
const data = query.state.data
// Stop polling once credentials are configured
if (data?.isConfigured) return false
return 5000
},
})
}
/**
* Update Portainer credentials
*/
export function useUpdatePortainerCredentials() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
orderId,
username,
password,
}: {
orderId: string
username: string
password: string
}) => {
const response = await fetch(`/api/v1/admin/orders/${orderId}/portainer`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!response.ok) {
throw new Error('Failed to update Portainer credentials')
}
return response.json()
},
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: portainerKeys.credentials(orderId) })
},
})
}
/**
* Test Portainer connection
*/
export function useTestPortainerConnection() {
return useMutation({
mutationFn: async (orderId: string) => {
const response = await fetch(`/api/v1/admin/orders/${orderId}/portainer`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to test Portainer connection')
}
return response.json() as Promise<{ success: boolean; error?: string }>
},
})
}
/**
* Fetch containers for an order
*/
export function useContainers(orderId: string, enabled: boolean = true) {
return useQuery({
queryKey: portainerKeys.containers(orderId),
queryFn: () => fetchContainers(orderId),
enabled: !!orderId && enabled,
refetchInterval: 5000, // Refresh every 5 seconds
})
}
/**
* Fetch container details
*/
export function useContainerDetails(orderId: string, containerId: string) {
return useQuery({
queryKey: portainerKeys.container(orderId, containerId),
queryFn: () => fetchContainerDetails(orderId, containerId),
enabled: !!orderId && !!containerId,
})
}
/**
* Fetch container logs
*/
export function useContainerLogs(orderId: string, containerId: string, tail: number = 100) {
return useQuery({
queryKey: portainerKeys.containerLogs(orderId, containerId),
queryFn: () => fetchContainerLogs(orderId, containerId, tail),
enabled: !!orderId && !!containerId,
})
}
/**
* Fetch container stats (CPU, memory, network) for all running containers
*/
export function useContainerStats(orderId: string, enabled: boolean = true) {
return useQuery({
queryKey: portainerKeys.containerStats(orderId),
queryFn: () => fetchContainerStats(orderId),
enabled: !!orderId && enabled,
refetchInterval: 3000, // Refresh every 3 seconds
})
}
/**
* Fetch stats for a single container (faster refresh for detail view)
*/
export function useSingleContainerStats(
orderId: string,
containerId: string,
enabled: boolean = true
) {
return useQuery({
queryKey: portainerKeys.singleContainerStats(orderId, containerId),
queryFn: () => fetchSingleContainerStats(orderId, containerId),
enabled: !!orderId && !!containerId && enabled,
refetchInterval: 2000, // Refresh every 2 seconds for detail view
})
}
/**
* Container action mutation (start, stop, restart)
*/
export function useContainerAction() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
orderId,
containerId,
action,
}: {
orderId: string
containerId: string
action: 'start' | 'stop' | 'restart'
}) => {
const response = await fetch(
`/api/v1/admin/orders/${orderId}/containers/${containerId}/${action}`,
{ method: 'POST' }
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || `Failed to ${action} container`)
}
return response.json()
},
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: portainerKeys.containers(orderId) })
},
})
}
/**
* Remove container mutation
*/
export function useRemoveContainer() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
orderId,
containerId,
force = false,
}: {
orderId: string
containerId: string
force?: boolean
}) => {
const response = await fetch(
`/api/v1/admin/orders/${orderId}/containers/${containerId}?force=${force}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to remove container')
}
return response.json()
},
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: portainerKeys.containers(orderId) })
},
})
}

126
src/hooks/use-profile.ts Normal file
View File

@@ -0,0 +1,126 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiGet, apiPatch, apiPost, apiDelete, ApiError } from '@/lib/api/client'
// Types
export interface Profile {
id: string
email: string
name: string | null
role: string
profilePhotoKey: string | null
profilePhotoUrl: string | null
twoFactorEnabled: boolean
createdAt: string
updatedAt: string
}
export interface UpdateProfilePayload {
name?: string
}
export interface ChangePasswordPayload {
currentPassword: string
newPassword: string
confirmPassword: string
}
export interface PhotoUploadResult {
success: boolean
message: string
profilePhotoKey: string
profilePhotoUrl: string | null
}
// Query keys
export const profileKeys = {
all: ['profile'] as const,
detail: () => [...profileKeys.all, 'detail'] as const,
}
// API functions
async function getProfile(): Promise<Profile> {
return apiGet<Profile>('/api/v1/profile')
}
async function updateProfile(data: UpdateProfilePayload): Promise<Profile> {
return apiPatch<Profile>('/api/v1/profile', data)
}
async function changePassword(data: ChangePasswordPayload): Promise<{ success: boolean; message: string }> {
return apiPost<{ success: boolean; message: string }>('/api/v1/profile/password', data)
}
async function uploadPhoto(file: File): Promise<PhotoUploadResult> {
const formData = new FormData()
formData.append('photo', file)
const response = await fetch('/api/v1/profile/photo', {
method: 'POST',
body: formData,
})
if (!response.ok) {
let data: unknown
try {
data = await response.json()
} catch {
// Response is not JSON
}
throw new ApiError(response.status, response.statusText, data)
}
return response.json()
}
async function deletePhoto(): Promise<{ success: boolean; message: string }> {
return apiDelete<{ success: boolean; message: string }>('/api/v1/profile/photo')
}
// Hooks
export function useProfile() {
return useQuery({
queryKey: profileKeys.detail(),
queryFn: getProfile,
})
}
export function useUpdateProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: profileKeys.detail() })
},
})
}
export function useChangePassword() {
return useMutation({
mutationFn: changePassword,
})
}
export function useUploadPhoto() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: uploadPhoto,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: profileKeys.detail() })
},
})
}
export function useDeletePhoto() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deletePhoto,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: profileKeys.detail() })
},
})
}

View File

@@ -123,10 +123,10 @@ export function useProvisioningLogs({
setError(err)
onError?.(err)
// Attempt to reconnect after 5 seconds
// Attempt to reconnect after 2 seconds (faster reconnection)
reconnectTimeoutRef.current = setTimeout(() => {
connect()
}, 5000)
}, 2000)
}
}
})

99
src/hooks/use-settings.ts Normal file
View File

@@ -0,0 +1,99 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getSettings,
updateSettings,
getSetting,
updateSetting,
deleteSetting,
} from '@/lib/api/admin'
import type { UpdateSettingsPayload } from '@/types/api'
// Query keys
export const settingsKeys = {
all: ['settings'] as const,
list: () => [...settingsKeys.all, 'list'] as const,
category: (category: string) => [...settingsKeys.all, 'category', category] as const,
detail: (key: string) => [...settingsKeys.all, 'detail', key] as const,
}
// Hooks
/**
* Fetch all settings (grouped by category)
*/
export function useSettings() {
return useQuery({
queryKey: settingsKeys.list(),
queryFn: () => getSettings(),
})
}
/**
* Fetch settings for a specific category
*/
export function useSettingsCategory(category: string) {
return useQuery({
queryKey: settingsKeys.category(category),
queryFn: () => getSettings(category),
enabled: !!category,
})
}
/**
* Fetch a single setting
*/
export function useSetting(key: string) {
return useQuery({
queryKey: settingsKeys.detail(key),
queryFn: () => getSetting(key),
enabled: !!key,
})
}
/**
* Batch update settings
*/
export function useUpdateSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateSettingsPayload) => updateSettings(data),
onSuccess: () => {
// Invalidate all settings queries
queryClient.invalidateQueries({ queryKey: settingsKeys.all })
},
})
}
/**
* Update a single setting
*/
export function useUpdateSetting() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
updateSetting(key, { value }),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: settingsKeys.detail(variables.key) })
queryClient.invalidateQueries({ queryKey: settingsKeys.list() })
},
})
}
/**
* Delete a setting (revert to default)
*/
export function useDeleteSetting() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (key: string) => deleteSetting(key),
onSuccess: (_, key) => {
queryClient.invalidateQueries({ queryKey: settingsKeys.detail(key) })
queryClient.invalidateQueries({ queryKey: settingsKeys.list() })
},
})
}

234
src/hooks/use-staff.ts Normal file
View File

@@ -0,0 +1,234 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { StaffRole, StaffStatus } from '@prisma/client'
// Query keys
export const staffKeys = {
all: ['staff'] as const,
list: (filters?: StaffFilters) => [...staffKeys.all, 'list', filters] as const,
detail: (id: string) => [...staffKeys.all, 'detail', id] as const,
invites: () => [...staffKeys.all, 'invites'] as const,
invite: (id: string) => [...staffKeys.all, 'invite', id] as const,
}
// Types
export interface StaffFilters {
status?: StaffStatus
search?: string
page?: number
limit?: number
}
export interface StaffMember {
id: string
email: string
name: string | null
role: StaffRole
status: StaffStatus
invitedBy: string | null
twoFactorEnabled: boolean
createdAt: string
updatedAt: string
isCurrentUser?: boolean
invitedByStaff?: {
id: string
name: string | null
email: string
} | null
}
export interface StaffListResponse {
staff: StaffMember[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
export interface StaffInvitation {
id: string
email: string
role: StaffRole
expiresAt: string
invitedBy: string
createdAt: string
isExpired?: boolean
invitedByStaff?: {
id: string
name: string | null
email: string
} | null
}
export interface InvitationsListResponse {
invitations: StaffInvitation[]
total: number
}
export interface UpdateStaffData {
role?: StaffRole
status?: StaffStatus
name?: string
}
export interface InviteStaffData {
email: string
role: StaffRole
}
// API functions
async function getStaffList(filters?: StaffFilters): Promise<StaffListResponse> {
const params = new URLSearchParams()
if (filters?.status) params.set('status', filters.status)
if (filters?.search) params.set('search', filters.search)
if (filters?.page) params.set('page', String(filters.page))
if (filters?.limit) params.set('limit', String(filters.limit))
const res = await fetch(`/api/v1/admin/staff?${params}`)
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to fetch staff')
}
return res.json()
}
async function getStaffMember(id: string): Promise<StaffMember> {
const res = await fetch(`/api/v1/admin/staff/${id}`)
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to fetch staff member')
}
return res.json()
}
async function updateStaff(id: string, data: UpdateStaffData): Promise<StaffMember> {
const res = await fetch(`/api/v1/admin/staff/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to update staff')
}
return res.json()
}
async function deleteStaff(id: string): Promise<{ deleted: boolean; email: string }> {
const res = await fetch(`/api/v1/admin/staff/${id}`, {
method: 'DELETE',
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to delete staff')
}
return res.json()
}
async function inviteStaff(data: InviteStaffData): Promise<StaffInvitation & { inviteUrl: string; emailSent?: boolean; emailError?: string }> {
const res = await fetch('/api/v1/admin/staff/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to send invitation')
}
return res.json()
}
async function getInvitations(includeExpired = false): Promise<InvitationsListResponse> {
const params = new URLSearchParams()
if (includeExpired) params.set('includeExpired', 'true')
const res = await fetch(`/api/v1/admin/staff/invites?${params}`)
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to fetch invitations')
}
return res.json()
}
async function cancelInvitation(id: string): Promise<{ deleted: boolean; email: string }> {
const res = await fetch(`/api/v1/admin/staff/invites/${id}`, {
method: 'DELETE',
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to cancel invitation')
}
return res.json()
}
// Hooks
export function useStaffList(filters?: StaffFilters) {
return useQuery({
queryKey: staffKeys.list(filters),
queryFn: () => getStaffList(filters),
})
}
export function useStaffMember(id: string) {
return useQuery({
queryKey: staffKeys.detail(id),
queryFn: () => getStaffMember(id),
enabled: !!id,
})
}
export function useUpdateStaff() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateStaffData }) =>
updateStaff(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: staffKeys.list() })
queryClient.invalidateQueries({ queryKey: staffKeys.detail(id) })
},
})
}
export function useDeleteStaff() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteStaff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: staffKeys.list() })
},
})
}
export function useInviteStaff() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: inviteStaff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: staffKeys.invites() })
},
})
}
export function useInvitations(includeExpired = false) {
return useQuery({
queryKey: [...staffKeys.invites(), { includeExpired }],
queryFn: () => getInvitations(includeExpired),
})
}
export function useCancelInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: cancelInvitation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: staffKeys.invites() })
},
})
}

152
src/hooks/use-two-factor.ts Normal file
View File

@@ -0,0 +1,152 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Query keys
export const twoFactorKeys = {
all: ['twoFactor'] as const,
status: () => [...twoFactorKeys.all, 'status'] as const,
}
// Types
export interface TwoFactorStatus {
enabled: boolean
enabledAt: string | null
backupCodesRemaining: number
}
export interface TwoFactorSetupResponse {
qrCode: string
secret: string
otpauthUri: string
}
export interface TwoFactorVerifyResponse {
enabled: boolean
backupCodes: string[]
message: string
}
export interface TwoFactorDisableResponse {
disabled: boolean
message: string
}
export interface BackupCodesResponse {
backupCodes: string[]
message: string
}
// API functions
async function get2FAStatus(): Promise<TwoFactorStatus> {
const res = await fetch('/api/v1/auth/2fa/status')
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to get 2FA status')
}
return res.json()
}
async function setup2FA(): Promise<TwoFactorSetupResponse> {
const res = await fetch('/api/v1/auth/2fa/setup', { method: 'POST' })
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to set up 2FA')
}
return res.json()
}
async function verify2FA(token: string): Promise<TwoFactorVerifyResponse> {
const res = await fetch('/api/v1/auth/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to verify 2FA')
}
return res.json()
}
async function disable2FA(data: {
password: string
token?: string
backupCode?: string
}): Promise<TwoFactorDisableResponse> {
const res = await fetch('/api/v1/auth/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to disable 2FA')
}
return res.json()
}
async function regenerateBackupCodes(password: string): Promise<BackupCodesResponse> {
const res = await fetch('/api/v1/auth/2fa/backup-codes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Failed to regenerate backup codes')
}
return res.json()
}
// Hooks
export function useTwoFactorStatus() {
return useQuery({
queryKey: twoFactorKeys.status(),
queryFn: get2FAStatus,
})
}
export function useSetup2FA() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setup2FA,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: twoFactorKeys.status() })
},
})
}
export function useVerify2FA() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: verify2FA,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: twoFactorKeys.status() })
},
})
}
export function useDisable2FA() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: disable2FA,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: twoFactorKeys.status() })
},
})
}
export function useRegenerateBackupCodes() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: regenerateBackupCodes,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: twoFactorKeys.status() })
},
})
}