Complete Hub Admin Dashboard with analytics, settings, and enterprise features
Major additions: - Analytics dashboard with charts (line, bar, donut) - Enterprise client monitoring with container management - Staff management with 2FA support - Profile management and settings pages - Netcup server integration - DNS verification panel - Portainer integration - Container logs and health monitoring - Automation controls for orders New API endpoints: - /api/v1/admin/analytics - /api/v1/admin/enterprise-clients - /api/v1/admin/netcup - /api/v1/admin/settings - /api/v1/admin/staff - /api/v1/profile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
90
src/hooks/use-analytics.ts
Normal file
90
src/hooks/use-analytics.ts
Normal 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
|
||||
})
|
||||
}
|
||||
44
src/hooks/use-automation.ts
Normal file
44
src/hooks/use-automation.ts
Normal 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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
107
src/hooks/use-dns.ts
Normal 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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
562
src/hooks/use-enterprise-clients.ts
Normal file
562
src/hooks/use-enterprise-clients.ts
Normal 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
836
src/hooks/use-netcup.ts
Normal 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
|
||||
}
|
||||
@@ -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
331
src/hooks/use-portainer.ts
Normal 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
126
src/hooks/use-profile.ts
Normal 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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
99
src/hooks/use-settings.ts
Normal 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
234
src/hooks/use-staff.ts
Normal 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
152
src/hooks/use-two-factor.ts
Normal 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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user