Merge pull request #8 from lordmathis/feat/frontend-api-auth

feat: Add management API key authentication with login dialog
This commit is contained in:
2025-07-31 20:36:32 +02:00
committed by GitHub
17 changed files with 786 additions and 355 deletions

View File

@@ -1,12 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import Header from "@/components/Header"; import Header from "@/components/Header";
import InstanceList from "@/components/InstanceList"; 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 { type CreateInstanceOptions, type Instance } from "@/types/instance";
import { useInstances } from "@/contexts/InstancesContext"; import { useInstances } from "@/contexts/InstancesContext";
import SystemInfoModal from "./components/SystemInfoModal"; import { useAuth } from "@/contexts/AuthContext";
function App() { function App() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
const [editingInstance, setEditingInstance] = useState<Instance | undefined>( const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
@@ -36,6 +39,28 @@ function App() {
setIsSystemInfoModalOpen(true); setIsSystemInfoModalOpen(true);
}; };
// Show loading spinner while checking auth
if (authLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
// Show login dialog if not authenticated
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-gray-50">
<LoginDialog open={true} />
</div>
);
}
// Show main app if authenticated
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} /> <Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} />
@@ -43,14 +68,14 @@ function App() {
<InstanceList editInstance={handleEditInstance} /> <InstanceList editInstance={handleEditInstance} />
</main> </main>
<InstanceModal <InstanceDialog
open={isInstanceModalOpen} open={isInstanceModalOpen}
onOpenChange={setIsInstanceModalOpen} onOpenChange={setIsInstanceModalOpen}
onSave={handleSaveInstance} onSave={handleSaveInstance}
instance={editingInstance} instance={editingInstance}
/> />
<SystemInfoModal <SystemInfoDialog
open={isSystemInfoModalOpen} open={isSystemInfoModalOpen}
onOpenChange={setIsSystemInfoModalOpen} onOpenChange={setIsSystemInfoModalOpen}
/> />

View File

@@ -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 { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import App from '@/App' import App from '@/App'
import { InstancesProvider } from '@/contexts/InstancesContext' import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
import { AuthProvider } from '@/contexts/AuthContext'
// Mock the API // Mock the API
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
@@ -35,9 +36,11 @@ vi.mock('@/lib/healthService', () => ({
function renderApp() { function renderApp() {
return render( return render(
<AuthProvider>
<InstancesProvider> <InstancesProvider>
<App /> <App />
</InstancesProvider> </InstancesProvider>
</AuthProvider>
) )
} }
@@ -50,6 +53,12 @@ describe('App Component - Critical Business Logic Only', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) 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', () => { describe('End-to-End Instance Management', () => {
@@ -75,7 +84,7 @@ describe('App Component - Critical Business Logic Only', () => {
const nameInput = screen.getByLabelText(/Instance Name/) const nameInput = screen.getByLabelText(/Instance Name/)
await user.type(nameInput, 'new-test-instance') 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 // Verify correct API call
await waitFor(() => { await waitFor(() => {
@@ -109,7 +118,7 @@ describe('App Component - Critical Business Logic Only', () => {
const editButtons = screen.getAllByTitle('Edit instance') const editButtons = screen.getAllByTitle('Edit instance')
await user.click(editButtons[0]) 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 // Verify correct API call with existing instance data
await waitFor(() => { await waitFor(() => {
@@ -167,7 +176,6 @@ describe('App Component - Critical Business Logic Only', () => {
renderApp() renderApp()
// App should still render and show error // App should still render and show error
expect(screen.getByText('Llamactl Dashboard')).toBeInTheDocument()
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error loading instances')).toBeInTheDocument() expect(screen.getByText('Error loading instances')).toBeInTheDocument()
}) })

View File

@@ -1,5 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpCircle } from "lucide-react"; import { HelpCircle, LogOut } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
interface HeaderProps { interface HeaderProps {
onCreateInstance: () => void; onCreateInstance: () => void;
@@ -7,6 +8,14 @@ interface HeaderProps {
} }
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
const { logout } = useAuth();
const handleLogout = () => {
if (confirm("Are you sure you want to logout?")) {
logout();
}
};
return ( return (
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="container mx-auto max-w-4xl px-4 py-4"> <div className="container mx-auto max-w-4xl px-4 py-4">
@@ -16,7 +25,9 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={onCreateInstance} data-testid="create-instance-button">Create Instance</Button> <Button onClick={onCreateInstance} data-testid="create-instance-button">
Create Instance
</Button>
<Button <Button
variant="outline" variant="outline"
@@ -27,6 +38,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
</Button> </Button>
<Button
variant="outline"
size="icon"
onClick={handleLogout}
data-testid="logout-button"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Instance } from "@/types/instance"; import type { Instance } from "@/types/instance";
import { Edit, FileText, Play, Square, Trash2 } from "lucide-react"; 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 HealthBadge from "@/components/HealthBadge";
import { useState } from "react"; import { useState } from "react";
import { useInstanceHealth } from "@/hooks/useInstanceHealth"; import { useInstanceHealth } from "@/hooks/useInstanceHealth";
@@ -118,7 +118,7 @@ function InstanceCard({
</CardContent> </CardContent>
</Card> </Card>
<LogsModal <LogsDialog
open={isLogsOpen} open={isLogsOpen}
onOpenChange={setIsLogsOpen} onOpenChange={setIsLogsOpen}
instanceName={instance.name} instanceName={instance.name}

View File

@@ -15,14 +15,14 @@ import { getBasicFields, getAdvancedFields } from "@/lib/zodFormUtils";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import ZodFormField from "@/components/ZodFormField"; import ZodFormField from "@/components/ZodFormField";
interface InstanceModalProps { interface InstanceDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (name: string, options: CreateInstanceOptions) => void; onSave: (name: string, options: CreateInstanceOptions) => void;
instance?: Instance; // For editing existing instance instance?: Instance; // For editing existing instance
} }
const InstanceModal: React.FC<InstanceModalProps> = ({ const InstanceDialog: React.FC<InstanceDialogProps> = ({
open, open,
onOpenChange, onOpenChange,
onSave, onSave,
@@ -40,7 +40,7 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
const basicFields = getBasicFields(); const basicFields = getBasicFields();
const advancedFields = getAdvancedFields(); const advancedFields = getAdvancedFields();
// Reset form when modal opens/closes or when instance changes // Reset form when dialog opens/closes or when instance changes
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (instance) { if (instance) {
@@ -255,14 +255,14 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
<Button <Button
variant="outline" variant="outline"
onClick={handleCancel} onClick={handleCancel}
data-testid="modal-cancel-button" data-testid="dialog-cancel-button"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={!instanceName.trim() || !!nameError} disabled={!instanceName.trim() || !!nameError}
data-testid="modal-save-button" data-testid="dialog-save-button"
> >
{isEditing {isEditing
? isRunning ? isRunning
@@ -276,4 +276,4 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
); );
}; };
export default InstanceModal; export default InstanceDialog;

View File

@@ -21,14 +21,14 @@ import {
Settings Settings
} from 'lucide-react' } from 'lucide-react'
interface LogsModalProps { interface LogsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
instanceName: string instanceName: string
isRunning: boolean isRunning: boolean
} }
const LogsModal: React.FC<LogsModalProps> = ({ const LogsDialog: React.FC<LogsDialogProps> = ({
open, open,
onOpenChange, onOpenChange,
instanceName, instanceName,
@@ -76,7 +76,7 @@ const LogsModal: React.FC<LogsModalProps> = ({
} }
} }
// Initial load when modal opens // Initial load when dialog opens
useEffect(() => { useEffect(() => {
if (open && instanceName) { if (open && instanceName) {
fetchLogs(lineCount) fetchLogs(lineCount)
@@ -327,4 +327,4 @@ const LogsModal: React.FC<LogsModalProps> = ({
) )
} }
export default LogsModal export default LogsDialog

View File

@@ -0,0 +1,151 @@
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<LoginDialogProps> = ({
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 handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !isSubmitDisabled) {
// Create a synthetic FormEvent to satisfy handleSubmit's type
const syntheticEvent = {
preventDefault: () => {},
} as React.FormEvent<HTMLFormElement>;
void handleSubmit(syntheticEvent)
}
}
const isSubmitDisabled = !apiKey.trim() || isLoading || localLoading
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
showCloseButton={false} // Prevent closing without auth
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Authentication Required
</DialogTitle>
<DialogDescription>
Please enter your management API key to access the Llamactl dashboard.
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => { void handleSubmit(e) }}>
<div className="grid gap-4 py-4">
{/* Error Display */}
{error && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">{error}</span>
</div>
)}
{/* API Key Input */}
<div className="grid gap-2">
<Label htmlFor="apiKey">
Management API Key <span className="text-red-500">*</span>
</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="sk-management-..."
disabled={isLoading || localLoading}
className={error ? "border-red-500" : ""}
autoFocus
autoComplete="off"
/>
<p className="text-sm text-muted-foreground">
Your management API key is required to access instance management features.
</p>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button
type="submit"
disabled={isSubmitDisabled}
data-testid="login-submit-button"
>
{(isLoading || localLoading) ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
<>
<Key className="h-4 w-4" />
Login
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export default LoginDialog

View File

@@ -30,7 +30,7 @@ interface SystemInfo {
help: string help: string
} }
const SystemInfoModal: React.FC<SystemInfoModalProps> = ({ const SystemInfoDialog: React.FC<SystemInfoModalProps> = ({
open, open,
onOpenChange onOpenChange
}) => { }) => {
@@ -59,7 +59,7 @@ const SystemInfoModal: React.FC<SystemInfoModalProps> = ({
} }
} }
// Load data when modal opens // Load data when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchSystemInfo() fetchSystemInfo()
@@ -180,4 +180,4 @@ const SystemInfoModal: React.FC<SystemInfoModalProps> = ({
) )
} }
export default SystemInfoModal export default SystemInfoDialog

View File

@@ -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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import InstanceCard from '@/components/InstanceCard' import InstanceCard from '@/components/InstanceCard'
@@ -29,6 +29,12 @@ describe('InstanceCard - Instance Actions and State', () => {
beforeEach(() => { 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 })))
})
afterEach(() => {
vi.restoreAllMocks()
}) })
describe('Instance Action Buttons', () => { describe('Instance Action Buttons', () => {
@@ -93,7 +99,7 @@ describe('InstanceCard - Instance Actions and State', () => {
expect(mockEditInstance).toHaveBeenCalledWith(stoppedInstance) 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() const user = userEvent.setup()
render( render(
@@ -109,7 +115,7 @@ describe('InstanceCard - Instance Actions and State', () => {
const logsButton = screen.getByTitle('View logs') const logsButton = screen.getByTitle('View logs')
await user.click(logsButton) 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() 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')) 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() 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 closeButtons = screen.getAllByText('Close')
const modalCloseButton = closeButtons.find(button => const dialogCloseButton = closeButtons.find(button =>
button.closest('[data-slot="dialog-content"]') button.closest('[data-slot="dialog-content"]')
) )
expect(modalCloseButton).toBeTruthy() expect(dialogCloseButton).toBeTruthy()
await user.click(modalCloseButton!) await user.click(dialogCloseButton!)
// Modal should close // Modal should close
expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument() expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument()

View File

@@ -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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import InstanceList from '@/components/InstanceList' import InstanceList from '@/components/InstanceList'
import { InstancesProvider } from '@/contexts/InstancesContext' import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
import { AuthProvider } from '@/contexts/AuthContext'
// Mock the API // Mock the API
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
@@ -30,13 +31,16 @@ vi.mock('@/lib/healthService', () => ({
function renderInstanceList(editInstance = vi.fn()) { function renderInstanceList(editInstance = vi.fn()) {
return render( return render(
<AuthProvider>
<InstancesProvider> <InstancesProvider>
<InstanceList editInstance={editInstance} /> <InstanceList editInstance={editInstance} />
</InstancesProvider> </InstancesProvider>
</AuthProvider>
) )
} }
describe('InstanceList - State Management and UI Logic', () => { describe('InstanceList - State Management and UI Logic', () => {
const mockEditInstance = vi.fn() const mockEditInstance = vi.fn()
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
@@ -45,12 +49,20 @@ describe('InstanceList - State Management and UI Logic', () => {
{ name: 'instance-3', running: false, options: { model: 'model3.gguf' } } { name: 'instance-3', running: false, options: { model: 'model3.gguf' } }
] ]
const DUMMY_API_KEY = 'test-api-key-123'
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() 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', () => { 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 // Mock a delayed response to test loading state
vi.mocked(instancesApi.list).mockImplementation(() => vi.mocked(instancesApi.list).mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockInstances), 100)) 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(await screen.findByText('Instances (3)')).toBeInTheDocument()
expect(screen.queryByText('Loading instances...')).not.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(
<InstancesProvider>
<InstanceList editInstance={mockEditInstance} />
</InstancesProvider>
)
// Should eventually show instances
// Note: This test is somewhat artificial since the context handles retries
expect(screen.getByText('Error loading instances')).toBeInTheDocument()
})
}) })
}) })

View File

@@ -1,7 +1,7 @@
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 { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import InstanceModal from '@/components/InstanceModal' import InstanceDialog from '@/components/InstanceDialog'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
describe('InstanceModal - Form Logic and Validation', () => { describe('InstanceModal - Form Logic and Validation', () => {
@@ -10,6 +10,12 @@ describe('InstanceModal - Form Logic and Validation', () => {
beforeEach(() => { 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 })))
})
afterEach(() => {
vi.restoreAllMocks()
}) })
describe('Create Mode', () => { describe('Create Mode', () => {
@@ -17,7 +23,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -25,7 +31,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
) )
// Try to submit without name // Try to submit without name
const saveButton = screen.getByTestId('modal-save-button') const saveButton = screen.getByTestId('dialog-save-button')
expect(saveButton).toBeDisabled() expect(saveButton).toBeDisabled()
// Add name, button should be enabled // Add name, button should be enabled
@@ -41,7 +47,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -54,7 +60,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
await user.type(nameInput, 'test instance!') await user.type(nameInput, 'test instance!')
expect(screen.getByText(/can only contain letters, numbers, hyphens, and underscores/)).toBeInTheDocument() 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 // Clear and test valid name
await user.clear(nameInput) await user.clear(nameInput)
@@ -62,7 +68,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/can only contain letters, numbers, hyphens, and underscores/)).not.toBeInTheDocument() 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() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -81,16 +87,16 @@ describe('InstanceModal - Form Logic and Validation', () => {
await user.type(screen.getByLabelText(/Instance Name/), 'my-instance') await user.type(screen.getByLabelText(/Instance Name/), 'my-instance')
// Submit form // Submit form
await user.click(screen.getByTestId('modal-save-button')) await user.click(screen.getByTestId('dialog-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('my-instance', { expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
auto_restart: true, // Default value auto_restart: true, // Default value
}) })
}) })
it('form resets when modal reopens', async () => { it('form resets when dialog reopens', async () => {
const { rerender } = render( const { rerender } = render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -101,18 +107,18 @@ describe('InstanceModal - Form Logic and Validation', () => {
const nameInput = screen.getByLabelText(/Instance Name/) const nameInput = screen.getByLabelText(/Instance Name/)
await userEvent.setup().type(nameInput, 'temp-name') await userEvent.setup().type(nameInput, 'temp-name')
// Close modal // Close dialog
rerender( rerender(
<InstanceModal <InstanceDialog
open={false} open={false}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
/> />
) )
// Reopen modal // Reopen dialog
rerender( rerender(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -138,7 +144,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
it('pre-fills form with existing instance data', () => { it('pre-fills form with existing instance data', () => {
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -159,7 +165,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -168,7 +174,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
) )
// Submit without changes // Submit without changes
await user.click(screen.getByTestId('modal-save-button')) await user.click(screen.getByTestId('dialog-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('existing-instance', { expect(mockOnSave).toHaveBeenCalledWith('existing-instance', {
model: 'test-model.gguf', model: 'test-model.gguf',
@@ -181,7 +187,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const runningInstance: Instance = { ...mockInstance, running: true } const runningInstance: Instance = { ...mockInstance, running: true }
const { rerender } = render( const { rerender } = render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -189,10 +195,10 @@ describe('InstanceModal - Form Logic and Validation', () => {
/> />
) )
expect(screen.getByTestId('modal-save-button')).toBeInTheDocument() expect(screen.getByTestId('dialog-save-button')).toBeInTheDocument()
rerender( rerender(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -207,7 +213,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
describe('Auto Restart Configuration', () => { describe('Auto Restart Configuration', () => {
it('shows restart options when auto restart is enabled', () => { it('shows restart options when auto restart is enabled', () => {
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -227,7 +233,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -247,7 +253,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -261,7 +267,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
await user.type(screen.getByLabelText(/Max Restarts/), '5') await user.type(screen.getByLabelText(/Max Restarts/), '5')
await user.type(screen.getByLabelText(/Restart Delay/), '10') 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', { expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
auto_restart: true, auto_restart: true,
@@ -276,7 +282,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -300,7 +306,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -310,7 +316,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
// Fill only required field // Fill only required field
await user.type(screen.getByLabelText(/Instance Name/), 'clean-instance') 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 // Should only include non-empty values
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', { expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
@@ -322,7 +328,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -335,7 +341,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const gpuLayersInput = screen.getByLabelText(/GPU Layers/) const gpuLayersInput = screen.getByLabelText(/GPU Layers/)
await user.type(gpuLayersInput, '15') 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', { expect(mockOnSave).toHaveBeenCalledWith('numeric-test', {
auto_restart: true, auto_restart: true,
@@ -349,14 +355,14 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
/> />
) )
await user.click(screen.getByTestId('modal-cancel-button')) await user.click(screen.getByTestId('dialog-cancel-button'))
expect(mockOnOpenChange).toHaveBeenCalledWith(false) expect(mockOnOpenChange).toHaveBeenCalledWith(false)
}) })
@@ -365,7 +371,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
const user = userEvent.setup() const user = userEvent.setup()
render( render(
<InstanceModal <InstanceDialog
open={true} open={true}
onOpenChange={mockOnOpenChange} onOpenChange={mockOnOpenChange}
onSave={mockOnSave} onSave={mockOnSave}
@@ -373,7 +379,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
) )
await user.type(screen.getByLabelText(/Instance Name/), 'test') 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(mockOnSave).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false) expect(mockOnOpenChange).toHaveBeenCalledWith(false)

View File

@@ -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<void>
logout: () => void
clearError: () => void
validateAuth: () => Promise<boolean>
}
type AuthContextType = AuthContextState & AuthContextActions
const AuthContext = createContext<AuthContextType | undefined>(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<string | null>(null)
const [error, setError] = useState<string | null>(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<boolean> => {
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<boolean> => {
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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
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}`
}
}

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react'
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import type { CreateInstanceOptions, Instance } from '@/types/instance' import type { CreateInstanceOptions, Instance } from '@/types/instance'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import { useAuth } from '@/contexts/AuthContext'
interface InstancesContextState { interface InstancesContextState {
instances: Instance[] instances: Instance[]
@@ -29,6 +29,7 @@ interface InstancesProviderProps {
} }
export const InstancesProvider = ({ children }: InstancesProviderProps) => { export const InstancesProvider = ({ children }: InstancesProviderProps) => {
const { isAuthenticated, isLoading: authLoading } = useAuth()
const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map()) const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -41,6 +42,11 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
}, []) }, [])
const fetchInstances = useCallback(async () => { const fetchInstances = useCallback(async () => {
if (!isAuthenticated) {
setLoading(false)
return
}
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -57,7 +63,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [isAuthenticated])
const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => { const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => {
setInstancesMap(prev => { setInstancesMap(prev => {
@@ -154,9 +160,19 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
} }
}, []) }, [])
// Only fetch instances when auth is ready and user is authenticated
useEffect(() => { useEffect(() => {
fetchInstances() if (!authLoading) {
}, [fetchInstances]) if (isAuthenticated) {
void fetchInstances()
} else {
// Clear instances when not authenticated
setInstancesMap(new Map())
setLoading(false)
setError(null)
}
}
}, [authLoading, isAuthenticated, fetchInstances])
const value: InstancesContextType = { const value: InstancesContextType = {
instances, instances,

View File

@@ -1,12 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from 'react' import type { ReactNode } from "react";
import { InstancesProvider, useInstances } from '@/contexts/InstancesContext' import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
import { instancesApi } from '@/lib/api' import { instancesApi } from "@/lib/api";
import type { Instance } from '@/types/instance' import type { Instance } from "@/types/instance";
import { AuthProvider } from "../AuthContext";
// Mock the API module // Mock the API module
vi.mock('@/lib/api', () => ({ vi.mock("@/lib/api", () => ({
instancesApi: { instancesApi: {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
@@ -15,8 +16,8 @@ vi.mock('@/lib/api', () => ({
stop: vi.fn(), stop: vi.fn(),
restart: vi.fn(), restart: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
} },
})) }));
// Test component to access context // Test component to access context
function TestComponent() { function TestComponent() {
@@ -30,15 +31,15 @@ function TestComponent() {
stopInstance, stopInstance,
restartInstance, restartInstance,
deleteInstance, deleteInstance,
clearError clearError,
} = useInstances() } = useInstances();
return ( return (
<div> <div>
<div data-testid="loading">{loading.toString()}</div> <div data-testid="loading">{loading.toString()}</div>
<div data-testid="error">{error || 'no-error'}</div> <div data-testid="error">{error || "no-error"}</div>
<div data-testid="instances-count">{instances.length}</div> <div data-testid="instances-count">{instances.length}</div>
{instances.map(instance => ( {instances.map((instance) => (
<div key={instance.name} data-testid={`instance-${instance.name}`}> <div key={instance.name} data-testid={`instance-${instance.name}`}>
{instance.name}:{instance.running.toString()} {instance.name}:{instance.running.toString()}
</div> </div>
@@ -46,350 +47,373 @@ function TestComponent() {
{/* Action buttons for testing with specific instances */} {/* Action buttons for testing with specific instances */}
<button <button
onClick={() => createInstance('new-instance', { model: 'test.gguf' })} onClick={() => createInstance("new-instance", { model: "test.gguf" })}
data-testid="create-instance" data-testid="create-instance"
> >
Create Instance Create Instance
</button> </button>
<button <button
onClick={() => updateInstance('instance1', { model: 'updated.gguf' })} onClick={() => updateInstance("instance1", { model: "updated.gguf" })}
data-testid="update-instance" data-testid="update-instance"
> >
Update Instance Update Instance
</button> </button>
<button <button
onClick={() => startInstance('instance2')} onClick={() => startInstance("instance2")}
data-testid="start-instance" data-testid="start-instance"
> >
Start Instance2 Start Instance2
</button> </button>
<button <button
onClick={() => stopInstance('instance1')} onClick={() => stopInstance("instance1")}
data-testid="stop-instance" data-testid="stop-instance"
> >
Stop Instance1 Stop Instance1
</button> </button>
<button <button
onClick={() => restartInstance('instance1')} onClick={() => restartInstance("instance1")}
data-testid="restart-instance" data-testid="restart-instance"
> >
Restart Instance1 Restart Instance1
</button> </button>
<button <button
onClick={() => deleteInstance('instance2')} onClick={() => deleteInstance("instance2")}
data-testid="delete-instance" data-testid="delete-instance"
> >
Delete Instance2 Delete Instance2
</button> </button>
<button <button onClick={clearError} data-testid="clear-error">
onClick={clearError}
data-testid="clear-error"
>
Clear Error Clear Error
</button> </button>
</div> </div>
) );
} }
function renderWithProvider(children: ReactNode) { function renderWithProvider(children: ReactNode) {
return render( return render(
<InstancesProvider> <AuthProvider>
{children} <InstancesProvider>{children}</InstancesProvider>
</InstancesProvider> </AuthProvider>
) );
} }
describe('InstancesContext', () => { describe("InstancesContext", () => {
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: 'instance1', running: true, options: { model: 'model1.gguf' } }, { name: "instance1", running: true, options: { model: "model1.gguf" } },
{ name: 'instance2', running: false, options: { model: 'model2.gguf' } } { name: "instance2", running: false, options: { model: "model2.gguf" } },
] ];
beforeEach(() => { 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 // Default successful API responses
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) vi.mocked(instancesApi.list).mockResolvedValue(mockInstances);
}) });
afterEach(() => { afterEach(() => {
vi.clearAllMocks() vi.restoreAllMocks();
}) });
describe('Initial Loading', () => { describe("Initial Loading", () => {
it('loads instances on mount', async () => { it("loads instances on mount", async () => {
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
// Should start loading // Should start loading
expect(screen.getByTestId('loading')).toHaveTextContent('true') expect(screen.getByTestId("loading")).toHaveTextContent("true");
// Should fetch instances // Should fetch instances
await waitFor(() => { await waitFor(() => {
expect(instancesApi.list).toHaveBeenCalledOnce() expect(instancesApi.list).toHaveBeenCalledOnce();
}) });
// Should display loaded instances // Should display loaded instances
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true') expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false') "instance1:true"
}) );
}) expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
"instance2:false"
);
});
});
it('handles API error during initial load', async () => { it("handles API error during initial load", async () => {
const errorMessage = 'Network error' const errorMessage = "Network error";
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage));
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
expect(screen.getByTestId('instances-count')).toHaveTextContent('0') expect(screen.getByTestId("instances-count")).toHaveTextContent("0");
}) });
}) });
}) });
describe('Create Instance', () => { describe("Create Instance", () => {
it('creates instance and adds it to state', async () => { it("creates instance and adds it to state", async () => {
const newInstance: Instance = { const newInstance: Instance = {
name: 'new-instance', name: "new-instance",
running: false, running: false,
options: { model: 'test.gguf' } options: { model: "test.gguf" },
} };
vi.mocked(instancesApi.create).mockResolvedValue(newInstance) vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
}) });
screen.getByTestId('create-instance').click() screen.getByTestId("create-instance").click();
await waitFor(() => { await waitFor(() => {
expect(instancesApi.create).toHaveBeenCalledWith('new-instance', { model: 'test.gguf' }) expect(instancesApi.create).toHaveBeenCalledWith("new-instance", {
}) model: "test.gguf",
});
});
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('3') expect(screen.getByTestId("instances-count")).toHaveTextContent("3");
expect(screen.getByTestId('instance-new-instance')).toHaveTextContent('new-instance:false') expect(screen.getByTestId("instance-new-instance")).toHaveTextContent(
}) "new-instance:false"
}) );
});
});
it('handles create instance error without changing state', async () => { it("handles create instance error without changing state", async () => {
const errorMessage = 'Instance already exists' const errorMessage = "Instance already exists";
vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage)) vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage));
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
}) });
screen.getByTestId('create-instance').click() screen.getByTestId("create-instance").click();
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
}) });
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
expect(screen.queryByTestId('instance-new-instance')).not.toBeInTheDocument() expect(
}) screen.queryByTestId("instance-new-instance")
}) ).not.toBeInTheDocument();
});
});
describe('Update Instance', () => { describe("Update Instance", () => {
it('updates instance and maintains it in state', async () => { it("updates instance and maintains it in state", async () => {
const updatedInstance: Instance = { const updatedInstance: Instance = {
name: 'instance1', name: "instance1",
running: true, running: true,
options: { model: 'updated.gguf' } options: { model: "updated.gguf" },
} };
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance) vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
}) });
screen.getByTestId('update-instance').click() screen.getByTestId("update-instance").click();
await waitFor(() => { await waitFor(() => {
expect(instancesApi.update).toHaveBeenCalledWith('instance1', { model: 'updated.gguf' }) expect(instancesApi.update).toHaveBeenCalledWith("instance1", {
}) model: "updated.gguf",
});
});
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() expect(screen.getByTestId("instance-instance1")).toBeInTheDocument();
}) });
}) });
}) });
describe('Start/Stop Instance', () => { describe("Start/Stop Instance", () => {
it('starts existing instance and updates its running state', async () => { it("starts existing instance and updates its running state", async () => {
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) vi.mocked(instancesApi.start).mockResolvedValue({} as Instance);
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
// instance2 starts as not running // 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) // Start instance2 (button already configured to start instance2)
screen.getByTestId('start-instance').click() screen.getByTestId("start-instance").click();
await waitFor(() => { await waitFor(() => {
expect(instancesApi.start).toHaveBeenCalledWith('instance2') expect(instancesApi.start).toHaveBeenCalledWith("instance2");
// The running state should be updated to true // 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 () => { it("stops instance and updates running state to false", async () => {
vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance) vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance);
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
// instance1 starts as running // 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) // Stop instance1 (button already configured to stop instance1)
screen.getByTestId('stop-instance').click() screen.getByTestId("stop-instance").click();
await waitFor(() => { await waitFor(() => {
expect(instancesApi.stop).toHaveBeenCalledWith('instance1') expect(instancesApi.stop).toHaveBeenCalledWith("instance1");
// The running state should be updated to false // 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 () => { it("handles start instance error", async () => {
const errorMessage = 'Failed to start instance' const errorMessage = "Failed to start instance";
vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage)) vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage));
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { 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(() => { await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
}) });
}) });
}) });
describe('Delete Instance', () => { describe("Delete Instance", () => {
it('deletes instance and removes it from state', async () => { it("deletes instance and removes it from state", async () => {
vi.mocked(instancesApi.delete).mockResolvedValue(undefined) vi.mocked(instancesApi.delete).mockResolvedValue(undefined);
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
expect(screen.getByTestId('instance-instance2')).toBeInTheDocument() expect(screen.getByTestId("instance-instance2")).toBeInTheDocument();
}) });
screen.getByTestId('delete-instance').click() screen.getByTestId("delete-instance").click();
await waitFor(() => { await waitFor(() => {
expect(instancesApi.delete).toHaveBeenCalledWith('instance2') expect(instancesApi.delete).toHaveBeenCalledWith("instance2");
}) });
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('1') expect(screen.getByTestId("instances-count")).toHaveTextContent("1");
expect(screen.queryByTestId('instance-instance2')).not.toBeInTheDocument() expect(
expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() // instance1 should still exist 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 () => { it("handles delete instance error without changing state", async () => {
const errorMessage = 'Instance is running' const errorMessage = "Instance is running";
vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage)) vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage));
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
}) });
screen.getByTestId('delete-instance').click() screen.getByTestId("delete-instance").click();
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
}) });
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
expect(screen.getByTestId('instance-instance2')).toBeInTheDocument() expect(screen.getByTestId("instance-instance2")).toBeInTheDocument();
}) });
}) });
describe('Error Management', () => { describe("Error Management", () => {
it('clears error when clearError is called', async () => { it("clears error when clearError is called", async () => {
const errorMessage = 'Test error' const errorMessage = "Test error";
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage));
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { 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(() => { await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent('no-error') expect(screen.getByTestId("error")).toHaveTextContent("no-error");
}) });
}) });
}) });
describe('State Consistency', () => { describe("State Consistency", () => {
it('maintains consistent state during multiple operations', async () => { it("maintains consistent state during multiple operations", async () => {
// Test that operations don't interfere with each other // Test that operations don't interfere with each other
const newInstance: Instance = { const newInstance: Instance = {
name: 'new-instance', name: "new-instance",
running: false, running: false,
options: {} options: {},
} };
vi.mocked(instancesApi.create).mockResolvedValue(newInstance) vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) vi.mocked(instancesApi.start).mockResolvedValue({} as Instance);
renderWithProvider(<TestComponent />) renderWithProvider(<TestComponent />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false') expect(screen.getByTestId("loading")).toHaveTextContent("false");
expect(screen.getByTestId('instances-count')).toHaveTextContent('2') expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
}) });
// Create new instance // Create new instance
screen.getByTestId('create-instance').click() screen.getByTestId("create-instance").click();
await waitFor(() => { 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) // Start an instance (this should not affect the count)
screen.getByTestId('start-instance').click() screen.getByTestId("start-instance").click();
await waitFor(() => { await waitFor(() => {
expect(instancesApi.start).toHaveBeenCalled() expect(instancesApi.start).toHaveBeenCalled();
expect(screen.getByTestId('instances-count')).toHaveTextContent('3') // Still 3 expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3
// But the running state should change // But the running state should change
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true') expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
}) "instance2:true"
}) );
}) });
}) });
});
});

View File

@@ -10,18 +10,31 @@ async function apiCall<T>(
): Promise<T> { ): Promise<T> {
const url = `${API_BASE}${endpoint}`; const url = `${API_BASE}${endpoint}`;
// Prepare headers // Get auth token from sessionStorage (same as AuthContext)
const headers: HeadersInit = { const storedKey = sessionStorage.getItem('llamactl_management_key');
// Prepare headers with auth
const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
...options.headers, ...(options.headers as Record<string, string>),
}; };
// Add auth header if available
if (storedKey) {
headers['Authorization'] = `Bearer ${storedKey}`;
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers, headers,
}); });
// Handle authentication errors
if (response.status === 401) {
throw new Error('Authentication required');
}
if (!response.ok) { if (!response.ok) {
// Try to get error message from response // Try to get error message from response
let errorMessage = `HTTP ${response.status}`; let errorMessage = `HTTP ${response.status}`;
@@ -47,7 +60,7 @@ async function apiCall<T>(
const text = await response.text(); const text = await response.text();
return text as T; return text as T;
} else { } else {
const data = await response.json(); const data = await response.json() as T;
return data; return data;
} }
} catch (error) { } catch (error) {
@@ -121,4 +134,7 @@ export const instancesApi = {
const params = lines ? `?lines=${lines}` : ""; const params = lines ? `?lines=${lines}` : "";
return apiCall<string>(`/instances/${name}/logs${params}`, {}, "text"); return apiCall<string>(`/instances/${name}/logs${params}`, {}, "text");
}, },
// GET /instances/{name}/proxy/health
getHealth: (name: string) => apiCall<any>(`/instances/${name}/proxy/health`),
}; };

View File

@@ -1,4 +1,5 @@
import { type HealthStatus } from '@/types/instance' import { type HealthStatus } from '@/types/instance'
import { instancesApi } from '@/lib/api'
type HealthCallback = (health: HealthStatus) => void type HealthCallback = (health: HealthStatus) => void
@@ -8,31 +9,33 @@ class HealthService {
async checkHealth(instanceName: string): Promise<HealthStatus> { async checkHealth(instanceName: string): Promise<HealthStatus> {
try { try {
const response = await fetch(`/api/v1/instances/${instanceName}/proxy/health`) await instancesApi.getHealth(instanceName)
if (response.status === 200) {
return { return {
status: 'ok', status: 'ok',
lastChecked: new Date() lastChecked: new Date()
} }
} else if (response.status === 503) { } catch (error) {
const data = await response.json() if (error instanceof Error) {
// Check if it's a 503 (service unavailable - loading)
if (error.message.includes('503')) {
return { return {
status: 'loading', status: 'loading',
message: data.error.message, message: 'Instance is starting up',
lastChecked: new Date()
}
} else {
return {
status: 'error',
message: `HTTP ${response.status}`,
lastChecked: new Date() lastChecked: new Date()
} }
} }
} catch (error) {
return { return {
status: 'error', status: 'error',
message: 'Network error', message: error.message,
lastChecked: new Date()
}
}
return {
status: 'error',
message: 'Unknown error',
lastChecked: new Date() lastChecked: new Date()
} }
} }
@@ -82,7 +85,7 @@ class HealthService {
}, 60000) }, 60000)
this.intervals.set(instanceName, interval) this.intervals.set(instanceName, interval)
}, 2000) }, 5000)
} }
private stopHealthCheck(instanceName: string): void { private stopHealthCheck(instanceName: string): void {

View File

@@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { InstancesProvider } from './contexts/InstancesContext' import { InstancesProvider } from './contexts/InstancesContext'
import './index.css' import './index.css'
import { AuthProvider } from './contexts/AuthContext'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<InstancesProvider> <InstancesProvider>
<App /> <App />
</InstancesProvider> </InstancesProvider>
</AuthProvider>
</React.StrictMode>, </React.StrictMode>,
) )