From c038cabaf6a4de6c400537a9a90a951ece1c3a14 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 31 Jul 2025 18:59:12 +0200 Subject: [PATCH 1/4] Implement authentication flow with API key support and loading states --- webui/src/App.tsx | 29 ++++- webui/src/components/Header.tsx | 27 +++- webui/src/components/LoginDialog.tsx | 157 +++++++++++++++++++++++ webui/src/contexts/AuthContext.tsx | 162 ++++++++++++++++++++++++ webui/src/contexts/InstancesContext.tsx | 26 +++- webui/src/lib/api.ts | 21 ++- webui/src/main.tsx | 9 +- 7 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 webui/src/components/LoginDialog.tsx create mode 100644 webui/src/contexts/AuthContext.tsx diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 3822554..6ae4e78 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -2,11 +2,14 @@ import { useState } from "react"; import Header from "@/components/Header"; import InstanceList from "@/components/InstanceList"; import InstanceModal from "@/components/InstanceModal"; +import LoginDialog from "@/components/LoginDialog"; +import SystemInfoModal from "./components/SystemInfoModal"; 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 (
@@ -58,4 +83,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file 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/LoginDialog.tsx b/webui/src/components/LoginDialog.tsx new file mode 100644 index 0000000..3f29948 --- /dev/null +++ b/webui/src/components/LoginDialog.tsx @@ -0,0 +1,157 @@ +import React, { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { AlertCircle, Key, Loader2 } from 'lucide-react' +import { useAuth } from '@/contexts/AuthContext' + +interface LoginDialogProps { + open: boolean + onOpenChange?: (open: boolean) => void +} + +const LoginDialog: React.FC = ({ + open, + onOpenChange, +}) => { + const { login, isLoading, error, clearError } = useAuth() + const [apiKey, setApiKey] = useState('') + const [localLoading, setLocalLoading] = useState(false) + + // Clear form and errors when dialog opens/closes + useEffect(() => { + if (open) { + setApiKey('') + clearError() + } + }, [open, clearError]) + + // Clear error when user starts typing + useEffect(() => { + if (error && apiKey) { + clearError() + } + }, [apiKey, error, clearError]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!apiKey.trim()) { + return + } + + setLocalLoading(true) + + try { + await login(apiKey.trim()) + // Login successful - dialog will close automatically when auth state changes + setApiKey('') + } catch (err) { + // Error is handled by the AuthContext + console.error('Login failed:', err) + } finally { + setLocalLoading(false) + } + } + + const handleCancel = () => { + setApiKey('') + clearError() + onOpenChange?.(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isSubmitDisabled) { + // Create a synthetic FormEvent to satisfy handleSubmit's type + const syntheticEvent = { + preventDefault: () => {}, + } as React.FormEvent; + void handleSubmit(syntheticEvent) + } + } + + const isSubmitDisabled = !apiKey.trim() || isLoading || localLoading + + return ( + + + + + + Authentication Required + + + Please enter your management API key to access the Llamactl dashboard. + + + +
{ void handleSubmit(e) }}> +
+ {/* Error Display */} + {error && ( +
+ + {error} +
+ )} + + {/* API Key Input */} +
+ + setApiKey(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="sk-management-..." + disabled={isLoading || localLoading} + className={error ? "border-red-500" : ""} + autoFocus + autoComplete="off" + /> +

+ Your management API key is required to access instance management features. +

+
+
+ + + + +
+
+
+ ) +} + +export default LoginDialog \ No newline at end of file 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/lib/api.ts b/webui/src/lib/api.ts index 66eb63c..25aba18 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) { 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 From f94a150b070cdc9c5e382680156a630f9918a9e3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 31 Jul 2025 19:03:43 +0200 Subject: [PATCH 2/4] Refactor modals to dialogs and update related tests for consistency --- webui/src/App.tsx | 8 +-- webui/src/__tests__/App.test.tsx | 4 +- webui/src/components/InstanceCard.tsx | 4 +- .../{InstanceModal.tsx => InstanceDialog.tsx} | 12 ++-- .../{LogModal.tsx => LogDialog.tsx} | 8 +-- ...stemInfoModal.tsx => SystemInfoDialog.tsx} | 6 +- .../__tests__/InstanceCard.test.tsx | 16 ++--- .../__tests__/InstanceModal.test.tsx | 66 +++++++++---------- 8 files changed, 62 insertions(+), 62 deletions(-) rename webui/src/components/{InstanceModal.tsx => InstanceDialog.tsx} (97%) rename webui/src/components/{LogModal.tsx => LogDialog.tsx} (98%) rename webui/src/components/{SystemInfoModal.tsx => SystemInfoDialog.tsx} (97%) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6ae4e78..a909ccb 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,9 +1,9 @@ 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 SystemInfoModal from "./components/SystemInfoModal"; +import SystemInfoDialog from "./components/SystemInfoDialog"; import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -68,14 +68,14 @@ function App() { - - diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 0159601..7289016 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -75,7 +75,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 +109,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(() => { 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 = ({ - - - - - - - ) + ); } 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" + ); + }); + }); + }); +});