feat: Add Wild Central web app (Central-only UI)

Extract Central pages from the wild-cloud web app into a standalone
React app for Wild Central. Includes:

- Central overview, DNS, DHCP, Firewall, VPN, Ingress, CrowdSec pages
- Simplified sidebar with Central-only navigation
- Branding updated to "Wild Central"
- All Cloud-specific pages, components, hooks, and API services removed
- TypeScript type-check and production build pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 04:10:43 +00:00
parent 5defc27f63
commit 735f3fcb05
121 changed files with 18347 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessages } from '../useMessages';
describe('useMessages', () => {
it('should initialize with empty messages', () => {
const { result } = renderHook(() => useMessages());
expect(result.current.messages).toEqual({});
});
it('should set a message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message', 'success');
});
expect(result.current.messages).toEqual({
test: { message: 'Test message', type: 'success' }
});
});
it('should set multiple messages', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('success', 'Success message', 'success');
result.current.setMessage('error', 'Error message', 'error');
result.current.setMessage('info', 'Info message', 'info');
});
expect(result.current.messages).toEqual({
success: { message: 'Success message', type: 'success' },
error: { message: 'Error message', type: 'error' },
info: { message: 'Info message', type: 'info' },
});
});
it('should update existing message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'First message', 'info');
});
expect(result.current.messages.test).toEqual({
message: 'First message',
type: 'info'
});
act(() => {
result.current.setMessage('test', 'Updated message', 'error');
});
expect(result.current.messages.test).toEqual({
message: 'Updated message',
type: 'error'
});
});
it('should clear a specific message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test1', 'Message 1', 'info');
result.current.setMessage('test2', 'Message 2', 'success');
});
expect(Object.keys(result.current.messages)).toHaveLength(2);
act(() => {
result.current.clearMessage('test1');
});
expect(result.current.messages).toEqual({
test2: { message: 'Message 2', type: 'success' }
});
});
it('should clear message by setting to null', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message', 'info');
});
expect(result.current.messages.test).toBeDefined();
act(() => {
result.current.setMessage('test', null);
});
expect(result.current.messages.test).toBeUndefined();
});
it('should clear all messages', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test1', 'Message 1', 'info');
result.current.setMessage('test2', 'Message 2', 'success');
result.current.setMessage('test3', 'Message 3', 'error');
});
expect(Object.keys(result.current.messages)).toHaveLength(3);
act(() => {
result.current.clearAllMessages();
});
expect(result.current.messages).toEqual({});
});
it('should default to info type when type not specified', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message');
});
expect(result.current.messages.test).toEqual({
message: 'Test message',
type: 'info'
});
});
});

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useStatus } from '../useStatus';
import { apiService } from '../../services/api-legacy';
// Mock the API service
vi.mock('../../services/api-legacy', () => ({
apiService: {
getStatus: vi.fn(),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
);
};
describe('useStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch status successfully', async () => {
const mockStatus = {
status: 'running',
version: '1.0.0',
uptime: '2 hours',
timestamp: '2024-01-01T00:00:00Z',
};
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockStatus);
expect(result.current.error).toBeNull();
expect(apiService.getStatus).toHaveBeenCalledTimes(1);
});
it('should handle error when fetching status fails', async () => {
const mockError = new Error('Network error');
(apiService.getStatus as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(mockError);
});
it('should refetch data when refetch is called', async () => {
const mockStatus = {
status: 'running',
version: '1.0.0',
uptime: '2 hours',
timestamp: '2024-01-01T00:00:00Z',
};
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(apiService.getStatus).toHaveBeenCalledTimes(1);
// Trigger refetch
result.current.refetch();
await waitFor(() => {
expect(apiService.getStatus).toHaveBeenCalledTimes(2);
});
});
});

