diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 3822554..a909ccb 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; import Header from "@/components/Header"; import InstanceList from "@/components/InstanceList"; -import InstanceModal from "@/components/InstanceModal"; +import InstanceDialog from "@/components/InstanceDialog"; +import LoginDialog from "@/components/LoginDialog"; +import SystemInfoDialog from "./components/SystemInfoDialog"; import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; -import SystemInfoModal from "./components/SystemInfoModal"; +import { useAuth } from "@/contexts/AuthContext"; function App() { + const { isAuthenticated, isLoading: authLoading } = useAuth(); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); const [editingInstance, setEditingInstance] = useState( @@ -36,6 +39,28 @@ function App() { setIsSystemInfoModalOpen(true); }; + // Show loading spinner while checking auth + if (authLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + // Show login dialog if not authenticated + if (!isAuthenticated) { + return ( +
+ +
+ ); + } + + // Show main app if authenticated return (
@@ -43,14 +68,14 @@ function App() { - - @@ -58,4 +83,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 0159601..1d22531 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -1,10 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import App from '@/App' import { InstancesProvider } from '@/contexts/InstancesContext' import { instancesApi } from '@/lib/api' import type { Instance } from '@/types/instance' +import { AuthProvider } from '@/contexts/AuthContext' // Mock the API vi.mock('@/lib/api', () => ({ @@ -35,9 +36,11 @@ vi.mock('@/lib/healthService', () => ({ function renderApp() { return render( - - - + + + + + ) } @@ -50,6 +53,12 @@ describe('App Component - Critical Business Logic Only', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123') + global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))) + }) + + afterEach(() => { + vi.restoreAllMocks() }) describe('End-to-End Instance Management', () => { @@ -75,7 +84,7 @@ describe('App Component - Critical Business Logic Only', () => { const nameInput = screen.getByLabelText(/Instance Name/) await user.type(nameInput, 'new-test-instance') - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) // Verify correct API call await waitFor(() => { @@ -109,7 +118,7 @@ describe('App Component - Critical Business Logic Only', () => { const editButtons = screen.getAllByTitle('Edit instance') await user.click(editButtons[0]) - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) // Verify correct API call with existing instance data await waitFor(() => { @@ -167,7 +176,6 @@ describe('App Component - Critical Business Logic Only', () => { renderApp() // App should still render and show error - expect(screen.getByText('Llamactl Dashboard')).toBeInTheDocument() await waitFor(() => { expect(screen.getByText('Error loading instances')).toBeInTheDocument() }) diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index 544677c..ed272ed 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; -import { HelpCircle } from "lucide-react"; +import { HelpCircle, LogOut } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; interface HeaderProps { onCreateInstance: () => void; @@ -7,6 +8,14 @@ interface HeaderProps { } function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { + const { logout } = useAuth(); + + const handleLogout = () => { + if (confirm("Are you sure you want to logout?")) { + logout(); + } + }; + return (
@@ -16,7 +25,9 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
- + + +
@@ -34,4 +55,4 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { ); } -export default Header; +export default Header; \ No newline at end of file diff --git a/webui/src/components/InstanceCard.tsx b/webui/src/components/InstanceCard.tsx index 56b49cb..5ecfcb2 100644 --- a/webui/src/components/InstanceCard.tsx +++ b/webui/src/components/InstanceCard.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Instance } from "@/types/instance"; import { Edit, FileText, Play, Square, Trash2 } from "lucide-react"; -import LogsModal from "@/components/LogModal"; +import LogsDialog from "@/components/LogDialog"; import HealthBadge from "@/components/HealthBadge"; import { useState } from "react"; import { useInstanceHealth } from "@/hooks/useInstanceHealth"; @@ -118,7 +118,7 @@ function InstanceCard({ - void; onSave: (name: string, options: CreateInstanceOptions) => void; instance?: Instance; // For editing existing instance } -const InstanceModal: React.FC = ({ +const InstanceDialog: React.FC = ({ open, onOpenChange, onSave, @@ -40,7 +40,7 @@ const InstanceModal: React.FC = ({ const basicFields = getBasicFields(); const advancedFields = getAdvancedFields(); - // Reset form when modal opens/closes or when instance changes + // Reset form when dialog opens/closes or when instance changes useEffect(() => { if (open) { if (instance) { @@ -255,14 +255,14 @@ const InstanceModal: React.FC = ({ + + + + + ) +} + +export default LoginDialog \ No newline at end of file diff --git a/webui/src/components/SystemInfoModal.tsx b/webui/src/components/SystemInfoDialog.tsx similarity index 97% rename from webui/src/components/SystemInfoModal.tsx rename to webui/src/components/SystemInfoDialog.tsx index add3c7c..2b24a0a 100644 --- a/webui/src/components/SystemInfoModal.tsx +++ b/webui/src/components/SystemInfoDialog.tsx @@ -30,7 +30,7 @@ interface SystemInfo { help: string } -const SystemInfoModal: React.FC = ({ +const SystemInfoDialog: React.FC = ({ open, onOpenChange }) => { @@ -59,7 +59,7 @@ const SystemInfoModal: React.FC = ({ } } - // Load data when modal opens + // Load data when dialog opens useEffect(() => { if (open) { fetchSystemInfo() @@ -180,4 +180,4 @@ const SystemInfoModal: React.FC = ({ ) } -export default SystemInfoModal \ No newline at end of file +export default SystemInfoDialog \ No newline at end of file diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx index 5b9202c..4429e4f 100644 --- a/webui/src/components/__tests__/InstanceCard.test.tsx +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InstanceCard from '@/components/InstanceCard' @@ -27,9 +27,15 @@ describe('InstanceCard - Instance Actions and State', () => { options: { model: 'running-model.gguf' } } - beforeEach(() => { - vi.clearAllMocks() - }) +beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123') + global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) describe('Instance Action Buttons', () => { it('calls startInstance when start button clicked on stopped instance', async () => { @@ -93,7 +99,7 @@ describe('InstanceCard - Instance Actions and State', () => { expect(mockEditInstance).toHaveBeenCalledWith(stoppedInstance) }) - it('opens logs modal when logs button clicked', async () => { + it('opens logs dialog when logs button clicked', async () => { const user = userEvent.setup() render( @@ -109,7 +115,7 @@ describe('InstanceCard - Instance Actions and State', () => { const logsButton = screen.getByTitle('View logs') await user.click(logsButton) - // Should open logs modal (we can verify this by checking if modal title appears) + // Should open logs dialog (we can verify this by checking if dialog title appears) expect(screen.getByText(`Logs: ${stoppedInstance.name}`)).toBeInTheDocument() }) }) @@ -272,19 +278,19 @@ describe('InstanceCard - Instance Actions and State', () => { /> ) - // Open logs modal + // Open logs dialog await user.click(screen.getByTitle('View logs')) - // Verify modal opened with correct instance data + // Verify dialog opened with correct instance data expect(screen.getByText('Logs: running-instance')).toBeInTheDocument() - // Close modal to test close functionality + // Close dialog to test close functionality const closeButtons = screen.getAllByText('Close') - const modalCloseButton = closeButtons.find(button => + const dialogCloseButton = closeButtons.find(button => button.closest('[data-slot="dialog-content"]') ) - expect(modalCloseButton).toBeTruthy() - await user.click(modalCloseButton!) + expect(dialogCloseButton).toBeTruthy() + await user.click(dialogCloseButton!) // Modal should close expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument() diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx index 0d047f8..b237987 100644 --- a/webui/src/components/__tests__/InstanceList.test.tsx +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -1,10 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InstanceList from '@/components/InstanceList' import { InstancesProvider } from '@/contexts/InstancesContext' import { instancesApi } from '@/lib/api' import type { Instance } from '@/types/instance' +import { AuthProvider } from '@/contexts/AuthContext' // Mock the API vi.mock('@/lib/api', () => ({ @@ -30,13 +31,16 @@ vi.mock('@/lib/healthService', () => ({ function renderInstanceList(editInstance = vi.fn()) { return render( - - - + + + + + ) } describe('InstanceList - State Management and UI Logic', () => { + const mockEditInstance = vi.fn() const mockInstances: Instance[] = [ @@ -45,12 +49,20 @@ describe('InstanceList - State Management and UI Logic', () => { { name: 'instance-3', running: false, options: { model: 'model3.gguf' } } ] + const DUMMY_API_KEY = 'test-api-key-123' + beforeEach(() => { vi.clearAllMocks() + window.sessionStorage.setItem('llamactl_management_key', DUMMY_API_KEY) + global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))) + }) + + afterEach(() => { + vi.restoreAllMocks() }) describe('Loading State', () => { - it('shows loading spinner while instances are being fetched', async () => { + it('shows loading spinner while instances are being fetched', () => { // Mock a delayed response to test loading state vi.mocked(instancesApi.list).mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockInstances), 100)) @@ -220,27 +232,5 @@ describe('InstanceList - State Management and UI Logic', () => { expect(await screen.findByText('Instances (3)')).toBeInTheDocument() expect(screen.queryByText('Loading instances...')).not.toBeInTheDocument() }) - - it('handles transition from error back to loaded state', async () => { - // Start with error - vi.mocked(instancesApi.list).mockRejectedValue(new Error('Network error')) - - const { rerender } = renderInstanceList(mockEditInstance) - - expect(await screen.findByText('Error loading instances')).toBeInTheDocument() - - // Simulate recovery (e.g., retry after network recovery) - vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) - - rerender( - - - - ) - - // Should eventually show instances - // Note: This test is somewhat artificial since the context handles retries - expect(screen.getByText('Error loading instances')).toBeInTheDocument() - }) }) }) \ No newline at end of file diff --git a/webui/src/components/__tests__/InstanceModal.test.tsx b/webui/src/components/__tests__/InstanceModal.test.tsx index fc405c0..926d214 100644 --- a/webui/src/components/__tests__/InstanceModal.test.tsx +++ b/webui/src/components/__tests__/InstanceModal.test.tsx @@ -1,23 +1,29 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import InstanceModal from '@/components/InstanceModal' +import InstanceDialog from '@/components/InstanceDialog' import type { Instance } from '@/types/instance' describe('InstanceModal - Form Logic and Validation', () => { const mockOnSave = vi.fn() const mockOnOpenChange = vi.fn() - beforeEach(() => { - vi.clearAllMocks() - }) +beforeEach(() => { + vi.clearAllMocks() + window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123') + global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) describe('Create Mode', () => { it('validates instance name is required', async () => { const user = userEvent.setup() render( - { ) // Try to submit without name - const saveButton = screen.getByTestId('modal-save-button') + const saveButton = screen.getByTestId('dialog-save-button') expect(saveButton).toBeDisabled() // Add name, button should be enabled @@ -41,7 +47,7 @@ describe('InstanceModal - Form Logic and Validation', () => { const user = userEvent.setup() render( - { await user.type(nameInput, 'test instance!') expect(screen.getByText(/can only contain letters, numbers, hyphens, and underscores/)).toBeInTheDocument() - expect(screen.getByTestId('modal-save-button')).toBeDisabled() + expect(screen.getByTestId('dialog-save-button')).toBeDisabled() // Clear and test valid name await user.clear(nameInput) @@ -62,7 +68,7 @@ describe('InstanceModal - Form Logic and Validation', () => { await waitFor(() => { expect(screen.queryByText(/can only contain letters, numbers, hyphens, and underscores/)).not.toBeInTheDocument() - expect(screen.getByTestId('modal-save-button')).not.toBeDisabled() + expect(screen.getByTestId('dialog-save-button')).not.toBeDisabled() }) }) @@ -70,7 +76,7 @@ describe('InstanceModal - Form Logic and Validation', () => { const user = userEvent.setup() render( - { await user.type(screen.getByLabelText(/Instance Name/), 'my-instance') // Submit form - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) expect(mockOnSave).toHaveBeenCalledWith('my-instance', { auto_restart: true, // Default value }) }) - it('form resets when modal reopens', async () => { + it('form resets when dialog reopens', async () => { const { rerender } = render( - { const nameInput = screen.getByLabelText(/Instance Name/) await userEvent.setup().type(nameInput, 'temp-name') - // Close modal + // Close dialog rerender( - ) - // Reopen modal + // Reopen dialog rerender( - { it('pre-fills form with existing instance data', () => { render( - { const user = userEvent.setup() render( - { ) // Submit without changes - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) expect(mockOnSave).toHaveBeenCalledWith('existing-instance', { model: 'test-model.gguf', @@ -181,7 +187,7 @@ describe('InstanceModal - Form Logic and Validation', () => { const runningInstance: Instance = { ...mockInstance, running: true } const { rerender } = render( - { /> ) - expect(screen.getByTestId('modal-save-button')).toBeInTheDocument() + expect(screen.getByTestId('dialog-save-button')).toBeInTheDocument() rerender( - { describe('Auto Restart Configuration', () => { it('shows restart options when auto restart is enabled', () => { render( - { const user = userEvent.setup() render( - { const user = userEvent.setup() render( - { await user.type(screen.getByLabelText(/Max Restarts/), '5') await user.type(screen.getByLabelText(/Restart Delay/), '10') - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) expect(mockOnSave).toHaveBeenCalledWith('test-instance', { auto_restart: true, @@ -276,7 +282,7 @@ describe('InstanceModal - Form Logic and Validation', () => { const user = userEvent.setup() render( - { const user = userEvent.setup() render( - { // Fill only required field await user.type(screen.getByLabelText(/Instance Name/), 'clean-instance') - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) // Should only include non-empty values expect(mockOnSave).toHaveBeenCalledWith('clean-instance', { @@ -322,7 +328,7 @@ describe('InstanceModal - Form Logic and Validation', () => { const user = userEvent.setup() render( - { const gpuLayersInput = screen.getByLabelText(/GPU Layers/) await user.type(gpuLayersInput, '15') - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) expect(mockOnSave).toHaveBeenCalledWith('numeric-test', { auto_restart: true, @@ -349,14 +355,14 @@ describe('InstanceModal - Form Logic and Validation', () => { const user = userEvent.setup() render( - ) - await user.click(screen.getByTestId('modal-cancel-button')) + await user.click(screen.getByTestId('dialog-cancel-button')) expect(mockOnOpenChange).toHaveBeenCalledWith(false) }) @@ -365,7 +371,7 @@ describe('InstanceModal - Form Logic and Validation', () => { const user = userEvent.setup() render( - { ) await user.type(screen.getByLabelText(/Instance Name/), 'test') - await user.click(screen.getByTestId('modal-save-button')) + await user.click(screen.getByTestId('dialog-save-button')) expect(mockOnSave).toHaveBeenCalled() expect(mockOnOpenChange).toHaveBeenCalledWith(false) diff --git a/webui/src/contexts/AuthContext.tsx b/webui/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..3d2e7ff --- /dev/null +++ b/webui/src/contexts/AuthContext.tsx @@ -0,0 +1,162 @@ +import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react' + +interface AuthContextState { + isAuthenticated: boolean + isLoading: boolean + apiKey: string | null + error: string | null +} + +interface AuthContextActions { + login: (apiKey: string) => Promise + logout: () => void + clearError: () => void + validateAuth: () => Promise +} + +type AuthContextType = AuthContextState & AuthContextActions + +const AuthContext = createContext(undefined) + +interface AuthProviderProps { + children: ReactNode +} + +const AUTH_STORAGE_KEY = 'llamactl_management_key' + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [apiKey, setApiKey] = useState(null) + const [error, setError] = useState(null) + + // Load auth state from sessionStorage on mount + useEffect(() => { + const loadStoredAuth = async () => { + try { + const storedKey = sessionStorage.getItem(AUTH_STORAGE_KEY) + if (storedKey) { + setApiKey(storedKey) + // Validate the stored key + const isValid = await validateApiKey(storedKey) + if (isValid) { + setIsAuthenticated(true) + } else { + // Invalid key, remove it + sessionStorage.removeItem(AUTH_STORAGE_KEY) + setApiKey(null) + } + } + } catch (err) { + console.error('Error loading stored auth:', err) + // Clear potentially corrupted storage + sessionStorage.removeItem(AUTH_STORAGE_KEY) + } finally { + setIsLoading(false) + } + } + + void loadStoredAuth() + }, []) + + // Validate API key by making a test request + const validateApiKey = async (key: string): Promise => { + try { + const response = await fetch('/api/v1/instances', { + headers: { + 'Authorization': `Bearer ${key}`, + 'Content-Type': 'application/json' + } + }) + + return response.ok + } catch (err) { + console.error('Auth validation error:', err) + return false + } + } + + const login = useCallback(async (key: string) => { + setIsLoading(true) + setError(null) + + try { + // Validate the provided API key + const isValid = await validateApiKey(key) + + if (!isValid) { + throw new Error('Invalid API key') + } + + // Store the key and update state + sessionStorage.setItem(AUTH_STORAGE_KEY, key) + setApiKey(key) + setIsAuthenticated(true) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Authentication failed' + setError(errorMessage) + throw new Error(errorMessage) + } finally { + setIsLoading(false) + } + }, []) + + const logout = useCallback(() => { + sessionStorage.removeItem(AUTH_STORAGE_KEY) + setApiKey(null) + setIsAuthenticated(false) + setError(null) + }, []) + + const clearError = useCallback(() => { + setError(null) + }, []) + + const validateAuth = useCallback(async (): Promise => { + if (!apiKey) return false + + const isValid = await validateApiKey(apiKey) + if (!isValid) { + logout() + } + return isValid + }, [apiKey, logout]) + + const value: AuthContextType = { + isAuthenticated, + isLoading, + apiKey, + error, + login, + logout, + clearError, + validateAuth, + } + + return ( + + {children} + + ) +} + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +// Helper hook for getting auth headers +export const useAuthHeaders = (): HeadersInit => { + const { apiKey, isAuthenticated } = useAuth() + + if (!isAuthenticated || !apiKey) { + return {} + } + + return { + 'Authorization': `Bearer ${apiKey}` + } +} diff --git a/webui/src/contexts/InstancesContext.tsx b/webui/src/contexts/InstancesContext.tsx index cf37de5..8340bd9 100644 --- a/webui/src/contexts/InstancesContext.tsx +++ b/webui/src/contexts/InstancesContext.tsx @@ -1,7 +1,7 @@ -import type { ReactNode } from 'react'; -import { createContext, useContext, useState, useEffect, useCallback } from 'react' +import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react' import type { CreateInstanceOptions, Instance } from '@/types/instance' import { instancesApi } from '@/lib/api' +import { useAuth } from '@/contexts/AuthContext' interface InstancesContextState { instances: Instance[] @@ -29,6 +29,7 @@ interface InstancesProviderProps { } export const InstancesProvider = ({ children }: InstancesProviderProps) => { + const { isAuthenticated, isLoading: authLoading } = useAuth() const [instancesMap, setInstancesMap] = useState>(new Map()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -41,6 +42,11 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { }, []) const fetchInstances = useCallback(async () => { + if (!isAuthenticated) { + setLoading(false) + return + } + try { setLoading(true) setError(null) @@ -57,7 +63,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { } finally { setLoading(false) } - }, []) + }, [isAuthenticated]) const updateInstanceInMap = useCallback((name: string, updates: Partial) => { setInstancesMap(prev => { @@ -154,9 +160,19 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { } }, []) + // Only fetch instances when auth is ready and user is authenticated useEffect(() => { - fetchInstances() - }, [fetchInstances]) + if (!authLoading) { + if (isAuthenticated) { + void fetchInstances() + } else { + // Clear instances when not authenticated + setInstancesMap(new Map()) + setLoading(false) + setError(null) + } + } + }, [authLoading, isAuthenticated, fetchInstances]) const value: InstancesContextType = { instances, diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index d0d5fb4..aa4e8e3 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -1,12 +1,13 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import type { ReactNode } from 'react' -import { InstancesProvider, useInstances } from '@/contexts/InstancesContext' -import { instancesApi } from '@/lib/api' -import type { Instance } from '@/types/instance' +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { InstancesProvider, useInstances } from "@/contexts/InstancesContext"; +import { instancesApi } from "@/lib/api"; +import type { Instance } from "@/types/instance"; +import { AuthProvider } from "../AuthContext"; // Mock the API module -vi.mock('@/lib/api', () => ({ +vi.mock("@/lib/api", () => ({ instancesApi: { list: vi.fn(), create: vi.fn(), @@ -15,8 +16,8 @@ vi.mock('@/lib/api', () => ({ stop: vi.fn(), restart: vi.fn(), delete: vi.fn(), - } -})) + }, +})); // Test component to access context function TestComponent() { @@ -30,366 +31,389 @@ function TestComponent() { stopInstance, restartInstance, deleteInstance, - clearError - } = useInstances() + clearError, + } = useInstances(); return (
{loading.toString()}
-
{error || 'no-error'}
+
{error || "no-error"}
{instances.length}
- {instances.map(instance => ( + {instances.map((instance) => (
{instance.name}:{instance.running.toString()}
))} - + {/* Action buttons for testing with specific instances */} - - - - - - -
- ) + ); } function renderWithProvider(children: ReactNode) { return render( - - {children} - - ) + + {children} + + ); } -describe('InstancesContext', () => { +describe("InstancesContext", () => { const mockInstances: Instance[] = [ - { name: 'instance1', running: true, options: { model: 'model1.gguf' } }, - { name: 'instance2', running: false, options: { model: 'model2.gguf' } } - ] + { name: "instance1", running: true, options: { model: "model1.gguf" } }, + { name: "instance2", running: false, options: { model: "model2.gguf" } }, + ]; beforeEach(() => { - vi.clearAllMocks() + vi.clearAllMocks(); + window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123'); + global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))); // Default successful API responses - vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) - }) + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances); + }); afterEach(() => { - vi.clearAllMocks() - }) + vi.restoreAllMocks(); + }); - describe('Initial Loading', () => { - it('loads instances on mount', async () => { - renderWithProvider() + describe("Initial Loading", () => { + it("loads instances on mount", async () => { + renderWithProvider(); // Should start loading - expect(screen.getByTestId('loading')).toHaveTextContent('true') - + expect(screen.getByTestId("loading")).toHaveTextContent("true"); + // Should fetch instances await waitFor(() => { - expect(instancesApi.list).toHaveBeenCalledOnce() - }) + expect(instancesApi.list).toHaveBeenCalledOnce(); + }); // Should display loaded instances await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true') - expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false') - }) - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + expect(screen.getByTestId("instance-instance1")).toHaveTextContent( + "instance1:true" + ); + expect(screen.getByTestId("instance-instance2")).toHaveTextContent( + "instance2:false" + ); + }); + }); - it('handles API error during initial load', async () => { - const errorMessage = 'Network error' - vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) + it("handles API error during initial load", async () => { + const errorMessage = "Network error"; + vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) - expect(screen.getByTestId('instances-count')).toHaveTextContent('0') - }) - }) - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("error")).toHaveTextContent(errorMessage); + expect(screen.getByTestId("instances-count")).toHaveTextContent("0"); + }); + }); + }); - describe('Create Instance', () => { - it('creates instance and adds it to state', async () => { - const newInstance: Instance = { - name: 'new-instance', - running: false, - options: { model: 'test.gguf' } - } - vi.mocked(instancesApi.create).mockResolvedValue(newInstance) + describe("Create Instance", () => { + it("creates instance and adds it to state", async () => { + const newInstance: Instance = { + name: "new-instance", + running: false, + options: { model: "test.gguf" }, + }; + vi.mocked(instancesApi.create).mockResolvedValue(newInstance); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + }); - screen.getByTestId('create-instance').click() + screen.getByTestId("create-instance").click(); await waitFor(() => { - expect(instancesApi.create).toHaveBeenCalledWith('new-instance', { model: 'test.gguf' }) - }) + expect(instancesApi.create).toHaveBeenCalledWith("new-instance", { + model: "test.gguf", + }); + }); await waitFor(() => { - expect(screen.getByTestId('instances-count')).toHaveTextContent('3') - expect(screen.getByTestId('instance-new-instance')).toHaveTextContent('new-instance:false') - }) - }) + expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); + expect(screen.getByTestId("instance-new-instance")).toHaveTextContent( + "new-instance:false" + ); + }); + }); - it('handles create instance error without changing state', async () => { - const errorMessage = 'Instance already exists' - vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage)) + it("handles create instance error without changing state", async () => { + const errorMessage = "Instance already exists"; + vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage)); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + }); - screen.getByTestId('create-instance').click() + screen.getByTestId("create-instance").click(); await waitFor(() => { - expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) - }) + expect(screen.getByTestId("error")).toHaveTextContent(errorMessage); + }); - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - expect(screen.queryByTestId('instance-new-instance')).not.toBeInTheDocument() - }) - }) + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + expect( + screen.queryByTestId("instance-new-instance") + ).not.toBeInTheDocument(); + }); + }); - describe('Update Instance', () => { - it('updates instance and maintains it in state', async () => { - const updatedInstance: Instance = { - name: 'instance1', - running: true, - options: { model: 'updated.gguf' } - } - vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance) + describe("Update Instance", () => { + it("updates instance and maintains it in state", async () => { + const updatedInstance: Instance = { + name: "instance1", + running: true, + options: { model: "updated.gguf" }, + }; + vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + }); - screen.getByTestId('update-instance').click() + screen.getByTestId("update-instance").click(); await waitFor(() => { - expect(instancesApi.update).toHaveBeenCalledWith('instance1', { model: 'updated.gguf' }) - }) + expect(instancesApi.update).toHaveBeenCalledWith("instance1", { + model: "updated.gguf", + }); + }); await waitFor(() => { - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() - }) - }) - }) + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + expect(screen.getByTestId("instance-instance1")).toBeInTheDocument(); + }); + }); + }); - describe('Start/Stop Instance', () => { - it('starts existing instance and updates its running state', async () => { - vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) + describe("Start/Stop Instance", () => { + it("starts existing instance and updates its running state", async () => { + vi.mocked(instancesApi.start).mockResolvedValue({} as Instance); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId("loading")).toHaveTextContent("false"); // instance2 starts as not running - expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false') - }) + expect(screen.getByTestId("instance-instance2")).toHaveTextContent( + "instance2:false" + ); + }); // Start instance2 (button already configured to start instance2) - screen.getByTestId('start-instance').click() + screen.getByTestId("start-instance").click(); await waitFor(() => { - expect(instancesApi.start).toHaveBeenCalledWith('instance2') + expect(instancesApi.start).toHaveBeenCalledWith("instance2"); // The running state should be updated to true - expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true') - }) - }) + expect(screen.getByTestId("instance-instance2")).toHaveTextContent( + "instance2:true" + ); + }); + }); - it('stops instance and updates running state to false', async () => { - vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance) + it("stops instance and updates running state to false", async () => { + vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId("loading")).toHaveTextContent("false"); // instance1 starts as running - expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true') - }) + expect(screen.getByTestId("instance-instance1")).toHaveTextContent( + "instance1:true" + ); + }); // Stop instance1 (button already configured to stop instance1) - screen.getByTestId('stop-instance').click() + screen.getByTestId("stop-instance").click(); await waitFor(() => { - expect(instancesApi.stop).toHaveBeenCalledWith('instance1') + expect(instancesApi.stop).toHaveBeenCalledWith("instance1"); // The running state should be updated to false - expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:false') - }) - }) + expect(screen.getByTestId("instance-instance1")).toHaveTextContent( + "instance1:false" + ); + }); + }); - it('handles start instance error', async () => { - const errorMessage = 'Failed to start instance' - vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage)) + it("handles start instance error", async () => { + const errorMessage = "Failed to start instance"; + vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage)); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + }); - screen.getByTestId('start-instance').click() + screen.getByTestId("start-instance").click(); await waitFor(() => { - expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) - }) - }) - }) + expect(screen.getByTestId("error")).toHaveTextContent(errorMessage); + }); + }); + }); - describe('Delete Instance', () => { - it('deletes instance and removes it from state', async () => { - vi.mocked(instancesApi.delete).mockResolvedValue(undefined) + describe("Delete Instance", () => { + it("deletes instance and removes it from state", async () => { + vi.mocked(instancesApi.delete).mockResolvedValue(undefined); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - expect(screen.getByTestId('instance-instance2')).toBeInTheDocument() - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + expect(screen.getByTestId("instance-instance2")).toBeInTheDocument(); + }); - screen.getByTestId('delete-instance').click() + screen.getByTestId("delete-instance").click(); await waitFor(() => { - expect(instancesApi.delete).toHaveBeenCalledWith('instance2') - }) + expect(instancesApi.delete).toHaveBeenCalledWith("instance2"); + }); await waitFor(() => { - expect(screen.getByTestId('instances-count')).toHaveTextContent('1') - expect(screen.queryByTestId('instance-instance2')).not.toBeInTheDocument() - expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() // instance1 should still exist - }) - }) + expect(screen.getByTestId("instances-count")).toHaveTextContent("1"); + expect( + screen.queryByTestId("instance-instance2") + ).not.toBeInTheDocument(); + expect(screen.getByTestId("instance-instance1")).toBeInTheDocument(); // instance1 should still exist + }); + }); - it('handles delete instance error without changing state', async () => { - const errorMessage = 'Instance is running' - vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage)) + it("handles delete instance error without changing state", async () => { + const errorMessage = "Instance is running"; + vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage)); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + }); - screen.getByTestId('delete-instance').click() + screen.getByTestId("delete-instance").click(); await waitFor(() => { - expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) - }) + expect(screen.getByTestId("error")).toHaveTextContent(errorMessage); + }); - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - expect(screen.getByTestId('instance-instance2')).toBeInTheDocument() - }) - }) + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + expect(screen.getByTestId("instance-instance2")).toBeInTheDocument(); + }); + }); - describe('Error Management', () => { - it('clears error when clearError is called', async () => { - const errorMessage = 'Test error' - vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) + describe("Error Management", () => { + it("clears error when clearError is called", async () => { + const errorMessage = "Test error"; + vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) - }) + expect(screen.getByTestId("error")).toHaveTextContent(errorMessage); + }); - screen.getByTestId('clear-error').click() + screen.getByTestId("clear-error").click(); await waitFor(() => { - expect(screen.getByTestId('error')).toHaveTextContent('no-error') - }) - }) - }) + expect(screen.getByTestId("error")).toHaveTextContent("no-error"); + }); + }); + }); - describe('State Consistency', () => { - it('maintains consistent state during multiple operations', async () => { + describe("State Consistency", () => { + it("maintains consistent state during multiple operations", async () => { // Test that operations don't interfere with each other - const newInstance: Instance = { - name: 'new-instance', - running: false, - options: {} - } - vi.mocked(instancesApi.create).mockResolvedValue(newInstance) - vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) + const newInstance: Instance = { + name: "new-instance", + running: false, + options: {}, + }; + vi.mocked(instancesApi.create).mockResolvedValue(newInstance); + vi.mocked(instancesApi.start).mockResolvedValue({} as Instance); - renderWithProvider() + renderWithProvider(); await waitFor(() => { - expect(screen.getByTestId('loading')).toHaveTextContent('false') - expect(screen.getByTestId('instances-count')).toHaveTextContent('2') - }) + expect(screen.getByTestId("loading")).toHaveTextContent("false"); + expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); + }); // Create new instance - screen.getByTestId('create-instance').click() + screen.getByTestId("create-instance").click(); await waitFor(() => { - expect(screen.getByTestId('instances-count')).toHaveTextContent('3') - }) + expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); + }); // Start an instance (this should not affect the count) - screen.getByTestId('start-instance').click() + screen.getByTestId("start-instance").click(); await waitFor(() => { - expect(instancesApi.start).toHaveBeenCalled() - expect(screen.getByTestId('instances-count')).toHaveTextContent('3') // Still 3 + expect(instancesApi.start).toHaveBeenCalled(); + expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3 // But the running state should change - expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true') - }) - }) - }) -}) \ No newline at end of file + expect(screen.getByTestId("instance-instance2")).toHaveTextContent( + "instance2:true" + ); + }); + }); + }); +}); diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 66eb63c..14ccc6b 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -10,18 +10,31 @@ async function apiCall( ): Promise { const url = `${API_BASE}${endpoint}`; - // Prepare headers - const headers: HeadersInit = { + // Get auth token from sessionStorage (same as AuthContext) + const storedKey = sessionStorage.getItem('llamactl_management_key'); + + // Prepare headers with auth + const headers: Record = { "Content-Type": "application/json", - ...options.headers, + ...(options.headers as Record), }; + // Add auth header if available + if (storedKey) { + headers['Authorization'] = `Bearer ${storedKey}`; + } + try { const response = await fetch(url, { ...options, headers, }); + // Handle authentication errors + if (response.status === 401) { + throw new Error('Authentication required'); + } + if (!response.ok) { // Try to get error message from response let errorMessage = `HTTP ${response.status}`; @@ -47,7 +60,7 @@ async function apiCall( const text = await response.text(); return text as T; } else { - const data = await response.json(); + const data = await response.json() as T; return data; } } catch (error) { @@ -121,4 +134,7 @@ export const instancesApi = { const params = lines ? `?lines=${lines}` : ""; return apiCall(`/instances/${name}/logs${params}`, {}, "text"); }, + + // GET /instances/{name}/proxy/health + getHealth: (name: string) => apiCall(`/instances/${name}/proxy/health`), }; diff --git a/webui/src/lib/healthService.ts b/webui/src/lib/healthService.ts index 2f52c97..025d29e 100644 --- a/webui/src/lib/healthService.ts +++ b/webui/src/lib/healthService.ts @@ -1,4 +1,5 @@ import { type HealthStatus } from '@/types/instance' +import { instancesApi } from '@/lib/api' type HealthCallback = (health: HealthStatus) => void @@ -8,31 +9,33 @@ class HealthService { async checkHealth(instanceName: string): Promise { try { - const response = await fetch(`/api/v1/instances/${instanceName}/proxy/health`) + await instancesApi.getHealth(instanceName) - if (response.status === 200) { - return { - status: 'ok', - lastChecked: new Date() + return { + status: 'ok', + lastChecked: new Date() + } + } catch (error) { + if (error instanceof Error) { + // Check if it's a 503 (service unavailable - loading) + if (error.message.includes('503')) { + return { + status: 'loading', + message: 'Instance is starting up', + lastChecked: new Date() + } } - } else if (response.status === 503) { - const data = await response.json() - return { - status: 'loading', - message: data.error.message, - lastChecked: new Date() - } - } else { + return { status: 'error', - message: `HTTP ${response.status}`, + message: error.message, lastChecked: new Date() } } - } catch (error) { + return { status: 'error', - message: 'Network error', + message: 'Unknown error', lastChecked: new Date() } } @@ -82,7 +85,7 @@ class HealthService { }, 60000) this.intervals.set(instanceName, interval) - }, 2000) + }, 5000) } private stopHealthCheck(instanceName: string): void { diff --git a/webui/src/main.tsx b/webui/src/main.tsx index ec2cd1d..ab046c2 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client' import App from './App' import { InstancesProvider } from './contexts/InstancesContext' import './index.css' +import { AuthProvider } from './contexts/AuthContext' ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + , ) \ No newline at end of file