Experimental gui.

This commit is contained in:
2025-06-26 08:28:52 -07:00
parent 55b052256a
commit c855786e61
99 changed files with 11664 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useConfig } from '../useConfig';
import { apiService } from '../../services/api';
// Mock the API service
vi.mock('../../services/api', () => ({
apiService: {
getConfig: vi.fn(),
createConfig: vi.fn(),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
);
};
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch config successfully when configured', async () => {
const mockConfigResponse = {
configured: true,
config: {
server: { host: '0.0.0.0', port: 5055 },
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.showConfigSetup).toBe(false);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.config).toEqual(mockConfigResponse.config);
expect(result.current.isConfigured).toBe(true);
expect(result.current.showConfigSetup).toBe(false);
expect(result.current.error).toBeNull();
});
it('should show config setup when not configured', async () => {
const mockConfigResponse = {
configured: false,
message: 'No configuration found',
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.config).toBeNull();
expect(result.current.isConfigured).toBe(false);
expect(result.current.showConfigSetup).toBe(true);
});
it('should create config successfully', async () => {
const mockConfigResponse = {
configured: false,
message: 'No configuration found',
};
const mockCreateResponse = {
status: 'Configuration created successfully',
};
const newConfig = {
server: { host: '0.0.0.0', port: 5055 },
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.showConfigSetup).toBe(true);
// Create config
await act(async () => {
result.current.createConfig(newConfig);
});
await waitFor(() => {
expect(result.current.isCreating).toBe(false);
});
expect(apiService.createConfig).toHaveBeenCalledWith(newConfig);
});
it('should handle error when fetching config fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getConfig).mockRejectedValue(mockError);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.config).toBeNull();
});
});

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';
// Mock the API service
vi.mock('../../services/api', () => ({
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',
};
vi.mocked(apiService.getStatus).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');
vi.mocked(apiService.getStatus).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',
};
vi.mocked(apiService.getStatus).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);
});
});
});

View File

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

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,19 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface AssetsResponse {
status: string;
}
export const useAssets = () => {
const downloadMutation = useMutation<AssetsResponse>({
mutationFn: apiService.downloadPXEAssets,
});
return {
downloadAssets: downloadMutation.mutate,
isDownloading: downloadMutation.isPending,
error: downloadMutation.error,
data: downloadMutation.data,
};
};

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
import type { Config } from '../types';
interface ConfigResponse {
configured: boolean;
config?: Config;
message?: string;
}
interface CreateConfigResponse {
status: string;
}
export const useConfig = () => {
const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false);
const configQuery = useQuery<ConfigResponse>({
queryKey: ['config'],
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, Config>({
mutationFn: apiService.createConfig,
onSuccess: () => {
// Invalidate and refetch config after successful creation
queryClient.invalidateQueries({ queryKey: ['config'] });
setShowConfigSetup(false);
},
});
return {
config: configQuery.data?.config || null,
isConfigured: configQuery.data?.configured || false,
showConfigSetup,
setShowConfigSetup,
isLoading: configQuery.isLoading,
isCreating: createConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error,
createConfig: createConfigMutation.mutate,
refetch: configQuery.refetch,
};
};

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
export const useConfigYaml = () => {
const queryClient = useQueryClient();
const configYamlQuery = useQuery({
queryKey: ['config', 'yaml'],
queryFn: () => apiService.getConfigYaml(),
staleTime: 30000, // Consider data fresh for 30 seconds
retry: true,
});
const updateConfigYamlMutation = useMutation({
mutationFn: (data: string) => apiService.updateConfigYaml(data),
onSuccess: () => {
// Invalidate both YAML and JSON config queries
queryClient.invalidateQueries({ queryKey: ['config'] });
},
});
// Check if error is 404 (endpoint doesn't exist)
const isEndpointMissing = configYamlQuery.error &&
configYamlQuery.error instanceof Error &&
configYamlQuery.error.message.includes('404');
// Only pass through real errors
const actualError = (configYamlQuery.error instanceof Error ? configYamlQuery.error : null) ||
(updateConfigYamlMutation.error instanceof Error ? updateConfigYamlMutation.error : null);
return {
yamlContent: configYamlQuery.data || '',
isLoading: configYamlQuery.isLoading,
error: actualError,
isEndpointMissing,
isUpdating: updateConfigYamlMutation.isPending,
updateYaml: updateConfigYamlMutation.mutate,
refetch: configYamlQuery.refetch,
};
};

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface DnsmasqResponse {
status: string;
}
export const useDnsmasq = () => {
const [dnsmasqConfig, setDnsmasqConfig] = useState<string>('');
const generateConfigMutation = useMutation<string>({
mutationFn: apiService.getDnsmasqConfig,
onSuccess: (data) => {
setDnsmasqConfig(data);
},
});
const restartMutation = useMutation<DnsmasqResponse>({
mutationFn: apiService.restartDnsmasq,
});
return {
dnsmasqConfig,
generateConfig: generateConfigMutation.mutate,
isGenerating: generateConfigMutation.isPending,
generateError: generateConfigMutation.error,
restart: restartMutation.mutate,
isRestarting: restartMutation.isPending,
restartError: restartMutation.error,
restartData: restartMutation.data,
};
};

View File

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