7
web/src/hooks/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export { useMessages } from './useMessages';
export { useStatus } from './useStatus';
export { useHealth } from './useHealth';
export { useConfig } from './useConfig';
export { useDnsmasq } from './useDnsmasq';
export { useCentralStatus } from './useCentralStatus';
export { useCloudflare } from './useCloudflare';

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,53 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../services/api/client';
interface CentralStatus {
status: string;
version: string;
uptime: string;
uptimeSeconds: number;
dataDir: string;
appsDir: string;
setupFiles: string;
pendingRestart: boolean;
}
/**
* Hook to fetch Wild Central server status and trigger restarts
*/
export function useCentralStatus() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['central', 'status'],
queryFn: async (): Promise<CentralStatus> => {
return apiClient.get('/api/v1/status');
},
refetchInterval: 30000,
staleTime: 10000,
});
const restartMutation = useMutation({
mutationFn: () => apiClient.post('/api/v1/daemon/restart'),
onSuccess: () => {
// Poll until the daemon comes back up
const poll = setInterval(async () => {
try {
await apiClient.get('/api/v1/health');
clearInterval(poll);
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
} catch {
// still restarting
}
}, 1000);
// Give up after 30s
setTimeout(() => clearInterval(poll), 30000);
},
});
return {
...statusQuery,
restart: () => restartMutation.mutate(),
isRestarting: restartMutation.isPending,
};
}

39
web/src/hooks/useCert.ts Normal file
View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { certApi } from '../services/api/cert';
export function useCert() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['cert', 'status'],
queryFn: () => certApi.getStatus(),
refetchInterval: false,
staleTime: 60000,
});
const provisionMutation = useMutation({
mutationFn: () => certApi.provision(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cert', 'status'] });
},
});
const renewMutation = useMutation({
mutationFn: () => certApi.renew(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cert', 'status'] });
},
});
return {
status: statusQuery.data,
isLoading: statusQuery.isLoading,
provision: provisionMutation.mutateAsync,
isProvisioning: provisionMutation.isPending,
provisionError: provisionMutation.error,
renew: renewMutation.mutateAsync,
isRenewing: renewMutation.isPending,
};
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { cloudflareApi } from '../services/api/cloudflare';
export const useCloudflare = () => {
const verifyQuery = useQuery({
queryKey: ['cloudflare', 'verify'],
queryFn: () => cloudflareApi.verify(),
refetchInterval: false,
staleTime: 60000,
});
return {
verification: verifyQuery.data,
isLoading: verifyQuery.isLoading,
error: verifyQuery.error,
refetch: verifyQuery.refetch,
};
};

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
import type { GlobalConfig, GlobalConfigResponse } from '../types';
interface CreateConfigResponse {
status: string;
}
/**
* Hook for managing Wild Central global configuration
* Endpoint: /api/v1/config
* File: {dataDir}/config.yaml
*/
export const useConfig = () => {
const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false);
const configQuery = useQuery<GlobalConfigResponse>({
queryKey: ['globalConfig'],
queryFn: () => apiService.getConfig(),
});
// Update showConfigSetup based on query data
useEffect(() => {
if (configQuery.data) {
setShowConfigSetup(configQuery.data.configured === false);
}
}, [configQuery.data]);
const createConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({
mutationFn: (config) => apiService.createConfig(config),
onSuccess: () => {
// Invalidate and refetch config after successful creation
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
setShowConfigSetup(false);
},
});
const updateConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({
mutationFn: (config) => apiService.updateConfig(config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
// Immediately re-check daemon status so the pending-restart banner appears
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
},
});
return {
config: configQuery.data?.config || null,
isConfigured: configQuery.data?.configured || false,
showConfigSetup,
setShowConfigSetup,
isLoading: configQuery.isLoading,
isCreating: createConfigMutation.isPending,
isUpdating: updateConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error || updateConfigMutation.error,
createConfig: createConfigMutation.mutate,
updateConfig: updateConfigMutation.mutateAsync,
refetch: configQuery.refetch,
};
};

View File

