mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 00:54:23 +00:00
Refactor project structure
This commit is contained in:
61
webui/src/App.tsx
Normal file
61
webui/src/App.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import InstanceList from "@/components/InstanceList";
|
||||
import InstanceModal from "@/components/InstanceModal";
|
||||
import { CreateInstanceOptions, Instance } from "@/types/instance";
|
||||
import { useInstances } from "@/contexts/InstancesContext";
|
||||
import SystemInfoModal from "./components/SystemInfoModal";
|
||||
|
||||
function App() {
|
||||
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
||||
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
||||
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
||||
undefined
|
||||
);
|
||||
const { createInstance, updateInstance } = useInstances();
|
||||
|
||||
const handleCreateInstance = () => {
|
||||
setEditingInstance(undefined);
|
||||
setIsInstanceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditInstance = (instance: Instance) => {
|
||||
setEditingInstance(instance);
|
||||
setIsInstanceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveInstance = (name: string, options: CreateInstanceOptions) => {
|
||||
if (editingInstance) {
|
||||
updateInstance(editingInstance.name, options);
|
||||
} else {
|
||||
createInstance(name, options);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowSystemInfo = () => {
|
||||
setIsSystemInfoModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} />
|
||||
<main className="container mx-auto max-w-4xl px-4 py-8">
|
||||
<InstanceList editInstance={handleEditInstance} />
|
||||
</main>
|
||||
|
||||
<InstanceModal
|
||||
open={isInstanceModalOpen}
|
||||
onOpenChange={setIsInstanceModalOpen}
|
||||
onSave={handleSaveInstance}
|
||||
instance={editingInstance}
|
||||
/>
|
||||
|
||||
<SystemInfoModal
|
||||
open={isSystemInfoModalOpen}
|
||||
onOpenChange={setIsSystemInfoModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
36
webui/src/components/Header.tsx
Normal file
36
webui/src/components/Header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
interface HeaderProps {
|
||||
onCreateInstance: () => void;
|
||||
onShowSystemInfo: () => void;
|
||||
}
|
||||
|
||||
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="container mx-auto max-w-4xl px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
LlamaCtl Dashboard
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onCreateInstance}>Create Instance</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowSystemInfo}
|
||||
title="System Info"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
74
webui/src/components/HealthBadge.tsx
Normal file
74
webui/src/components/HealthBadge.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// ui/src/components/HealthBadge.tsx
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HealthStatus } from "@/types/instance";
|
||||
import { CheckCircle, Loader2, XCircle } from "lucide-react";
|
||||
|
||||
interface HealthBadgeProps {
|
||||
health?: HealthStatus;
|
||||
}
|
||||
|
||||
const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
||||
if (!health) {
|
||||
health = {
|
||||
status: "unknown", // Default to unknown if not provided
|
||||
lastChecked: new Date(), // Default to current date
|
||||
message: undefined, // No message by default
|
||||
};
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (health.status) {
|
||||
case "ok":
|
||||
return <CheckCircle className="h-3 w-3" />;
|
||||
case "loading":
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case "error":
|
||||
return <XCircle className="h-3 w-3" />;
|
||||
case "unknown":
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getVariant = () => {
|
||||
switch (health.status) {
|
||||
case "ok":
|
||||
return "default";
|
||||
case "loading":
|
||||
return "outline";
|
||||
case "error":
|
||||
return "destructive";
|
||||
case "unknown":
|
||||
return "secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const getText = () => {
|
||||
switch (health.status) {
|
||||
case "ok":
|
||||
return "Ready";
|
||||
case "loading":
|
||||
return "Loading";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "unknown":
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={getVariant()}
|
||||
className={`flex items-center gap-1.5 ${
|
||||
health.status === "ok"
|
||||
? "bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-800"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{getIcon()}
|
||||
<span className="text-xs">{getText()}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthBadge;
|
||||
126
webui/src/components/InstanceCard.tsx
Normal file
126
webui/src/components/InstanceCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
// ui/src/components/InstanceCard.tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Instance } from "@/types/instance";
|
||||
import { Edit, FileText, Play, Square, Trash2 } from "lucide-react";
|
||||
import LogsModal from "@/components/LogModal";
|
||||
import HealthBadge from "@/components/HealthBadge";
|
||||
import { useState } from "react";
|
||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||
|
||||
interface InstanceCardProps {
|
||||
instance: Instance;
|
||||
startInstance: (name: string) => void;
|
||||
stopInstance: (name: string) => void;
|
||||
deleteInstance: (name: string) => void;
|
||||
editInstance: (instance: Instance) => void;
|
||||
}
|
||||
|
||||
function InstanceCard({
|
||||
instance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
editInstance,
|
||||
}: InstanceCardProps) {
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
||||
const health = useInstanceHealth(instance.name, instance.running);
|
||||
|
||||
const handleStart = () => {
|
||||
startInstance(instance.name);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
stopInstance(instance.name);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (
|
||||
confirm(`Are you sure you want to delete instance "${instance.name}"?`)
|
||||
) {
|
||||
deleteInstance(instance.name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
editInstance(instance);
|
||||
};
|
||||
|
||||
const handleLogs = () => {
|
||||
setIsLogsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{instance.name}</CardTitle>
|
||||
{instance.running && <HealthBadge health={health} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={instance.running}
|
||||
title="Start instance"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={!instance.running}
|
||||
title="Stop instance"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleEdit}
|
||||
title="Edit instance"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleLogs}
|
||||
title="View logs"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={instance.running}
|
||||
title="Delete instance"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogsModal
|
||||
open={isLogsOpen}
|
||||
onOpenChange={setIsLogsOpen}
|
||||
instanceName={instance.name}
|
||||
isRunning={instance.running}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstanceCard;
|
||||
70
webui/src/components/InstanceList.tsx
Normal file
70
webui/src/components/InstanceList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// ui/src/components/InstanceList.tsx
|
||||
import { useInstances } from '@/contexts/InstancesContext'
|
||||
import InstanceCard from '@/components/InstanceCard'
|
||||
import { Instance } from '@/types/instance'
|
||||
import { memo } from 'react'
|
||||
|
||||
interface InstanceListProps {
|
||||
editInstance: (instance: Instance) => void
|
||||
}
|
||||
|
||||
// Memoize InstanceCard to prevent re-renders when other instances change
|
||||
const MemoizedInstanceCard = memo(InstanceCard)
|
||||
|
||||
function InstanceList({ editInstance }: InstanceListProps) {
|
||||
const { instances, loading, error, startInstance, stopInstance, deleteInstance } = useInstances()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<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 instances...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-600 mb-4">
|
||||
<p className="text-lg font-semibold">Error loading instances</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (instances.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 text-lg mb-2">No instances found</p>
|
||||
<p className="text-gray-500 text-sm">Create your first instance to get started</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">
|
||||
Instances ({instances.length})
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{instances.map((instance) => (
|
||||
<MemoizedInstanceCard
|
||||
key={instance.name}
|
||||
instance={instance}
|
||||
startInstance={startInstance}
|
||||
stopInstance={stopInstance}
|
||||
deleteInstance={deleteInstance}
|
||||
editInstance={editInstance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceList
|
||||
248
webui/src/components/InstanceModal.tsx
Normal file
248
webui/src/components/InstanceModal.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
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 { CreateInstanceOptions, Instance } from '@/types/instance'
|
||||
import { getBasicFields, getAdvancedFields } from '@/lib/zodFormUtils'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import ZodFormField from '@/components/ZodFormField'
|
||||
|
||||
interface InstanceModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (name: string, options: CreateInstanceOptions) => void
|
||||
instance?: Instance // For editing existing instance
|
||||
}
|
||||
|
||||
const InstanceModal: React.FC<InstanceModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
instance
|
||||
}) => {
|
||||
const isEditing = !!instance
|
||||
const isRunning = instance?.running || true // Assume running if instance exists
|
||||
|
||||
const [instanceName, setInstanceName] = useState('')
|
||||
const [formData, setFormData] = useState<CreateInstanceOptions>({})
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
// Get field lists dynamically from the type
|
||||
const basicFields = getBasicFields()
|
||||
const advancedFields = getAdvancedFields()
|
||||
|
||||
// Reset form when modal opens/closes or when instance changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (instance) {
|
||||
// Populate form with existing instance data
|
||||
setInstanceName(instance.name)
|
||||
setFormData(instance.options || {})
|
||||
} else {
|
||||
// Reset form for new instance
|
||||
setInstanceName('')
|
||||
setFormData({
|
||||
auto_restart: true, // Default value
|
||||
})
|
||||
}
|
||||
setShowAdvanced(false) // Always start with basic view
|
||||
setNameError('') // Reset any name errors
|
||||
}
|
||||
}, [open, instance])
|
||||
|
||||
const handleFieldChange = (key: keyof CreateInstanceOptions, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleNameChange = (name: string) => {
|
||||
setInstanceName(name)
|
||||
// Validate instance name
|
||||
if (!name.trim()) {
|
||||
setNameError('Instance name is required')
|
||||
} else if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
|
||||
setNameError('Instance name can only contain letters, numbers, hyphens, and underscores')
|
||||
} else {
|
||||
setNameError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate instance name before saving
|
||||
if (!instanceName.trim()) {
|
||||
setNameError('Instance name is required')
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up undefined values to avoid sending empty fields
|
||||
const cleanOptions: CreateInstanceOptions = {}
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '' && value !== null) {
|
||||
// Handle arrays - don't include empty arrays
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return
|
||||
}
|
||||
;(cleanOptions as any)[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
onSave(instanceName, cleanOptions)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const toggleAdvanced = () => {
|
||||
setShowAdvanced(!showAdvanced)
|
||||
}
|
||||
|
||||
// Check if auto_restart is enabled
|
||||
const isAutoRestartEnabled = formData.auto_restart === true
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Edit Instance' : 'Create New Instance'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? 'Modify the instance configuration below.'
|
||||
: 'Configure your new llama-server instance below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Instance Name - Special handling since it's not in CreateInstanceOptions */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Instance Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={instanceName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="my-instance"
|
||||
disabled={isEditing} // Don't allow name changes when editing
|
||||
className={nameError ? 'border-red-500' : ''}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-sm text-red-500">{nameError}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unique identifier for the instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Restart Configuration Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Auto Restart Configuration</h3>
|
||||
|
||||
{/* Auto Restart Toggle */}
|
||||
<ZodFormField
|
||||
fieldKey="auto_restart"
|
||||
value={formData.auto_restart}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* Show restart options only when auto restart is enabled */}
|
||||
{isAutoRestartEnabled && (
|
||||
<div className="ml-6 space-y-4 border-l-2 border-muted pl-4">
|
||||
<ZodFormField
|
||||
fieldKey="max_restarts"
|
||||
value={formData.max_restarts}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<ZodFormField
|
||||
fieldKey="restart_delay"
|
||||
value={formData.restart_delay}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Fields - Automatically generated from type (excluding auto restart options) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Basic Configuration</h3>
|
||||
{basicFields
|
||||
.filter(fieldKey => fieldKey !== 'auto_restart') // Exclude auto_restart as it's handled above
|
||||
.map((fieldKey) => (
|
||||
<ZodFormField
|
||||
key={fieldKey}
|
||||
fieldKey={fieldKey}
|
||||
value={formData[fieldKey]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Advanced Fields Toggle */}
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleAdvanced}
|
||||
className="flex items-center gap-2 p-0 h-auto font-medium"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Advanced Configuration
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
({advancedFields.filter(f => !['max_restarts', 'restart_delay'].includes(f as string)).length} options)
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Fields - Automatically generated from type (excluding restart options) */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 pl-6 border-l-2 border-muted">
|
||||
<div className="space-y-4">
|
||||
{advancedFields
|
||||
.filter(fieldKey => !['max_restarts', 'restart_delay'].includes(fieldKey as string)) // Exclude restart options as they're handled above
|
||||
.sort()
|
||||
.map((fieldKey) => (
|
||||
<ZodFormField
|
||||
key={fieldKey}
|
||||
fieldKey={fieldKey}
|
||||
value={formData[fieldKey]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!instanceName.trim() || !!nameError}>
|
||||
{isEditing ? (isRunning ? 'Update & Restart Instance' : 'Update Instance') : 'Create Instance'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceModal
|
||||
330
webui/src/components/LogModal.tsx
Normal file
330
webui/src/components/LogModal.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect, useRef } 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 { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
RefreshCw,
|
||||
Download,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LogsModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
instanceName: string
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
const LogsModal: React.FC<LogsModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
instanceName,
|
||||
isRunning
|
||||
}) => {
|
||||
const [logs, setLogs] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lineCount, setLineCount] = useState(100)
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Fetch logs function
|
||||
const fetchLogs = async (lines?: number) => {
|
||||
if (!instanceName) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const params = lines ? `?lines=${lines}` : ''
|
||||
const response = await fetch(`/api/v1/instances/${instanceName}/logs${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch logs: ${response.status}`)
|
||||
}
|
||||
|
||||
const logText = await response.text()
|
||||
setLogs(logText)
|
||||
|
||||
// Auto-scroll to bottom
|
||||
setTimeout(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, 100)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch logs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load when modal opens
|
||||
useEffect(() => {
|
||||
if (open && instanceName) {
|
||||
fetchLogs(lineCount)
|
||||
}
|
||||
}, [open, instanceName])
|
||||
|
||||
// Auto-refresh effect
|
||||
useEffect(() => {
|
||||
if (autoRefresh && isRunning && open) {
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
fetchLogs(lineCount)
|
||||
}, 2000) // Refresh every 2 seconds
|
||||
} else {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current)
|
||||
refreshIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, isRunning, open, lineCount])
|
||||
|
||||
// Copy logs to clipboard
|
||||
const copyLogs = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(logs)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy logs:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download logs as file
|
||||
const downloadLogs = () => {
|
||||
const blob = new Blob([logs], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${instanceName}-logs.txt`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Handle line count change
|
||||
const handleLineCountChange = (value: string) => {
|
||||
const num = parseInt(value) || 100
|
||||
setLineCount(num)
|
||||
}
|
||||
|
||||
// Apply new line count
|
||||
const applyLineCount = () => {
|
||||
fetchLogs(lineCount)
|
||||
setShowSettings(false)
|
||||
}
|
||||
|
||||
// Format logs with basic syntax highlighting
|
||||
const formatLogs = (logText: string) => {
|
||||
if (!logText) return ''
|
||||
|
||||
return logText.split('\n').map((line, index) => {
|
||||
let className = 'font-mono text-sm leading-relaxed'
|
||||
|
||||
// Basic log level detection
|
||||
if (line.includes('ERROR') || line.includes('[ERROR]')) {
|
||||
className += ' text-red-400'
|
||||
} else if (line.includes('WARN') || line.includes('[WARN]')) {
|
||||
className += ' text-yellow-400'
|
||||
} else if (line.includes('INFO') || line.includes('[INFO]')) {
|
||||
className += ' text-blue-400'
|
||||
} else if (line.includes('DEBUG') || line.includes('[DEBUG]')) {
|
||||
className += ' text-gray-400'
|
||||
} else if (line.includes('===')) {
|
||||
className += ' text-green-400 font-semibold'
|
||||
} else {
|
||||
className += ' text-gray-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className={className}>
|
||||
{line || '\u00A0'} {/* Non-breaking space for empty lines */}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-w-[calc(100%-2rem)] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Logs: {instanceName}
|
||||
<Badge variant={isRunning ? "default" : "secondary"}>
|
||||
{isRunning ? "Running" : "Stopped"}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Instance logs and output
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchLogs(lineCount)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="lineCount">Lines:</Label>
|
||||
<Input
|
||||
id="lineCount"
|
||||
type="number"
|
||||
value={lineCount}
|
||||
onChange={(e) => handleLineCountChange(e.target.value)}
|
||||
className="w-24"
|
||||
min="1"
|
||||
max="10000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoRefresh"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
disabled={!isRunning}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="autoRefresh">
|
||||
Auto-refresh {!isRunning && '(instance not running)'}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={applyLineCount}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg mb-4">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 bg-gray-900 rounded-lg p-4 overflow-auto min-h-[400px] max-h-[500px]"
|
||||
>
|
||||
{loading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">Loading logs...</span>
|
||||
</div>
|
||||
) : logs ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{formatLogs(logs)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
No logs available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{autoRefresh && isRunning && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
Auto-refreshing every 2 seconds
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={copyLogs}
|
||||
disabled={!logs}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={downloadLogs}
|
||||
disabled={!logs}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsModal
|
||||
183
webui/src/components/SystemInfoModal.tsx
Normal file
183
webui/src/components/SystemInfoModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Monitor,
|
||||
HelpCircle
|
||||
} from 'lucide-react'
|
||||
import { serverApi } from '@/lib/api'
|
||||
|
||||
interface SystemInfoModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
version: string
|
||||
devices: string
|
||||
help: string
|
||||
}
|
||||
|
||||
const SystemInfoModal: React.FC<SystemInfoModalProps> = ({
|
||||
open,
|
||||
onOpenChange
|
||||
}) => {
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
// Fetch system info
|
||||
const fetchSystemInfo = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [version, devices, help] = await Promise.all([
|
||||
serverApi.getVersion(),
|
||||
serverApi.getDevices(),
|
||||
serverApi.getHelp()
|
||||
])
|
||||
|
||||
setSystemInfo({ version, devices, help })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch system info')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load data when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchSystemInfo()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} >
|
||||
<DialogContent className="sm:max-w-4xl max-w-[calc(100%-2rem)] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
System Information
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Llama.cpp server environment and capabilities
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchSystemInfo}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && !systemInfo ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">Loading system information...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
</div>
|
||||
) : systemInfo ? (
|
||||
<div className="space-y-6">
|
||||
{/* Version Section */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Version</h3>
|
||||
|
||||
<div className="bg-gray-900 rounded-lg p-4">
|
||||
<div className="mb-2">
|
||||
<span className="text-sm text-gray-400">$ llama-server --version</span>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-mono">
|
||||
{systemInfo.version}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">Available Devices</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-lg p-4">
|
||||
<div className="mb-2">
|
||||
<span className="text-sm text-gray-400">$ llama-server --list-devices</span>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-mono">
|
||||
{systemInfo.devices}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="flex items-center gap-2 p-0 h-auto font-semibold"
|
||||
>
|
||||
{showHelp ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Command Line Options
|
||||
</Button>
|
||||
|
||||
{showHelp && (
|
||||
<div className="bg-gray-900 rounded-lg p-4">
|
||||
<div className="mb-2">
|
||||
<span className="text-sm text-gray-400">$ llama-server --help</span>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-mono max-h-64 overflow-y-auto">
|
||||
{systemInfo.help}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemInfoModal
|
||||
119
webui/src/components/ZodFormField.tsx
Normal file
119
webui/src/components/ZodFormField.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { CreateInstanceOptions } from '@/types/instance'
|
||||
import { getFieldType, basicFieldsConfig } from '@/lib/zodFormUtils'
|
||||
|
||||
interface ZodFormFieldProps {
|
||||
fieldKey: keyof CreateInstanceOptions
|
||||
value: any
|
||||
onChange: (key: keyof CreateInstanceOptions, value: any) => void
|
||||
}
|
||||
|
||||
const ZodFormField: React.FC<ZodFormFieldProps> = ({ fieldKey, value, onChange }) => {
|
||||
// Get configuration for basic fields, or use field name for advanced fields
|
||||
const config = basicFieldsConfig[fieldKey as string] || { label: fieldKey }
|
||||
|
||||
// Get type from Zod schema
|
||||
const fieldType = getFieldType(fieldKey)
|
||||
|
||||
const handleChange = (newValue: any) => {
|
||||
onChange(fieldKey, newValue)
|
||||
}
|
||||
|
||||
const renderField = () => {
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={fieldKey}
|
||||
checked={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"
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
const numValue = e.target.value ? parseFloat(e.target.value) : undefined
|
||||
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={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 ZodFormField
|
||||
46
webui/src/components/ui/badge.tsx
Normal file
46
webui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
59
webui/src/components/ui/button.tsx
Normal file
59
webui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
webui/src/components/ui/card.tsx
Normal file
92
webui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
30
webui/src/components/ui/checkbox.tsx
Normal file
30
webui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
141
webui/src/components/ui/dialog.tsx
Normal file
141
webui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
21
webui/src/components/ui/input.tsx
Normal file
21
webui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
22
webui/src/components/ui/label.tsx
Normal file
22
webui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
187
webui/src/contexts/InstancesContext.tsx
Normal file
187
webui/src/contexts/InstancesContext.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||
import { CreateInstanceOptions, Instance } from '@/types/instance'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
|
||||
interface InstancesContextState {
|
||||
instances: Instance[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface InstancesContextActions {
|
||||
fetchInstances: () => Promise<void>
|
||||
createInstance: (name: string, options: CreateInstanceOptions) => Promise<void>
|
||||
updateInstance: (name: string, options: CreateInstanceOptions) => Promise<void>
|
||||
startInstance: (name: string) => Promise<void>
|
||||
stopInstance: (name: string) => Promise<void>
|
||||
restartInstance: (name: string) => Promise<void>
|
||||
deleteInstance: (name: string) => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
type InstancesContextType = InstancesContextState & InstancesContextActions
|
||||
|
||||
const InstancesContext = createContext<InstancesContextType | undefined>(undefined)
|
||||
|
||||
interface InstancesProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Convert map to array for consumers
|
||||
const instances = Array.from(instancesMap.values())
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const fetchInstances = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await instancesApi.list()
|
||||
|
||||
// Convert array to map
|
||||
const newMap = new Map<string, Instance>()
|
||||
data.forEach(instance => {
|
||||
newMap.set(instance.name, instance)
|
||||
})
|
||||
setInstancesMap(newMap)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch instances')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => {
|
||||
setInstancesMap(prev => {
|
||||
const newMap = new Map(prev)
|
||||
const existing = newMap.get(name)
|
||||
if (existing) {
|
||||
newMap.set(name, { ...existing, ...updates })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createInstance = useCallback(async (name: string, options: CreateInstanceOptions) => {
|
||||
try {
|
||||
setError(null)
|
||||
const newInstance = await instancesApi.create(name, options)
|
||||
|
||||
// Add to map directly
|
||||
setInstancesMap(prev => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.set(name, newInstance)
|
||||
return newMap
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create instance')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateInstance = useCallback(async (name: string, options: CreateInstanceOptions) => {
|
||||
try {
|
||||
setError(null)
|
||||
const updatedInstance = await instancesApi.update(name, options)
|
||||
|
||||
// Update in map directly
|
||||
setInstancesMap(prev => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.set(name, updatedInstance)
|
||||
return newMap
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update instance')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startInstance = useCallback(async (name: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.start(name)
|
||||
|
||||
// Update only this instance's running status
|
||||
updateInstanceInMap(name, { running: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start instance')
|
||||
}
|
||||
}, [updateInstanceInMap])
|
||||
|
||||
const stopInstance = useCallback(async (name: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.stop(name)
|
||||
|
||||
// Update only this instance's running status
|
||||
updateInstanceInMap(name, { running: false })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to stop instance')
|
||||
}
|
||||
}, [updateInstanceInMap])
|
||||
|
||||
const restartInstance = useCallback(async (name: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.restart(name)
|
||||
|
||||
// Update only this instance's running status
|
||||
updateInstanceInMap(name, { running: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to restart instance')
|
||||
}
|
||||
}, [updateInstanceInMap])
|
||||
|
||||
const deleteInstance = useCallback(async (name: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
await instancesApi.delete(name)
|
||||
|
||||
// Remove from map directly
|
||||
setInstancesMap(prev => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.delete(name)
|
||||
return newMap
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete instance')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstances()
|
||||
}, [fetchInstances])
|
||||
|
||||
const value: InstancesContextType = {
|
||||
instances,
|
||||
loading,
|
||||
error,
|
||||
fetchInstances,
|
||||
createInstance,
|
||||
updateInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
restartInstance,
|
||||
deleteInstance,
|
||||
clearError,
|
||||
}
|
||||
|
||||
return (
|
||||
<InstancesContext.Provider value={value}>
|
||||
{children}
|
||||
</InstancesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useInstances = (): InstancesContextType => {
|
||||
const context = useContext(InstancesContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useInstances must be used within an InstancesProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
25
webui/src/hooks/useInstanceHealth.ts
Normal file
25
webui/src/hooks/useInstanceHealth.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// ui/src/hooks/useInstanceHealth.ts
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HealthStatus } from '@/types/instance'
|
||||
import { healthService } from '@/lib/healthService'
|
||||
|
||||
export function useInstanceHealth(instanceName: string, isRunning: boolean): HealthStatus | undefined {
|
||||
const [health, setHealth] = useState<HealthStatus | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunning) {
|
||||
setHealth(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to health updates for this instance
|
||||
const unsubscribe = healthService.subscribe(instanceName, (healthStatus) => {
|
||||
setHealth(healthStatus)
|
||||
})
|
||||
|
||||
// Cleanup subscription on unmount or when running changes
|
||||
return unsubscribe
|
||||
}, [instanceName, isRunning])
|
||||
|
||||
return health
|
||||
}
|
||||
120
webui/src/index.css
Normal file
120
webui/src/index.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
146
webui/src/lib/api.ts
Normal file
146
webui/src/lib/api.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { CreateInstanceOptions, Instance } from "@/types/instance"
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
// Configuration for API calls
|
||||
// interface ApiConfig {
|
||||
// apiKey?: string
|
||||
// }
|
||||
|
||||
// Global config - can be updated when auth is added
|
||||
// let apiConfig: ApiConfig = {}
|
||||
|
||||
// export const setApiConfig = (config: ApiConfig) => {
|
||||
// apiConfig = config
|
||||
// }
|
||||
|
||||
// Base API call function with error handling
|
||||
async function apiCall<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`
|
||||
|
||||
// Prepare headers
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
// Add API key - not supported yet
|
||||
// if (apiConfig.apiKey) {
|
||||
// headers['Authorization'] = `Bearer ${apiConfig.apiKey}`
|
||||
// }
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to get error message from response
|
||||
let errorMessage = `HTTP ${response.status}`
|
||||
try {
|
||||
const errorText = await response.text()
|
||||
if (errorText) {
|
||||
errorMessage += `: ${errorText}`
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the error, just use status
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// Handle empty responses (like DELETE)
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Network error occurred')
|
||||
}
|
||||
}
|
||||
|
||||
// Server API functions
|
||||
export const serverApi = {
|
||||
// GET /server/help
|
||||
getHelp: async (): Promise<string> => {
|
||||
const response = await fetch(`${API_BASE}/server/help`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.text()
|
||||
},
|
||||
|
||||
// GET /server/version
|
||||
getVersion: async (): Promise<string> => {
|
||||
const response = await fetch(`${API_BASE}/server/version`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.text()
|
||||
},
|
||||
|
||||
// GET /server/devices
|
||||
getDevices: async (): Promise<string> => {
|
||||
const response = await fetch(`${API_BASE}/server/devices`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return response.text()
|
||||
},
|
||||
}
|
||||
|
||||
// Instance API functions
|
||||
export const instancesApi = {
|
||||
// GET /instances
|
||||
list: () => apiCall<Instance[]>('/instances'),
|
||||
|
||||
// GET /instances/{name}
|
||||
get: (name: string) => apiCall<Instance>(`/instances/${name}`),
|
||||
|
||||
// POST /instances/{name}
|
||||
create: (name: string, options: CreateInstanceOptions) =>
|
||||
apiCall<Instance>(`/instances/${name}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
}),
|
||||
|
||||
// PUT /instances/{name}
|
||||
update: (name: string, options: CreateInstanceOptions) =>
|
||||
apiCall<Instance>(`/instances/${name}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(options),
|
||||
}),
|
||||
|
||||
// DELETE /instances/{name}
|
||||
delete: (name: string) =>
|
||||
apiCall<void>(`/instances/${name}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// POST /instances/{name}/start
|
||||
start: (name: string) =>
|
||||
apiCall<Instance>(`/instances/${name}/start`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// POST /instances/{name}/stop
|
||||
stop: (name: string) =>
|
||||
apiCall<Instance>(`/instances/${name}/stop`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// POST /instances/{name}/restart
|
||||
restart: (name: string) =>
|
||||
apiCall<Instance>(`/instances/${name}/restart`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// GET /instances/{name}/logs
|
||||
getLogs: (name: string, lines?: number) => {
|
||||
const params = lines ? `?lines=${lines}` : ''
|
||||
return apiCall<string>(`/instances/${name}/logs${params}`)
|
||||
},
|
||||
}
|
||||
115
webui/src/lib/healthService.ts
Normal file
115
webui/src/lib/healthService.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { HealthStatus } from '@/types/instance'
|
||||
|
||||
type HealthCallback = (health: HealthStatus) => void
|
||||
|
||||
class HealthService {
|
||||
private intervals: Map<string, NodeJS.Timeout> = new Map()
|
||||
private callbacks: Map<string, Set<HealthCallback>> = new Map()
|
||||
|
||||
async checkHealth(instanceName: string): Promise<HealthStatus> {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/instances/${instanceName}/proxy/health`)
|
||||
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
status: 'ok',
|
||||
lastChecked: new Date()
|
||||
}
|
||||
} else if (response.status === 503) {
|
||||
const data = await response.json()
|
||||
return {
|
||||
status: 'loading',
|
||||
message: data.error.message,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `HTTP ${response.status}`,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Network error',
|
||||
lastChecked: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(instanceName: string, callback: HealthCallback): () => void {
|
||||
if (!this.callbacks.has(instanceName)) {
|
||||
this.callbacks.set(instanceName, new Set())
|
||||
}
|
||||
|
||||
this.callbacks.get(instanceName)!.add(callback)
|
||||
|
||||
// Start health checking if this is the first subscriber
|
||||
if (this.callbacks.get(instanceName)!.size === 1) {
|
||||
this.startHealthCheck(instanceName)
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const callbacks = this.callbacks.get(instanceName)
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback)
|
||||
|
||||
// Stop health checking if no more subscribers
|
||||
if (callbacks.size === 0) {
|
||||
this.stopHealthCheck(instanceName)
|
||||
this.callbacks.delete(instanceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthCheck(instanceName: string): void {
|
||||
if (this.intervals.has(instanceName)) {
|
||||
return // Already checking
|
||||
}
|
||||
|
||||
// Initial check with delay
|
||||
setTimeout(async () => {
|
||||
const health = await this.checkHealth(instanceName)
|
||||
this.notifyCallbacks(instanceName, health)
|
||||
|
||||
// Start periodic checks
|
||||
const interval = setInterval(async () => {
|
||||
const health = await this.checkHealth(instanceName)
|
||||
this.notifyCallbacks(instanceName, health)
|
||||
}, 60000)
|
||||
|
||||
this.intervals.set(instanceName, interval)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
private stopHealthCheck(instanceName: string): void {
|
||||
const interval = this.intervals.get(instanceName)
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
this.intervals.delete(instanceName)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyCallbacks(instanceName: string, health: HealthStatus): void {
|
||||
const callbacks = this.callbacks.get(instanceName)
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => callback(health))
|
||||
}
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
this.intervals.forEach(interval => clearInterval(interval))
|
||||
this.intervals.clear()
|
||||
this.callbacks.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const healthService = new HealthService()
|
||||
|
||||
// Export the individual checkHealth function as well
|
||||
export async function checkHealth(instanceName: string): Promise<HealthStatus> {
|
||||
return healthService.checkHealth(instanceName)
|
||||
}
|
||||
6
webui/src/lib/utils.ts
Normal file
6
webui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
59
webui/src/lib/zodFormUtils.ts
Normal file
59
webui/src/lib/zodFormUtils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { CreateInstanceOptions, getAllFieldKeys } from '@/schemas/instanceOptions'
|
||||
|
||||
// Only define the basic fields we want to show by default
|
||||
export const basicFieldsConfig: Record<string, {
|
||||
label: string
|
||||
description?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
}> = {
|
||||
auto_restart: {
|
||||
label: 'Auto Restart',
|
||||
description: 'Automatically restart the instance on failure'
|
||||
},
|
||||
max_restarts: {
|
||||
label: 'Max Restarts',
|
||||
placeholder: '3',
|
||||
description: 'Maximum number of restart attempts (0 = unlimited)'
|
||||
},
|
||||
restart_delay: {
|
||||
label: 'Restart Delay (seconds)',
|
||||
placeholder: '5',
|
||||
description: 'Delay in seconds before attempting restart'
|
||||
},
|
||||
model: {
|
||||
label: 'Model Path',
|
||||
placeholder: '/path/to/model.gguf',
|
||||
description: 'Path to the model file'
|
||||
},
|
||||
hf_repo: {
|
||||
label: 'Hugging Face Repository',
|
||||
placeholder: 'microsoft/DialoGPT-medium',
|
||||
description: 'Hugging Face model repository'
|
||||
},
|
||||
hf_file: {
|
||||
label: 'Hugging Face File',
|
||||
placeholder: 'model.gguf',
|
||||
description: 'Specific file in the repository'
|
||||
},
|
||||
gpu_layers: {
|
||||
label: 'GPU Layers',
|
||||
placeholder: '0',
|
||||
description: 'Number of layers to offload to GPU'
|
||||
}
|
||||
}
|
||||
|
||||
export function isBasicField(key: keyof CreateInstanceOptions): boolean {
|
||||
return key in basicFieldsConfig
|
||||
}
|
||||
|
||||
export function getBasicFields(): (keyof CreateInstanceOptions)[] {
|
||||
return Object.keys(basicFieldsConfig) as (keyof CreateInstanceOptions)[]
|
||||
}
|
||||
|
||||
export function getAdvancedFields(): (keyof CreateInstanceOptions)[] {
|
||||
return getAllFieldKeys().filter(key => !isBasicField(key))
|
||||
}
|
||||
|
||||
// Re-export the Zod-based functions
|
||||
export { getFieldType } from '@/schemas/instanceOptions'
|
||||
13
webui/src/main.tsx
Normal file
13
webui/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { InstancesProvider } from './contexts/InstancesContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
199
webui/src/schemas/instanceOptions.ts
Normal file
199
webui/src/schemas/instanceOptions.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Define the Zod schema
|
||||
export const CreateInstanceOptionsSchema = z.object({
|
||||
// Restart options
|
||||
auto_restart: z.boolean().optional(),
|
||||
max_restarts: z.number().optional(),
|
||||
restart_delay: z.number().optional(),
|
||||
|
||||
// Common params
|
||||
verbose_prompt: z.boolean().optional(),
|
||||
threads: z.number().optional(),
|
||||
threads_batch: z.number().optional(),
|
||||
cpu_mask: z.string().optional(),
|
||||
cpu_range: z.string().optional(),
|
||||
cpu_strict: z.number().optional(),
|
||||
priority: z.number().optional(),
|
||||
poll: z.number().optional(),
|
||||
cpu_mask_batch: z.string().optional(),
|
||||
cpu_range_batch: z.string().optional(),
|
||||
cpu_strict_batch: z.number().optional(),
|
||||
priority_batch: z.number().optional(),
|
||||
poll_batch: z.number().optional(),
|
||||
ctx_size: z.number().optional(),
|
||||
predict: z.number().optional(),
|
||||
batch_size: z.number().optional(),
|
||||
ubatch_size: z.number().optional(),
|
||||
keep: z.number().optional(),
|
||||
flash_attn: z.boolean().optional(),
|
||||
no_perf: z.boolean().optional(),
|
||||
escape: z.boolean().optional(),
|
||||
no_escape: z.boolean().optional(),
|
||||
rope_scaling: z.string().optional(),
|
||||
rope_scale: z.number().optional(),
|
||||
rope_freq_base: z.number().optional(),
|
||||
rope_freq_scale: z.number().optional(),
|
||||
yarn_orig_ctx: z.number().optional(),
|
||||
yarn_ext_factor: z.number().optional(),
|
||||
yarn_attn_factor: z.number().optional(),
|
||||
yarn_beta_slow: z.number().optional(),
|
||||
yarn_beta_fast: z.number().optional(),
|
||||
dump_kv_cache: z.boolean().optional(),
|
||||
no_kv_offload: z.boolean().optional(),
|
||||
cache_type_k: z.string().optional(),
|
||||
cache_type_v: z.string().optional(),
|
||||
defrag_thold: z.number().optional(),
|
||||
parallel: z.number().optional(),
|
||||
mlock: z.boolean().optional(),
|
||||
no_mmap: z.boolean().optional(),
|
||||
numa: z.string().optional(),
|
||||
device: z.string().optional(),
|
||||
override_tensor: z.array(z.string()).optional(),
|
||||
gpu_layers: z.number().optional(),
|
||||
split_mode: z.string().optional(),
|
||||
tensor_split: z.string().optional(),
|
||||
main_gpu: z.number().optional(),
|
||||
check_tensors: z.boolean().optional(),
|
||||
override_kv: z.array(z.string()).optional(),
|
||||
lora: z.array(z.string()).optional(),
|
||||
lora_scaled: z.array(z.string()).optional(),
|
||||
control_vector: z.array(z.string()).optional(),
|
||||
control_vector_scaled: z.array(z.string()).optional(),
|
||||
control_vector_layer_range: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
model_url: z.string().optional(),
|
||||
hf_repo: z.string().optional(),
|
||||
hf_repo_draft: z.string().optional(),
|
||||
hf_file: z.string().optional(),
|
||||
hf_repo_v: z.string().optional(),
|
||||
hf_file_v: z.string().optional(),
|
||||
hf_token: z.string().optional(),
|
||||
log_disable: z.boolean().optional(),
|
||||
log_file: z.string().optional(),
|
||||
log_colors: z.boolean().optional(),
|
||||
verbose: z.boolean().optional(),
|
||||
verbosity: z.number().optional(),
|
||||
log_prefix: z.boolean().optional(),
|
||||
log_timestamps: z.boolean().optional(),
|
||||
|
||||
// Sampling params
|
||||
samplers: z.string().optional(),
|
||||
seed: z.number().optional(),
|
||||
sampling_seq: z.string().optional(),
|
||||
ignore_eos: z.boolean().optional(),
|
||||
temperature: z.number().optional(),
|
||||
top_k: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
min_p: z.number().optional(),
|
||||
xtc_probability: z.number().optional(),
|
||||
xtc_threshold: z.number().optional(),
|
||||
typical: z.number().optional(),
|
||||
repeat_last_n: z.number().optional(),
|
||||
repeat_penalty: z.number().optional(),
|
||||
presence_penalty: z.number().optional(),
|
||||
frequency_penalty: z.number().optional(),
|
||||
dry_multiplier: z.number().optional(),
|
||||
dry_base: z.number().optional(),
|
||||
dry_allowed_length: z.number().optional(),
|
||||
dry_penalty_last_n: z.number().optional(),
|
||||
dry_sequence_breaker: z.array(z.string()).optional(),
|
||||
dynatemp_range: z.number().optional(),
|
||||
dynatemp_exp: z.number().optional(),
|
||||
mirostat: z.number().optional(),
|
||||
mirostat_lr: z.number().optional(),
|
||||
mirostat_ent: z.number().optional(),
|
||||
logit_bias: z.array(z.string()).optional(),
|
||||
grammar: z.string().optional(),
|
||||
grammar_file: z.string().optional(),
|
||||
json_schema: z.string().optional(),
|
||||
json_schema_file: z.string().optional(),
|
||||
|
||||
// Server/Example-specific params
|
||||
no_context_shift: z.boolean().optional(),
|
||||
special: z.boolean().optional(),
|
||||
no_warmup: z.boolean().optional(),
|
||||
spm_infill: z.boolean().optional(),
|
||||
pooling: z.string().optional(),
|
||||
cont_batching: z.boolean().optional(),
|
||||
no_cont_batching: z.boolean().optional(),
|
||||
mmproj: z.string().optional(),
|
||||
mmproj_url: z.string().optional(),
|
||||
no_mmproj: z.boolean().optional(),
|
||||
no_mmproj_offload: z.boolean().optional(),
|
||||
alias: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
path: z.string().optional(),
|
||||
no_webui: z.boolean().optional(),
|
||||
embedding: z.boolean().optional(),
|
||||
reranking: z.boolean().optional(),
|
||||
api_key: z.string().optional(),
|
||||
api_key_file: z.string().optional(),
|
||||
ssl_key_file: z.string().optional(),
|
||||
ssl_cert_file: z.string().optional(),
|
||||
chat_template_kwargs: z.string().optional(),
|
||||
timeout: z.number().optional(),
|
||||
threads_http: z.number().optional(),
|
||||
cache_reuse: z.number().optional(),
|
||||
metrics: z.boolean().optional(),
|
||||
slots: z.boolean().optional(),
|
||||
props: z.boolean().optional(),
|
||||
no_slots: z.boolean().optional(),
|
||||
slot_save_path: z.string().optional(),
|
||||
jinja: z.boolean().optional(),
|
||||
reasoning_format: z.string().optional(),
|
||||
reasoning_budget: z.number().optional(),
|
||||
chat_template: z.string().optional(),
|
||||
chat_template_file: z.string().optional(),
|
||||
no_prefill_assistant: z.boolean().optional(),
|
||||
slot_prompt_similarity: z.number().optional(),
|
||||
lora_init_without_apply: z.boolean().optional(),
|
||||
|
||||
// Speculative decoding params
|
||||
draft_max: z.number().optional(),
|
||||
draft_min: z.number().optional(),
|
||||
draft_p_min: z.number().optional(),
|
||||
ctx_size_draft: z.number().optional(),
|
||||
device_draft: z.string().optional(),
|
||||
gpu_layers_draft: z.number().optional(),
|
||||
model_draft: z.string().optional(),
|
||||
cache_type_k_draft: z.string().optional(),
|
||||
cache_type_v_draft: z.string().optional(),
|
||||
|
||||
// Audio/TTS params
|
||||
model_vocoder: z.string().optional(),
|
||||
tts_use_guide_tokens: z.boolean().optional(),
|
||||
|
||||
// Default model params
|
||||
embd_bge_small_en_default: z.boolean().optional(),
|
||||
embd_e5_small_en_default: z.boolean().optional(),
|
||||
embd_gte_small_default: z.boolean().optional(),
|
||||
fim_qwen_1_5b_default: z.boolean().optional(),
|
||||
fim_qwen_3b_default: z.boolean().optional(),
|
||||
fim_qwen_7b_default: z.boolean().optional(),
|
||||
fim_qwen_7b_spec: z.boolean().optional(),
|
||||
fim_qwen_14b_spec: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// Infer the TypeScript type from the schema
|
||||
export type CreateInstanceOptions = z.infer<typeof CreateInstanceOptionsSchema>
|
||||
|
||||
// Helper to get all field keys
|
||||
export function getAllFieldKeys(): (keyof CreateInstanceOptions)[] {
|
||||
return Object.keys(CreateInstanceOptionsSchema.shape) as (keyof CreateInstanceOptions)[]
|
||||
}
|
||||
|
||||
// Get field type from Zod schema
|
||||
export function getFieldType(key: keyof CreateInstanceOptions): 'text' | 'number' | 'boolean' | 'array' {
|
||||
const fieldSchema = CreateInstanceOptionsSchema.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.ZodNumber) return 'number'
|
||||
if (innerSchema instanceof z.ZodArray) return 'array'
|
||||
return 'text' // ZodString and others default to text
|
||||
}
|
||||
15
webui/src/types/instance.ts
Normal file
15
webui/src/types/instance.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||
|
||||
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'loading' | 'error' | 'unknown'
|
||||
message?: string
|
||||
lastChecked: Date
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
name: string;
|
||||
running: boolean;
|
||||
options?: CreateInstanceOptions;
|
||||
}
|
||||
Reference in New Issue
Block a user