mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-05 16:44:22 +00:00
Merge pull request #76 from lordmathis/feat/import-export
feat: Ad support for instance import and export on frontend
This commit is contained in:
@@ -42,6 +42,7 @@ Each instance is displayed as a card showing:
|
|||||||

|

|
||||||
|
|
||||||
1. Click the **"Create Instance"** button on the dashboard
|
1. Click the **"Create Instance"** button on the dashboard
|
||||||
|
2. *Optional*: Click **"Import"** in the dialog header to load a previously exported configuration
|
||||||
2. Enter a unique **Name** for your instance (only required field)
|
2. Enter a unique **Name** for your instance (only required field)
|
||||||
3. **Select Target Node**: Choose which node to deploy the instance to from the dropdown
|
3. **Select Target Node**: Choose which node to deploy the instance to from the dropdown
|
||||||
4. **Choose Backend Type**:
|
4. **Choose Backend Type**:
|
||||||
@@ -219,6 +220,12 @@ curl -X PUT http://localhost:8080/api/v1/instances/{name} \
|
|||||||
Configuration changes require restarting the instance to take effect.
|
Configuration changes require restarting the instance to take effect.
|
||||||
|
|
||||||
|
|
||||||
|
## Export Instance
|
||||||
|
|
||||||
|
**Via Web UI**
|
||||||
|
1. Click the **"More actions"** button (three dots) on an instance card
|
||||||
|
2. Click **"Export"** to download the instance configuration as a JSON file
|
||||||
|
|
||||||
## View Logs
|
## View Logs
|
||||||
|
|
||||||
**Via Web UI**
|
**Via Web UI**
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ func (o *Options) MarshalJSON() ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal backend options: %w", err)
|
return nil, fmt.Errorf("failed to marshal backend options: %w", err)
|
||||||
}
|
}
|
||||||
|
// Create a new map to avoid concurrent map writes
|
||||||
|
aux.BackendOptions = make(map[string]any)
|
||||||
if err := json.Unmarshal(optionsData, &aux.BackendOptions); err != nil {
|
if err := json.Unmarshal(optionsData, &aux.BackendOptions); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal backend options to map: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal backend options to map: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ interface BackendFormFieldProps {
|
|||||||
|
|
||||||
const BackendFormField: React.FC<BackendFormFieldProps> = ({ fieldKey, value, onChange }) => {
|
const BackendFormField: React.FC<BackendFormFieldProps> = ({ fieldKey, value, onChange }) => {
|
||||||
// Get configuration for basic fields, or use field name for advanced fields
|
// Get configuration for basic fields, or use field name for advanced fields
|
||||||
const config = basicBackendFieldsConfig[fieldKey as string] || { label: fieldKey }
|
const config = basicBackendFieldsConfig[fieldKey] || { label: fieldKey }
|
||||||
|
|
||||||
// Get type from Zod schema
|
// Get type from Zod schema
|
||||||
const fieldType = getBackendFieldType(fieldKey)
|
const fieldType = getBackendFieldType(fieldKey)
|
||||||
|
|
||||||
const handleChange = (newValue: string | number | boolean | string[] | undefined) => {
|
const handleChange = (newValue: string | number | boolean | string[] | undefined) => {
|
||||||
onChange(fieldKey as string, newValue)
|
onChange(fieldKey, newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderField = () => {
|
const renderField = () => {
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
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, MoreHorizontal } from "lucide-react";
|
import { Edit, FileText, Play, Square, Trash2, MoreHorizontal, Download } from "lucide-react";
|
||||||
import LogsDialog from "@/components/LogDialog";
|
import LogsDialog from "@/components/LogDialog";
|
||||||
import HealthBadge from "@/components/HealthBadge";
|
import HealthBadge from "@/components/HealthBadge";
|
||||||
import BackendBadge from "@/components/BackendBadge";
|
import BackendBadge from "@/components/BackendBadge";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||||
|
import { instancesApi } from "@/lib/api";
|
||||||
|
|
||||||
interface InstanceCardProps {
|
interface InstanceCardProps {
|
||||||
instance: Instance;
|
instance: Instance;
|
||||||
@@ -52,6 +53,40 @@ function InstanceCard({
|
|||||||
setIsLogsOpen(true);
|
setIsLogsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
// Fetch the most up-to-date instance data from the backend
|
||||||
|
const instanceData = await instancesApi.get(instance.name);
|
||||||
|
|
||||||
|
// Remove docker_enabled as it's a computed field, not persisted to disk
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { docker_enabled, ...persistedData } = instanceData;
|
||||||
|
|
||||||
|
// Convert to JSON string with pretty formatting (matching backend format)
|
||||||
|
const jsonString = JSON.stringify(persistedData, null, 2);
|
||||||
|
|
||||||
|
// Create a blob and download link
|
||||||
|
const blob = new Blob([jsonString], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${instance.name}.json`;
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export instance:", error);
|
||||||
|
alert(`Failed to export instance: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
const running = instance.status === "running";
|
const running = instance.status === "running";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,6 +166,18 @@ function InstanceCard({
|
|||||||
Logs
|
Logs
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleExport}
|
||||||
|
title="Export instance"
|
||||||
|
data-testid="export-instance-button"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { BackendType, type CreateInstanceOptions, type Instance } from "@/types/instance";
|
import { BackendType, type CreateInstanceOptions, type Instance } from "@/types/instance";
|
||||||
|
import type { BackendOptions } from "@/schemas/instanceOptions";
|
||||||
import ParseCommandDialog from "@/components/ParseCommandDialog";
|
import ParseCommandDialog from "@/components/ParseCommandDialog";
|
||||||
import InstanceSettingsCard from "@/components/instance/InstanceSettingsCard";
|
import InstanceSettingsCard from "@/components/instance/InstanceSettingsCard";
|
||||||
import BackendConfigurationCard from "@/components/instance/BackendConfigurationCard";
|
import BackendConfigurationCard from "@/components/instance/BackendConfigurationCard";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
|
||||||
interface InstanceDialogProps {
|
interface InstanceDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,6 +34,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
const [formData, setFormData] = useState<CreateInstanceOptions>({});
|
const [formData, setFormData] = useState<CreateInstanceOptions>({});
|
||||||
const [nameError, setNameError] = useState("");
|
const [nameError, setNameError] = useState("");
|
||||||
const [showParseDialog, setShowParseDialog] = useState(false);
|
const [showParseDialog, setShowParseDialog] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
||||||
// Reset form when dialog opens/closes or when instance changes
|
// Reset form when dialog opens/closes or when instance changes
|
||||||
@@ -54,31 +57,31 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
}
|
}
|
||||||
}, [open, instance]);
|
}, [open, instance]);
|
||||||
|
|
||||||
const handleFieldChange = (key: keyof CreateInstanceOptions, value: any) => {
|
const handleFieldChange = (key: keyof CreateInstanceOptions, value: unknown) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
// If backend_type is changing, clear backend_options
|
// If backend_type is changing, clear backend_options
|
||||||
if (key === 'backend_type' && prev.backend_type !== value) {
|
if (key === 'backend_type' && prev.backend_type !== value) {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[key]: value,
|
backend_type: value as CreateInstanceOptions['backend_type'],
|
||||||
backend_options: {}, // Clear backend options when backend type changes
|
backend_options: {}, // Clear backend options when backend type changes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
};
|
} as CreateInstanceOptions;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackendFieldChange = (key: string, value: any) => {
|
const handleBackendFieldChange = (key: string, value: unknown) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
backend_options: {
|
backend_options: {
|
||||||
...prev.backend_options,
|
...prev.backend_options,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
} as any,
|
} as BackendOptions,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,11 +107,13 @@ 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 = {} as CreateInstanceOptions;
|
||||||
Object.entries(formData).forEach(([key, value]) => {
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
|
const typedKey = key as keyof CreateInstanceOptions;
|
||||||
|
|
||||||
if (key === 'backend_options' && value && typeof value === 'object' && !Array.isArray(value)) {
|
if (key === 'backend_options' && value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
// Handle backend_options specially - clean nested object
|
// Handle backend_options specially - clean nested object
|
||||||
const cleanBackendOptions: any = {};
|
const cleanBackendOptions: Record<string, unknown> = {};
|
||||||
Object.entries(value).forEach(([backendKey, backendValue]) => {
|
Object.entries(value).forEach(([backendKey, backendValue]) => {
|
||||||
if (backendValue !== undefined && backendValue !== null && (typeof backendValue !== 'string' || backendValue.trim() !== "")) {
|
if (backendValue !== undefined && backendValue !== null && (typeof backendValue !== 'string' || backendValue.trim() !== "")) {
|
||||||
// Handle arrays - don't include empty arrays
|
// Handle arrays - don't include empty arrays
|
||||||
@@ -121,7 +126,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
|
|
||||||
// Only include backend_options if it has content
|
// Only include backend_options if it has content
|
||||||
if (Object.keys(cleanBackendOptions).length > 0) {
|
if (Object.keys(cleanBackendOptions).length > 0) {
|
||||||
(cleanOptions as any)[key] = cleanBackendOptions;
|
(cleanOptions as Record<string, unknown>)[typedKey] = cleanBackendOptions as BackendOptions;
|
||||||
}
|
}
|
||||||
} else if (value !== undefined && value !== null) {
|
} else if (value !== undefined && value !== null) {
|
||||||
// Skip empty strings
|
// Skip empty strings
|
||||||
@@ -132,7 +137,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
if (Array.isArray(value) && value.length === 0) {
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(cleanOptions as any)[key] = value;
|
(cleanOptions as Record<string, unknown>)[typedKey] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +158,49 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
setShowParseDialog(false);
|
setShowParseDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportFile = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
const importedData = JSON.parse(content) as { name?: string; options?: CreateInstanceOptions };
|
||||||
|
|
||||||
|
// Validate that it's an instance export
|
||||||
|
if (!importedData.name || !importedData.options) {
|
||||||
|
alert('Invalid instance file: Missing required fields (name, options)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the instance name (only for new instances, not editing)
|
||||||
|
if (!isEditing && typeof importedData.name === 'string') {
|
||||||
|
handleNameChange(importedData.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate all the options from the imported file
|
||||||
|
if (importedData.options) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
...importedData.options,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the file input
|
||||||
|
event.target.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse instance file:', error);
|
||||||
|
alert(`Failed to parse instance file: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
// Save button label logic
|
// Save button label logic
|
||||||
let saveButtonLabel = "Create Instance";
|
let saveButtonLabel = "Create Instance";
|
||||||
@@ -168,14 +216,38 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
|
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<div className="flex items-center justify-between">
|
||||||
{isEditing ? "Edit Instance" : "Create New Instance"}
|
<div className="flex-1">
|
||||||
</DialogTitle>
|
<DialogTitle>
|
||||||
<DialogDescription>
|
{isEditing ? "Edit Instance" : "Create New Instance"}
|
||||||
{isEditing
|
</DialogTitle>
|
||||||
? "Modify the instance configuration below."
|
<DialogDescription>
|
||||||
: "Configure your new llama-server instance below."}
|
{isEditing
|
||||||
</DialogDescription>
|
? "Modify the instance configuration below."
|
||||||
|
: "Configure your new llama-server instance below."}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImportFile}
|
||||||
|
title="Import instance configuration from JSON file"
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ function InstanceList({ editInstance }: InstanceListProps) {
|
|||||||
<MemoizedInstanceCard
|
<MemoizedInstanceCard
|
||||||
key={instance.name}
|
key={instance.name}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
startInstance={startInstance}
|
startInstance={() => { void startInstance(instance.name) }}
|
||||||
stopInstance={stopInstance}
|
stopInstance={() => { void stopInstance(instance.name) }}
|
||||||
deleteInstance={deleteInstance}
|
deleteInstance={() => { void deleteInstance(instance.name) }}
|
||||||
editInstance={editInstance}
|
editInstance={editInstance}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const ParseCommandDialog: React.FC<ParseCommandDialogProps> = ({
|
|||||||
options = await backendsApi.vllm.parseCommand(command);
|
options = await backendsApi.vllm.parseCommand(command);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported backend type: ${backendType}`);
|
throw new Error(`Unsupported backend type: ${String(backendType)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
onParsed(options);
|
onParsed(options);
|
||||||
|
|||||||
Reference in New Issue
Block a user