@@ -0,0 +1,128 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { crowdSecApi, type AddDecisionRequest } from '../services/api/networking';
export const useCrowdSec = () => {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['crowdsec', 'status'],
queryFn: () => crowdSecApi.getStatus(),
refetchInterval: false,
staleTime: 30000,
});
const summaryQuery = useQuery({
queryKey: ['crowdsec', 'summary'],
queryFn: () => crowdSecApi.getSummary(),
refetchInterval: false,
staleTime: 300000, // 5 minutes — CAPI bans change slowly
enabled: statusQuery.data?.active ?? false,
});
const alertsQuery = useQuery({
queryKey: ['crowdsec', 'alerts'],
queryFn: () => crowdSecApi.getAlerts(50),
refetchInterval: false,
staleTime: 60000,
enabled: statusQuery.data?.active ?? false,
});
const alertsGraphQuery = useQuery({
queryKey: ['crowdsec', 'alerts-graph'],
queryFn: () => crowdSecApi.getAlerts(500),
refetchInterval: false,
staleTime: 120000,
enabled: statusQuery.data?.active ?? false,
});
const decisionsQuery = useQuery({
queryKey: ['crowdsec', 'decisions'],
queryFn: () => crowdSecApi.getDecisions(),
refetchInterval: false,
staleTime: 30000,
enabled: statusQuery.data?.active ?? false,
});
const addDecisionMutation = useMutation({
mutationFn: (req: AddDecisionRequest) => crowdSecApi.addDecision(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'decisions'] });
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'summary'] });
},
});
const deleteDecisionMutation = useMutation({
mutationFn: (id: number) => crowdSecApi.deleteDecision(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'decisions'] });
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'summary'] });
},
});
const deleteDecisionByIPMutation = useMutation({
mutationFn: (ip: string) => crowdSecApi.deleteDecisionByIP(ip),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'decisions'] });
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'summary'] });
},
});
const deleteBouncerMutation = useMutation({
mutationFn: (name: string) => crowdSecApi.deleteBouncer(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] });
},
});
const deleteMachineMutation = useMutation({
mutationFn: (name: string) => crowdSecApi.deleteMachine(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] });
},
});
const provisionMutation = useMutation({
mutationFn: (instanceName: string) => crowdSecApi.provision(instanceName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] });
},
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error,
summary: summaryQuery.data,
isLoadingSummary: summaryQuery.isLoading,
alerts: alertsQuery.data?.alerts ?? [],
isLoadingAlerts: alertsQuery.isLoading,
refetchAlerts: alertsQuery.refetch,
graphAlerts: alertsGraphQuery.data?.alerts ?? [],
isLoadingGraphAlerts: alertsGraphQuery.isLoading,
decisions: decisionsQuery.data?.decisions ?? [],
isLoadingDecisions: decisionsQuery.isLoading,
addDecision: addDecisionMutation.mutate,
isAddingDecision: addDecisionMutation.isPending,
addDecisionError: addDecisionMutation.error,
deleteDecision: deleteDecisionMutation.mutate,
isDeletingDecision: deleteDecisionMutation.isPending,
deleteDecisionByIP: deleteDecisionByIPMutation.mutate,
isDeletingByIP: deleteDecisionByIPMutation.isPending,
deleteBouncer: deleteBouncerMutation.mutate,
isDeletingBouncer: deleteBouncerMutation.isPending,
deleteMachine: deleteMachineMutation.mutate,
isDeletingMachine: deleteMachineMutation.isPending,
provision: provisionMutation.mutate,
isProvisioning: provisionMutation.isPending,
};
};

33
web/src/hooks/useDdns.ts Normal file
View File

@@ -0,0 +1,33 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ddnsApi } from '../services/api/networking';
export const useDdns = () => {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['ddns', 'status'],
queryFn: () => ddnsApi.getStatus(),
refetchInterval: false,
staleTime: 30000,
});
const triggerMutation = useMutation({
mutationFn: () => ddnsApi.trigger(),
onSuccess: () => {
// Refresh status shortly after trigger
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] });
}, 2000);
},
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
trigger: triggerMutation.mutate,
isTriggering: triggerMutation.isPending,
triggerData: triggerMutation.data,
triggerError: triggerMutation.error,
};
};

42
web/src/hooks/useDhcp.ts Normal file
View File

