mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 00:54:23 +00:00
Implement backend configuration options and refactor related components
This commit is contained in:
@@ -5,6 +5,7 @@ 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 { BackendType } from '@/types/instance'
|
||||||
import { AuthProvider } from '@/contexts/AuthContext'
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
// Mock the API
|
// Mock the API
|
||||||
@@ -46,8 +47,8 @@ function renderApp() {
|
|||||||
|
|
||||||
describe('App Component - Critical Business Logic Only', () => {
|
describe('App Component - Critical Business Logic Only', () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'test-instance-1', status: 'stopped', options: { model: 'model1.gguf' } },
|
{ name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model1.gguf' } } },
|
||||||
{ name: 'test-instance-2', status: 'running', options: { model: 'model2.gguf' } }
|
{ name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model2.gguf' } } }
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -82,7 +83,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
name: 'new-test-instance',
|
name: 'new-test-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { model: 'new-model.gguf' }
|
options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'new-model.gguf' } }
|
||||||
}
|
}
|
||||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', {
|
expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', {
|
||||||
auto_restart: true, // Default value
|
auto_restart: true, // Default value
|
||||||
|
backend_type: BackendType.LLAMA_SERVER
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
name: 'test-instance-1',
|
name: 'test-instance-1',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { model: 'updated-model.gguf' }
|
options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'updated-model.gguf' } }
|
||||||
}
|
}
|
||||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
|
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
|
||||||
|
|
||||||
@@ -138,7 +140,8 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
// Verify correct API call with existing instance data
|
// Verify correct API call with existing instance data
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(instancesApi.update).toHaveBeenCalledWith('test-instance-1', {
|
expect(instancesApi.update).toHaveBeenCalledWith('test-instance-1', {
|
||||||
model: "model1.gguf", // Pre-filled from existing instance
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
|
backend_options: { model: "model1.gguf" } // Pre-filled from existing instance
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
123
webui/src/components/BackendFormField.tsx
Normal file
123
webui/src/components/BackendFormField.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import type { BackendOptions } from '@/schemas/instanceOptions'
|
||||||
|
import { getBackendFieldType, basicBackendFieldsConfig } from '@/lib/zodFormUtils'
|
||||||
|
|
||||||
|
interface BackendFormFieldProps {
|
||||||
|
fieldKey: keyof BackendOptions
|
||||||
|
value: string | number | boolean | string[] | undefined
|
||||||
|
onChange: (key: string, value: string | number | boolean | string[] | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackendFormField: React.FC<BackendFormFieldProps> = ({ fieldKey, value, onChange }) => {
|
||||||
|
// Get configuration for basic fields, or use field name for advanced fields
|
||||||
|
const config = basicBackendFieldsConfig[fieldKey as string] || { label: fieldKey }
|
||||||
|
|
||||||
|
// Get type from Zod schema
|
||||||
|
const fieldType = getBackendFieldType(fieldKey)
|
||||||
|
|
||||||
|
const handleChange = (newValue: string | number | boolean | string[] | undefined) => {
|
||||||
|
onChange(fieldKey as string, newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderField = () => {
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'boolean':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={fieldKey}
|
||||||
|
checked={typeof value === 'boolean' ? value : false}
|
||||||
|
onCheckedChange={(checked) => handleChange(checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={fieldKey} className="text-sm font-normal">
|
||||||
|
{config.label}
|
||||||
|
{config.description && (
|
||||||
|
<span className="text-muted-foreground ml-1">- {config.description}</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={fieldKey}>
|
||||||
|
{config.label}
|
||||||
|
{config.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldKey}
|
||||||
|
type="number"
|
||||||
|
step="any" // This allows decimal numbers
|
||||||
|
value={typeof value === 'string' || typeof value === 'number' ? value : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const numValue = e.target.value ? parseFloat(e.target.value) : undefined
|
||||||
|
// Only update if the parsed value is valid or the input is empty
|
||||||
|
if (e.target.value === '' || (numValue !== undefined && !isNaN(numValue))) {
|
||||||
|
handleChange(numValue)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
/>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'array':
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={fieldKey}>
|
||||||
|
{config.label}
|
||||||
|
{config.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldKey}
|
||||||
|
type="text"
|
||||||
|
value={Array.isArray(value) ? value.join(', ') : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const arrayValue = e.target.value
|
||||||
|
? e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
: undefined
|
||||||
|
handleChange(arrayValue)
|
||||||
|
}}
|
||||||
|
placeholder="item1, item2, item3"
|
||||||
|
/>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">Separate multiple values with commas</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={fieldKey}>
|
||||||
|
{config.label}
|
||||||
|
{config.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldKey}
|
||||||
|
type="text"
|
||||||
|
value={typeof value === 'string' || typeof value === 'number' ? value : ''}
|
||||||
|
onChange={(e) => handleChange(e.target.value || undefined)}
|
||||||
|
placeholder={config.placeholder}
|
||||||
|
/>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="space-y-2">{renderField()}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BackendFormField
|
||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
import type { CreateInstanceOptions, Instance } from "@/types/instance";
|
||||||
import { getBasicFields, getAdvancedFields } from "@/lib/zodFormUtils";
|
import { BackendType } from "@/types/instance";
|
||||||
|
import { getBasicFields, getAdvancedFields, getBasicBackendFields, getAdvancedBackendFields } 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";
|
||||||
|
import BackendFormField from "@/components/BackendFormField";
|
||||||
|
|
||||||
interface InstanceDialogProps {
|
interface InstanceDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -38,6 +40,8 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
// Get field lists dynamically from the type
|
// Get field lists dynamically from the type
|
||||||
const basicFields = getBasicFields();
|
const basicFields = getBasicFields();
|
||||||
const advancedFields = getAdvancedFields();
|
const advancedFields = getAdvancedFields();
|
||||||
|
const basicBackendFields = getBasicBackendFields();
|
||||||
|
const advancedBackendFields = getAdvancedBackendFields();
|
||||||
|
|
||||||
// Reset form when dialog opens/closes or when instance changes
|
// Reset form when dialog opens/closes or when instance changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,6 +55,8 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
setInstanceName("");
|
setInstanceName("");
|
||||||
setFormData({
|
setFormData({
|
||||||
auto_restart: true, // Default value
|
auto_restart: true, // Default value
|
||||||
|
backend_type: BackendType.LLAMA_SERVER, // Default backend type
|
||||||
|
backend_options: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setShowAdvanced(false); // Always start with basic view
|
setShowAdvanced(false); // Always start with basic view
|
||||||
@@ -65,6 +71,16 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackendFieldChange = (key: string, value: any) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
backend_options: {
|
||||||
|
...prev.backend_options,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameChange = (name: string) => {
|
const handleNameChange = (name: string) => {
|
||||||
setInstanceName(name);
|
setInstanceName(name);
|
||||||
// Validate instance name
|
// Validate instance name
|
||||||
@@ -89,7 +105,24 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
// Clean up undefined values to avoid sending empty fields
|
// Clean up undefined values to avoid sending empty fields
|
||||||
const cleanOptions: CreateInstanceOptions = {};
|
const cleanOptions: CreateInstanceOptions = {};
|
||||||
Object.entries(formData).forEach(([key, value]) => {
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== "" && value !== null) {
|
if (key === 'backend_options' && value && typeof value === 'object') {
|
||||||
|
// Handle backend_options specially - clean nested object
|
||||||
|
const cleanBackendOptions: any = {};
|
||||||
|
Object.entries(value).forEach(([backendKey, backendValue]) => {
|
||||||
|
if (backendValue !== undefined && backendValue !== null && (typeof backendValue !== 'string' || backendValue.trim() !== "")) {
|
||||||
|
// Handle arrays - don't include empty arrays
|
||||||
|
if (Array.isArray(backendValue) && backendValue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanBackendOptions[backendKey] = backendValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only include backend_options if it has content
|
||||||
|
if (Object.keys(cleanBackendOptions).length > 0) {
|
||||||
|
(cleanOptions as any)[key] = cleanBackendOptions;
|
||||||
|
}
|
||||||
|
} else if (value !== undefined && value !== null && (typeof value !== 'string' || value.trim() !== "")) {
|
||||||
// Handle arrays - don't include empty arrays
|
// Handle arrays - don't include empty arrays
|
||||||
if (Array.isArray(value) && value.length === 0) {
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -196,8 +229,9 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
(fieldKey) =>
|
(fieldKey) =>
|
||||||
fieldKey !== "auto_restart" &&
|
fieldKey !== "auto_restart" &&
|
||||||
fieldKey !== "max_restarts" &&
|
fieldKey !== "max_restarts" &&
|
||||||
fieldKey !== "restart_delay"
|
fieldKey !== "restart_delay" &&
|
||||||
) // Exclude auto_restart, max_restarts, and restart_delay as they're handled above
|
fieldKey !== "backend_options" // backend_options is handled separately
|
||||||
|
)
|
||||||
.map((fieldKey) => (
|
.map((fieldKey) => (
|
||||||
<ZodFormField
|
<ZodFormField
|
||||||
key={fieldKey}
|
key={fieldKey}
|
||||||
@@ -208,6 +242,21 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Backend Configuration Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Backend Configuration</h3>
|
||||||
|
|
||||||
|
{/* Basic backend fields */}
|
||||||
|
{basicBackendFields.map((fieldKey) => (
|
||||||
|
<BackendFormField
|
||||||
|
key={fieldKey}
|
||||||
|
fieldKey={fieldKey}
|
||||||
|
value={formData.backend_options?.[fieldKey]}
|
||||||
|
onChange={handleBackendFieldChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Advanced Fields Toggle */}
|
{/* Advanced Fields Toggle */}
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -226,8 +275,8 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
{
|
{
|
||||||
advancedFields.filter(
|
advancedFields.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
!["max_restarts", "restart_delay"].includes(f as string)
|
!["max_restarts", "restart_delay", "backend_options"].includes(f as string)
|
||||||
).length
|
).length + advancedBackendFields.length
|
||||||
}{" "}
|
}{" "}
|
||||||
options)
|
options)
|
||||||
</span>
|
</span>
|
||||||
@@ -237,24 +286,51 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
{/* Advanced Fields - Automatically generated from type (excluding restart options) */}
|
{/* Advanced Fields - Automatically generated from type (excluding restart options) */}
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="space-y-4 pl-6 border-l-2 border-muted">
|
<div className="space-y-4 pl-6 border-l-2 border-muted">
|
||||||
<div className="space-y-4">
|
{/* Advanced instance fields */}
|
||||||
{advancedFields
|
{advancedFields
|
||||||
.filter(
|
.filter(
|
||||||
(fieldKey) =>
|
(fieldKey) =>
|
||||||
!["max_restarts", "restart_delay"].includes(
|
!["max_restarts", "restart_delay", "backend_options"].includes(
|
||||||
fieldKey as string
|
fieldKey as string
|
||||||
)
|
)
|
||||||
) // Exclude restart options as they're handled above
|
).length > 0 && (
|
||||||
.sort()
|
<div className="space-y-4">
|
||||||
.map((fieldKey) => (
|
<h4 className="text-md font-medium">Advanced Instance Configuration</h4>
|
||||||
<ZodFormField
|
{advancedFields
|
||||||
key={fieldKey}
|
.filter(
|
||||||
fieldKey={fieldKey}
|
(fieldKey) =>
|
||||||
value={formData[fieldKey]}
|
!["max_restarts", "restart_delay", "backend_options"].includes(
|
||||||
onChange={handleFieldChange}
|
fieldKey as string
|
||||||
/>
|
)
|
||||||
))}
|
)
|
||||||
</div>
|
.sort()
|
||||||
|
.map((fieldKey) => (
|
||||||
|
<ZodFormField
|
||||||
|
key={fieldKey}
|
||||||
|
fieldKey={fieldKey}
|
||||||
|
value={fieldKey === 'backend_options' ? undefined : formData[fieldKey]}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced backend fields */}
|
||||||
|
{advancedBackendFields.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-md font-medium">Advanced Backend Configuration</h4>
|
||||||
|
{advancedBackendFields
|
||||||
|
.sort()
|
||||||
|
.map((fieldKey) => (
|
||||||
|
<BackendFormField
|
||||||
|
key={fieldKey}
|
||||||
|
fieldKey={fieldKey}
|
||||||
|
value={formData.backend_options?.[fieldKey]}
|
||||||
|
onChange={handleBackendFieldChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import type { CreateInstanceOptions } from '@/types/instance'
|
import type { CreateInstanceOptions } from '@/types/instance'
|
||||||
|
import { BackendType } from '@/types/instance'
|
||||||
import { getFieldType, basicFieldsConfig } from '@/lib/zodFormUtils'
|
import { getFieldType, basicFieldsConfig } from '@/lib/zodFormUtils'
|
||||||
|
|
||||||
interface ZodFormFieldProps {
|
interface ZodFormFieldProps {
|
||||||
@@ -23,6 +24,30 @@ const ZodFormField: React.FC<ZodFormFieldProps> = ({ fieldKey, value, onChange }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderField = () => {
|
const renderField = () => {
|
||||||
|
// Special handling for backend_type field - render as dropdown
|
||||||
|
if (fieldKey === 'backend_type') {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor={fieldKey}>
|
||||||
|
{config.label}
|
||||||
|
{config.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id={fieldKey}
|
||||||
|
value={typeof value === 'string' ? value : BackendType.LLAMA_SERVER}
|
||||||
|
onChange={(e) => handleChange(e.target.value || undefined)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value={BackendType.LLAMA_SERVER}>Llama Server</option>
|
||||||
|
{/* Add more backend types here as they become available */}
|
||||||
|
</select>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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'
|
||||||
import type { Instance } from '@/types/instance'
|
import type { Instance } from '@/types/instance'
|
||||||
|
import { BackendType } from '@/types/instance'
|
||||||
|
|
||||||
// Mock the health hook since we're not testing health logic here
|
// Mock the health hook since we're not testing health logic here
|
||||||
vi.mock('@/hooks/useInstanceHealth', () => ({
|
vi.mock('@/hooks/useInstanceHealth', () => ({
|
||||||
@@ -18,13 +19,13 @@ describe('InstanceCard - Instance Actions and State', () => {
|
|||||||
const stoppedInstance: Instance = {
|
const stoppedInstance: Instance = {
|
||||||
name: 'test-instance',
|
name: 'test-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: { model: 'test-model.gguf' }
|
options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'test-model.gguf' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningInstance: Instance = {
|
const runningInstance: Instance = {
|
||||||
name: 'running-instance',
|
name: 'running-instance',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
options: { model: 'running-model.gguf' }
|
options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'running-model.gguf' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { BackendType } from '@/types/instance'
|
||||||
import { AuthProvider } from '@/contexts/AuthContext'
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
// Mock the API
|
// Mock the API
|
||||||
@@ -44,9 +45,9 @@ describe('InstanceList - State Management and UI Logic', () => {
|
|||||||
const mockEditInstance = vi.fn()
|
const mockEditInstance = vi.fn()
|
||||||
|
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'instance-1', status: 'stopped', options: { model: 'model1.gguf' } },
|
{ name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model1.gguf' } } },
|
||||||
{ name: 'instance-2', status: 'running', options: { model: 'model2.gguf' } },
|
{ name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model2.gguf' } } },
|
||||||
{ name: 'instance-3', status: 'stopped', options: { model: 'model3.gguf' } }
|
{ name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model3.gguf' } } }
|
||||||
]
|
]
|
||||||
|
|
||||||
const DUMMY_API_KEY = 'test-api-key-123'
|
const DUMMY_API_KEY = 'test-api-key-123'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import InstanceDialog from '@/components/InstanceDialog'
|
import InstanceDialog from '@/components/InstanceDialog'
|
||||||
import type { Instance } from '@/types/instance'
|
import type { Instance } from '@/types/instance'
|
||||||
|
import { BackendType } from '@/types/instance'
|
||||||
|
|
||||||
describe('InstanceModal - Form Logic and Validation', () => {
|
describe('InstanceModal - Form Logic and Validation', () => {
|
||||||
const mockOnSave = vi.fn()
|
const mockOnSave = vi.fn()
|
||||||
@@ -91,6 +92,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
|
expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
|
||||||
auto_restart: true, // Default value
|
auto_restart: true, // Default value
|
||||||
|
backend_type: BackendType.LLAMA_SERVER
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,8 +138,8 @@ afterEach(() => {
|
|||||||
name: 'existing-instance',
|
name: 'existing-instance',
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
options: {
|
options: {
|
||||||
model: 'test-model.gguf',
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
gpu_layers: 10,
|
backend_options: { model: 'test-model.gguf', gpu_layers: 10 },
|
||||||
auto_restart: false
|
auto_restart: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,8 +179,8 @@ afterEach(() => {
|
|||||||
await user.click(screen.getByTestId('dialog-save-button'))
|
await user.click(screen.getByTestId('dialog-save-button'))
|
||||||
|
|
||||||
expect(mockOnSave).toHaveBeenCalledWith('existing-instance', {
|
expect(mockOnSave).toHaveBeenCalledWith('existing-instance', {
|
||||||
model: 'test-model.gguf',
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
gpu_layers: 10,
|
backend_options: { model: 'test-model.gguf', gpu_layers: 10 },
|
||||||
auto_restart: false
|
auto_restart: false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -271,6 +273,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
|
expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
|
||||||
auto_restart: true,
|
auto_restart: true,
|
||||||
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
max_restarts: 5,
|
max_restarts: 5,
|
||||||
restart_delay: 10
|
restart_delay: 10
|
||||||
})
|
})
|
||||||
@@ -321,6 +324,7 @@ afterEach(() => {
|
|||||||
// Should only include non-empty values
|
// Should only include non-empty values
|
||||||
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
|
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
|
||||||
auto_restart: true, // Only this default value should be included
|
auto_restart: true, // Only this default value should be included
|
||||||
|
backend_type: BackendType.LLAMA_SERVER
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -345,7 +349,8 @@ afterEach(() => {
|
|||||||
|
|
||||||
expect(mockOnSave).toHaveBeenCalledWith('numeric-test', {
|
expect(mockOnSave).toHaveBeenCalledWith('numeric-test', {
|
||||||
auto_restart: true,
|
auto_restart: true,
|
||||||
gpu_layers: 15, // Should be number, not string
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
|
backend_options: { gpu_layers: 15 }, // Should be number, not string
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { BackendType } from "@/types/instance";
|
||||||
import { AuthProvider } from "../AuthContext";
|
import { AuthProvider } from "../AuthContext";
|
||||||
|
|
||||||
// Mock the API module
|
// Mock the API module
|
||||||
@@ -47,13 +48,13 @@ 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", { backend_type: BackendType.LLAMA_SERVER, backend_options: { 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", { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "updated.gguf" } })}
|
||||||
data-testid="update-instance"
|
data-testid="update-instance"
|
||||||
>
|
>
|
||||||
Update Instance
|
Update Instance
|
||||||
@@ -99,8 +100,8 @@ function renderWithProvider(children: ReactNode) {
|
|||||||
|
|
||||||
describe("InstancesContext", () => {
|
describe("InstancesContext", () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: "instance1", status: "running", options: { model: "model1.gguf" } },
|
{ name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "model1.gguf" } } },
|
||||||
{ name: "instance2", status: "stopped", options: { model: "model2.gguf" } },
|
{ name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "model2.gguf" } } },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -159,7 +160,7 @@ describe("InstancesContext", () => {
|
|||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
options: { model: "test.gguf" },
|
options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "test.gguf" } },
|
||||||
};
|
};
|
||||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||||
|
|
||||||
@@ -174,7 +175,8 @@ describe("InstancesContext", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(instancesApi.create).toHaveBeenCalledWith("new-instance", {
|
expect(instancesApi.create).toHaveBeenCalledWith("new-instance", {
|
||||||
model: "test.gguf",
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
|
backend_options: { model: "test.gguf" }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +217,7 @@ describe("InstancesContext", () => {
|
|||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
name: "instance1",
|
name: "instance1",
|
||||||
status: "running",
|
status: "running",
|
||||||
options: { model: "updated.gguf" },
|
options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "updated.gguf" } },
|
||||||
};
|
};
|
||||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
|
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
|
||||||
|
|
||||||
@@ -230,7 +232,8 @@ describe("InstancesContext", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(instancesApi.update).toHaveBeenCalledWith("instance1", {
|
expect(instancesApi.update).toHaveBeenCalledWith("instance1", {
|
||||||
model: "updated.gguf",
|
backend_type: BackendType.LLAMA_SERVER,
|
||||||
|
backend_options: { model: "updated.gguf" }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type CreateInstanceOptions, getAllFieldKeys } from '@/schemas/instanceOptions'
|
import { type CreateInstanceOptions, type BackendOptions, getAllFieldKeys, getAllBackendFieldKeys } from '@/schemas/instanceOptions'
|
||||||
|
|
||||||
// Only define the basic fields we want to show by default
|
// Instance-level basic fields (not backend-specific)
|
||||||
export const basicFieldsConfig: Record<string, {
|
export const basicFieldsConfig: Record<string, {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
@@ -30,6 +30,19 @@ export const basicFieldsConfig: Record<string, {
|
|||||||
label: 'On-Demand Start',
|
label: 'On-Demand Start',
|
||||||
description: 'Start instance upon receiving OpenAI-compatible API request'
|
description: 'Start instance upon receiving OpenAI-compatible API request'
|
||||||
},
|
},
|
||||||
|
backend_type: {
|
||||||
|
label: 'Backend Type',
|
||||||
|
description: 'Type of backend to use for this instance'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend-specific basic fields (these go in backend_options)
|
||||||
|
export const basicBackendFieldsConfig: Record<string, {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
}> = {
|
||||||
model: {
|
model: {
|
||||||
label: 'Model Path',
|
label: 'Model Path',
|
||||||
placeholder: '/path/to/model.gguf',
|
placeholder: '/path/to/model.gguf',
|
||||||
@@ -56,6 +69,10 @@ export function isBasicField(key: keyof CreateInstanceOptions): boolean {
|
|||||||
return key in basicFieldsConfig
|
return key in basicFieldsConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBasicBackendField(key: keyof BackendOptions): boolean {
|
||||||
|
return key in basicBackendFieldsConfig
|
||||||
|
}
|
||||||
|
|
||||||
export function getBasicFields(): (keyof CreateInstanceOptions)[] {
|
export function getBasicFields(): (keyof CreateInstanceOptions)[] {
|
||||||
return Object.keys(basicFieldsConfig) as (keyof CreateInstanceOptions)[]
|
return Object.keys(basicFieldsConfig) as (keyof CreateInstanceOptions)[]
|
||||||
}
|
}
|
||||||
@@ -64,5 +81,13 @@ export function getAdvancedFields(): (keyof CreateInstanceOptions)[] {
|
|||||||
return getAllFieldKeys().filter(key => !isBasicField(key))
|
return getAllFieldKeys().filter(key => !isBasicField(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBasicBackendFields(): (keyof BackendOptions)[] {
|
||||||
|
return Object.keys(basicBackendFieldsConfig) as (keyof BackendOptions)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdvancedBackendFields(): (keyof BackendOptions)[] {
|
||||||
|
return getAllBackendFieldKeys().filter(key => !isBasicBackendField(key))
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export the Zod-based functions
|
// Re-export the Zod-based functions
|
||||||
export { getFieldType } from '@/schemas/instanceOptions'
|
export { getFieldType, getBackendFieldType } from '@/schemas/instanceOptions'
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
// Define the Zod schema
|
// Define the backend options schema (previously embedded in CreateInstanceOptionsSchema)
|
||||||
export const CreateInstanceOptionsSchema = z.object({
|
export const BackendOptionsSchema = z.object({
|
||||||
// Restart options
|
|
||||||
auto_restart: z.boolean().optional(),
|
|
||||||
max_restarts: z.number().optional(),
|
|
||||||
restart_delay: z.number().optional(),
|
|
||||||
idle_timeout: z.number().optional(),
|
|
||||||
on_demand_start: z.boolean().optional(),
|
|
||||||
|
|
||||||
// Common params
|
// Common params
|
||||||
verbose_prompt: z.boolean().optional(),
|
verbose_prompt: z.boolean().optional(),
|
||||||
threads: z.number().optional(),
|
threads: z.number().optional(),
|
||||||
@@ -176,22 +169,57 @@ export const CreateInstanceOptionsSchema = z.object({
|
|||||||
fim_qwen_14b_spec: z.boolean().optional(),
|
fim_qwen_14b_spec: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Infer the TypeScript type from the schema
|
// Define the main create instance options schema
|
||||||
|
export const CreateInstanceOptionsSchema = z.object({
|
||||||
|
// Restart options
|
||||||
|
auto_restart: z.boolean().optional(),
|
||||||
|
max_restarts: z.number().optional(),
|
||||||
|
restart_delay: z.number().optional(),
|
||||||
|
idle_timeout: z.number().optional(),
|
||||||
|
on_demand_start: z.boolean().optional(),
|
||||||
|
|
||||||
|
// Backend configuration
|
||||||
|
backend_type: z.enum(['llama_server']).optional(),
|
||||||
|
backend_options: BackendOptionsSchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Infer the TypeScript types from the schemas
|
||||||
|
export type BackendOptions = z.infer<typeof BackendOptionsSchema>
|
||||||
export type CreateInstanceOptions = z.infer<typeof CreateInstanceOptionsSchema>
|
export type CreateInstanceOptions = z.infer<typeof CreateInstanceOptionsSchema>
|
||||||
|
|
||||||
// Helper to get all field keys
|
// Helper to get all field keys for CreateInstanceOptions
|
||||||
export function getAllFieldKeys(): (keyof CreateInstanceOptions)[] {
|
export function getAllFieldKeys(): (keyof CreateInstanceOptions)[] {
|
||||||
return Object.keys(CreateInstanceOptionsSchema.shape) as (keyof CreateInstanceOptions)[]
|
return Object.keys(CreateInstanceOptionsSchema.shape) as (keyof CreateInstanceOptions)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get all backend option field keys
|
||||||
|
export function getAllBackendFieldKeys(): (keyof BackendOptions)[] {
|
||||||
|
return Object.keys(BackendOptionsSchema.shape) as (keyof BackendOptions)[]
|
||||||
|
}
|
||||||
|
|
||||||
// Get field type from Zod schema
|
// Get field type from Zod schema
|
||||||
export function getFieldType(key: keyof CreateInstanceOptions): 'text' | 'number' | 'boolean' | 'array' {
|
export function getFieldType(key: keyof CreateInstanceOptions): 'text' | 'number' | 'boolean' | 'array' | 'object' {
|
||||||
const fieldSchema = CreateInstanceOptionsSchema.shape[key]
|
const fieldSchema = CreateInstanceOptionsSchema.shape[key]
|
||||||
if (!fieldSchema) return 'text'
|
if (!fieldSchema) return 'text'
|
||||||
|
|
||||||
// Handle ZodOptional wrapper
|
// Handle ZodOptional wrapper
|
||||||
const innerSchema = fieldSchema instanceof z.ZodOptional ? fieldSchema.unwrap() : fieldSchema
|
const innerSchema = fieldSchema instanceof z.ZodOptional ? fieldSchema.unwrap() : fieldSchema
|
||||||
|
|
||||||
|
if (innerSchema instanceof z.ZodBoolean) return 'boolean'
|
||||||
|
if (innerSchema instanceof z.ZodNumber) return 'number'
|
||||||
|
if (innerSchema instanceof z.ZodArray) return 'array'
|
||||||
|
if (innerSchema instanceof z.ZodObject) return 'object'
|
||||||
|
return 'text' // ZodString and others default to text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get field type for backend options
|
||||||
|
export function getBackendFieldType(key: keyof BackendOptions): 'text' | 'number' | 'boolean' | 'array' {
|
||||||
|
const fieldSchema = BackendOptionsSchema.shape[key]
|
||||||
|
if (!fieldSchema) return 'text'
|
||||||
|
|
||||||
|
// Handle ZodOptional wrapper
|
||||||
|
const innerSchema = fieldSchema instanceof z.ZodOptional ? fieldSchema.unwrap() : fieldSchema
|
||||||
|
|
||||||
if (innerSchema instanceof z.ZodBoolean) return 'boolean'
|
if (innerSchema instanceof z.ZodBoolean) return 'boolean'
|
||||||
if (innerSchema instanceof z.ZodNumber) return 'number'
|
if (innerSchema instanceof z.ZodNumber) return 'number'
|
||||||
if (innerSchema instanceof z.ZodArray) return 'array'
|
if (innerSchema instanceof z.ZodArray) return 'array'
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import type { CreateInstanceOptions } from '@/schemas/instanceOptions'
|
|||||||
|
|
||||||
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||||
|
|
||||||
|
export const BackendType = {
|
||||||
|
LLAMA_SERVER: 'llama_server'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type BackendTypeValue = typeof BackendType[keyof typeof BackendType]
|
||||||
|
|
||||||
export type InstanceStatus = 'running' | 'stopped' | 'failed'
|
export type InstanceStatus = 'running' | 'stopped' | 'failed'
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user