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:
127
web/src/hooks/__tests__/useMessages.test.ts
Normal file
127
web/src/hooks/__tests__/useMessages.test.ts
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
104
web/src/hooks/__tests__/useStatus.test.ts
Normal file
104
web/src/hooks/__tests__/useStatus.test.ts
Normal 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
7
web/src/hooks/index.ts
Normal 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';
|
||||
19
web/src/hooks/use-mobile.ts
Normal file
19
web/src/hooks/use-mobile.ts
Normal 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
|
||||
}
|
||||
53
web/src/hooks/useCentralStatus.ts
Normal file
53
web/src/hooks/useCentralStatus.ts
Normal 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
39
web/src/hooks/useCert.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
web/src/hooks/useCloudflare.ts
Normal file
18
web/src/hooks/useCloudflare.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
62
web/src/hooks/useConfig.ts
Normal file
62
web/src/hooks/useConfig.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
128
web/src/hooks/useCrowdSec.ts
Normal file
128
web/src/hooks/useCrowdSec.ts
Normal 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
33
web/src/hooks/useDdns.ts
Normal 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
42
web/src/hooks/useDhcp.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
63
web/src/hooks/useDnsmasq.ts
Normal file
63
web/src/hooks/useDnsmasq.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
52
web/src/hooks/useHaproxy.ts
Normal file
52
web/src/hooks/useHaproxy.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
13
web/src/hooks/useHealth.ts
Normal file
13
web/src/hooks/useHealth.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
33
web/src/hooks/useMessages.ts
Normal file
33
web/src/hooks/useMessages.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
39
web/src/hooks/useNftables.ts
Normal file
39
web/src/hooks/useNftables.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
25
web/src/hooks/usePageHelp.tsx
Normal file
25
web/src/hooks/usePageHelp.tsx
Normal 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
|
||||
}
|
||||
11
web/src/hooks/useStatus.ts
Normal file
11
web/src/hooks/useStatus.ts
Normal 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
93
web/src/hooks/useVpn.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user