@@ -0,0 +1,42 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { dhcpApi } from '../services/api/networking';
import type { DhcpStaticLease } from '../services/api/networking';
export const useDhcp = () => {
const queryClient = useQueryClient();
const leasesQuery = useQuery({
queryKey: ['dhcp', 'leases'],
queryFn: () => dhcpApi.getLeases(),
refetchInterval: 30000,
staleTime: 20000,
});
const addStaticMutation = useMutation({
mutationFn: (lease: DhcpStaticLease) => dhcpApi.addStatic(lease),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dhcp', 'leases'] });
},
});
const deleteStaticMutation = useMutation({
mutationFn: (mac: string) => dhcpApi.deleteStatic(mac),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dhcp', 'leases'] });
},
});
return {
leases: leasesQuery.data?.leases ?? [],
isLoadingLeases: leasesQuery.isLoading,
leasesError: leasesQuery.error,
refetchLeases: leasesQuery.refetch,
addStatic: addStaticMutation.mutate,
isAddingStatic: addStaticMutation.isPending,
addStaticError: addStaticMutation.error,
deleteStatic: deleteStaticMutation.mutate,
isDeletingStatic: deleteStaticMutation.isPending,
};
};

View File

@@ -0,0 +1,63 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
import type { DnsmasqStatus, DnsmasqConfigResponse, StatusResponse } from '../types';
export const useDnsmasq = () => {
// Query for status
const statusQuery = useQuery<DnsmasqStatus>({
queryKey: ['dnsmasq', 'status'],
queryFn: () => apiService.getDnsmasqStatus(),
refetchInterval: 30000,
staleTime: 10000,
});
// Query for config
const configQuery = useQuery<DnsmasqConfigResponse>({
queryKey: ['dnsmasq', 'config'],
queryFn: () => apiService.getDnsmasqConfig(),
enabled: false, // Only fetch when explicitly called
});
// Mutation for generating config (with optional overwrite)
const generateMutation = useMutation<DnsmasqConfigResponse, Error, boolean>({
mutationFn: (overwrite = false) => apiService.generateDnsmasqConfig(overwrite),
onSuccess: () => {
statusQuery.refetch();
configQuery.refetch();
},
});
// Mutation for restarting service
const restartMutation = useMutation<StatusResponse>({
mutationFn: () => apiService.restartDnsmasq(),
onSuccess: () => {
statusQuery.refetch();
},
});
return {
// Status
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
// Config
config: configQuery.data,
isLoadingConfig: configQuery.isLoading,
configError: configQuery.error,
fetchConfig: configQuery.refetch,
// Generate
generateConfig: generateMutation.mutate,
generateData: generateMutation.data,
isGenerating: generateMutation.isPending,
generateError: generateMutation.error,
// Restart
restart: restartMutation.mutate,
restartData: restartMutation.data,
isRestarting: restartMutation.isPending,
restartError: restartMutation.error,
};
};

View File

@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { haproxyApi } from '../services/api/networking';
export const useHaproxy = () => {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['haproxy', 'status'],
queryFn: () => haproxyApi.getStatus(),
refetchInterval: 30000,
staleTime: 10000,
});
const statsQuery = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: () => haproxyApi.getStats(),
refetchInterval: 30000,
staleTime: 10000,
});
const generateMutation = useMutation({
mutationFn: () => haproxyApi.generate(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['haproxy', 'status'] });
},
});
const restartMutation = useMutation({
mutationFn: () => haproxyApi.restart(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['haproxy', 'status'] });
},
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error,
stats: statsQuery.data?.backends ?? [],
isLoadingStats: statsQuery.isLoading,
generate: generateMutation.mutate,
isGenerating: generateMutation.isPending,
generateData: generateMutation.data,
generateError: generateMutation.error,
restart: restartMutation.mutate,
isRestarting: restartMutation.isPending,
restartError: restartMutation.error,
};
};

View File

@@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
interface HealthResponse {
service: string;
status: string;
}
export const useHealth = () => {
return useMutation<HealthResponse>({
mutationFn: apiService.getHealth,
});
};

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import type { Messages } from '../types';
export const useMessages = () => {
const [messages, setMessages] = useState<Messages>({});
const setMessage = (key: string, message: string | null, type: 'info' | 'success' | 'error' = 'info') => {
if (message === null) {
setMessages(prev => {
const newMessages = { ...prev };
delete newMessages[key];
return newMessages;
});
} else {
setMessages(prev => ({ ...prev, [key]: { message, type } }));
}
};
const clearMessage = (key: string) => {
setMessage(key, null);
};
const clearAllMessages = () => {
setMessages({});
};
return {
messages,
setMessage,
clearMessage,
clearAllMessages,
};
};

View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { nftablesApi } from '../services/api/networking';
export const useNftables = () => {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['nftables', 'status'],
queryFn: () => nftablesApi.getStatus(),
refetchInterval: false,
staleTime: 30000,
});
const interfacesQuery = useQuery({
queryKey: ['nftables', 'interfaces'],
queryFn: () => nftablesApi.getInterfaces(),
refetchInterval: false,
staleTime: 60000,
});
const applyMutation = useMutation({
mutationFn: () => nftablesApi.apply(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nftables', 'status'] });
},
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
refetchStatus: statusQuery.refetch,
interfaces: interfacesQuery.data?.interfaces ?? [],
apply: applyMutation.mutate,
isApplying: applyMutation.isPending,
applyError: applyMutation.error,
};
};

View File

@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useHelp } from '../contexts/HelpContext';
import type { ReactNode } from 'react';
interface PageHelpOptions {
title: string;
description: ReactNode;
icon?: ReactNode;
color?: string;
actions?: ReactNode;
}
export function usePageHelp(options: PageHelpOptions | null) {
const { setHelpContent } = useHelp();
useEffect(() => {
setHelpContent(options);
// Clear help content when component unmounts
return () => {
setHelpContent(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run on mount/unmount
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
import type { Status } from '../types';
export const useStatus = () => {
return useQuery<Status>({
queryKey: ['status'],
queryFn: apiService.getStatus,
refetchInterval: 30000, // Refetch every 30 seconds
});
};

93
web/src/hooks/useVpn.ts Normal file
View File

@@ -0,0 +1,93 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { vpnApi, type VpnConfig } from '../services/api/vpn';
export function useVpn() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['vpn', 'status'],
queryFn: () => vpnApi.getStatus(),
refetchInterval: 10000,
staleTime: 5000,
});
const configQuery = useQuery({
queryKey: ['vpn', 'config'],
queryFn: () => vpnApi.getConfig(),
refetchInterval: false,
staleTime: 60000,
});
const peersQuery = useQuery({
queryKey: ['vpn', 'peers'],
queryFn: () => vpnApi.listPeers(),
refetchInterval: false,
staleTime: 30000,
});
const updateConfigMutation = useMutation({
mutationFn: (cfg: Omit<VpnConfig, 'publicKey'>) => vpnApi.updateConfig(cfg),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vpn', 'config'] });
},
});
const generateKeypairMutation = useMutation({
mutationFn: () => vpnApi.generateKeypair(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vpn', 'config'] });
},
});
const applyMutation = useMutation({
mutationFn: () => vpnApi.apply(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vpn', 'status'] });
},
});
const addPeerMutation = useMutation({
mutationFn: (name: string) => vpnApi.addPeer(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vpn', 'peers'] });
},
});
const deletePeerMutation = useMutation({
mutationFn: (id: string) => vpnApi.deletePeer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vpn', 'peers'] });
},
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error,
config: configQuery.data,
isLoadingConfig: configQuery.isLoading,
configError: configQuery.error,
peers: peersQuery.data?.peers ?? [],
isLoadingPeers: peersQuery.isLoading,
updateConfig: updateConfigMutation.mutateAsync,
isUpdatingConfig: updateConfigMutation.isPending,
updateConfigError: updateConfigMutation.error,
generateKeypair: generateKeypairMutation.mutateAsync,
isGeneratingKeypair: generateKeypairMutation.isPending,
apply: applyMutation.mutateAsync,
isApplying: applyMutation.isPending,
applyError: applyMutation.error,
addPeer: addPeerMutation.mutateAsync,
isAddingPeer: addPeerMutation.isPending,
addPeerError: addPeerMutation.error,
deletePeer: deletePeerMutation.mutateAsync,
isDeletingPeer: deletePeerMutation.isPending,
};
}