mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 17:14:28 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34edb8a2e5 | |||
| 560850f86d | |||
| c340439306 | |||
| 77c0e22fd0 | |||
| d65c5ab717 | |||
| 2b94244c8a | |||
| 2e5644db53 | |||
| 7ee22fee51 | |||
| e5baedb776 | |||
| e6205b930e | |||
| f9eb424690 | |||
| 5b84b64623 | |||
| 7813a5f2be | |||
| a00c9b82a6 | |||
| cbfa6bd48f | |||
| bee0f72c10 | |||
| a5d8f541f0 | |||
| dfcc16083c | |||
| 6ec2919049 | |||
| d6a6f377fc | |||
| cd9a71d9fc | |||
| 2c4cc5a69a | |||
| b1fc1d2dc8 | |||
| 08c47a16a0 | |||
| 219db7abce | |||
| 14131a6274 | |||
| e65f4f1641 | |||
| 5ef0654cdd | |||
| 1814772fa2 |
@@ -5,6 +5,7 @@ import (
|
|||||||
"llamactl/pkg/config"
|
"llamactl/pkg/config"
|
||||||
"llamactl/pkg/manager"
|
"llamactl/pkg/manager"
|
||||||
"llamactl/pkg/server"
|
"llamactl/pkg/server"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -38,8 +39,7 @@ func main() {
|
|||||||
configPath := os.Getenv("LLAMACTL_CONFIG_PATH")
|
configPath := os.Getenv("LLAMACTL_CONFIG_PATH")
|
||||||
cfg, err := config.LoadConfig(configPath)
|
cfg, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
log.Printf("Error loading config: %v\nUsing default configuration.", err)
|
||||||
fmt.Println("Using default configuration.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set version information
|
// Set version information
|
||||||
@@ -50,13 +50,11 @@ func main() {
|
|||||||
// Create the data directory if it doesn't exist
|
// Create the data directory if it doesn't exist
|
||||||
if cfg.Instances.AutoCreateDirs {
|
if cfg.Instances.AutoCreateDirs {
|
||||||
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.Instances.InstancesDir, 0755); err != nil {
|
||||||
fmt.Printf("Error creating config directory %s: %v\n", cfg.Instances.InstancesDir, err)
|
log.Printf("Error creating config directory %s: %v\nPersistence will not be available.", cfg.Instances.InstancesDir, err)
|
||||||
fmt.Println("Persistence will not be available.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.Instances.LogsDir, 0755); err != nil {
|
||||||
fmt.Printf("Error creating log directory %s: %v\n", cfg.Instances.LogsDir, err)
|
log.Printf("Error creating log directory %s: %v\nInstance logs will not be available.", cfg.Instances.LogsDir, err)
|
||||||
fmt.Println("Instance logs will not be available.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +79,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
fmt.Printf("Llamactl server listening on %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
fmt.Printf("Llamactl server listening on %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
fmt.Printf("Error starting server: %v\n", err)
|
log.Printf("Error starting server: %v\n", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -90,7 +88,7 @@ func main() {
|
|||||||
fmt.Println("Shutting down server...")
|
fmt.Println("Shutting down server...")
|
||||||
|
|
||||||
if err := server.Close(); err != nil {
|
if err := server.Close(); err != nil {
|
||||||
fmt.Printf("Error shutting down server: %v\n", err)
|
log.Printf("Error shutting down server: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Server shut down gracefully.")
|
fmt.Println("Server shut down gracefully.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -231,6 +232,11 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
|||||||
cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs")
|
cfg.Instances.LogsDir = filepath.Join(cfg.Instances.DataDir, "logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate port range
|
||||||
|
if cfg.Instances.PortRange[0] <= 0 || cfg.Instances.PortRange[1] <= 0 || cfg.Instances.PortRange[0] >= cfg.Instances.PortRange[1] {
|
||||||
|
return AppConfig{}, fmt.Errorf("invalid port range: %v", cfg.Instances.PortRange)
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"llamactl/pkg/config"
|
"llamactl/pkg/config"
|
||||||
"log"
|
"log"
|
||||||
"net/http/httputil"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,15 +182,6 @@ func (i *Instance) GetPort() int {
|
|||||||
return i.options.GetPort()
|
return i.options.GetPort()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProxy returns the reverse proxy for this instance
|
|
||||||
func (i *Instance) GetProxy() (*httputil.ReverseProxy, error) {
|
|
||||||
if i.proxy == nil {
|
|
||||||
return nil, fmt.Errorf("instance %s has no proxy component", i.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return i.proxy.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Instance) IsRemote() bool {
|
func (i *Instance) IsRemote() bool {
|
||||||
opts := i.GetOptions()
|
opts := i.GetOptions()
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
@@ -242,6 +233,22 @@ func (i *Instance) ShouldTimeout() bool {
|
|||||||
return i.proxy.shouldTimeout()
|
return i.proxy.shouldTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInflightRequests returns the current number of inflight requests
|
||||||
|
func (i *Instance) GetInflightRequests() int32 {
|
||||||
|
if i.proxy == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return i.proxy.getInflightRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP serves HTTP requests through the proxy with request tracking and shutdown handling
|
||||||
|
func (i *Instance) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if i.proxy == nil {
|
||||||
|
return fmt.Errorf("instance %s has no proxy component", i.Name)
|
||||||
|
}
|
||||||
|
return i.proxy.serveHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Instance) getCommand() string {
|
func (i *Instance) getCommand() string {
|
||||||
opts := i.GetOptions()
|
opts := i.GetOptions()
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
|
|||||||
@@ -171,64 +171,6 @@ func TestSetOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetProxy(t *testing.T) {
|
|
||||||
globalConfig := &config.AppConfig{
|
|
||||||
Backends: config.BackendConfig{
|
|
||||||
LlamaCpp: config.BackendSettings{
|
|
||||||
Command: "llama-server",
|
|
||||||
Args: []string{},
|
|
||||||
},
|
|
||||||
MLX: config.BackendSettings{
|
|
||||||
Command: "mlx_lm.server",
|
|
||||||
Args: []string{},
|
|
||||||
},
|
|
||||||
VLLM: config.BackendSettings{
|
|
||||||
Command: "vllm",
|
|
||||||
Args: []string{"serve"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Instances: config.InstancesConfig{
|
|
||||||
LogsDir: "/tmp/test",
|
|
||||||
},
|
|
||||||
Nodes: map[string]config.NodeConfig{},
|
|
||||||
LocalNode: "main",
|
|
||||||
}
|
|
||||||
|
|
||||||
options := &instance.Options{
|
|
||||||
Nodes: map[string]struct{}{"main": {}},
|
|
||||||
BackendOptions: backends.Options{
|
|
||||||
BackendType: backends.BackendTypeLlamaCpp,
|
|
||||||
LlamaServerOptions: &backends.LlamaServerOptions{
|
|
||||||
Host: "localhost",
|
|
||||||
Port: 8080,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock onStatusChange function
|
|
||||||
mockOnStatusChange := func(oldStatus, newStatus instance.Status) {}
|
|
||||||
|
|
||||||
inst := instance.New("test-instance", globalConfig, options, mockOnStatusChange)
|
|
||||||
|
|
||||||
// Get proxy for the first time
|
|
||||||
proxy1, err := inst.GetProxy()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetProxy failed: %v", err)
|
|
||||||
}
|
|
||||||
if proxy1 == nil {
|
|
||||||
t.Error("Expected proxy to be created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get proxy again - should return cached version
|
|
||||||
proxy2, err := inst.GetProxy()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetProxy failed: %v", err)
|
|
||||||
}
|
|
||||||
if proxy1 != proxy2 {
|
|
||||||
t.Error("Expected cached proxy to be returned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMarshalJSON(t *testing.T) {
|
func TestMarshalJSON(t *testing.T) {
|
||||||
globalConfig := &config.AppConfig{
|
globalConfig := &config.AppConfig{
|
||||||
Backends: config.BackendConfig{
|
Backends: config.BackendConfig{
|
||||||
@@ -613,11 +555,6 @@ func TestRemoteInstanceOperations(t *testing.T) {
|
|||||||
t.Error("Expected error when restarting remote instance")
|
t.Error("Expected error when restarting remote instance")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProxy should fail for remote instance
|
|
||||||
if _, err := inst.GetProxy(); err != nil {
|
|
||||||
t.Error("Expected no error when getting proxy for remote instance")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLogs should fail for remote instance
|
// GetLogs should fail for remote instance
|
||||||
if _, err := inst.GetLogs(10); err == nil {
|
if _, err := inst.GetLogs(10); err == nil {
|
||||||
t.Error("Expected error when getting logs for remote instance")
|
t.Error("Expected error when getting logs for remote instance")
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logger struct {
|
type logger struct {
|
||||||
name string
|
name string
|
||||||
logDir string
|
logDir string
|
||||||
logFile *os.File
|
logFile atomic.Pointer[os.File]
|
||||||
logFilePath string
|
logFilePath string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -47,11 +48,11 @@ func (i *logger) create() error {
|
|||||||
return fmt.Errorf("failed to create stdout log file: %w", err)
|
return fmt.Errorf("failed to create stdout log file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
i.logFile = logFile
|
i.logFile.Store(logFile)
|
||||||
|
|
||||||
// Write a startup marker to both files
|
// Write a startup marker to both files
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||||
fmt.Fprintf(i.logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
|
fmt.Fprintf(logFile, "\n=== Instance %s started at %s ===\n", i.name, timestamp)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -102,11 +103,12 @@ func (i *logger) close() {
|
|||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
|
|
||||||
if i.logFile != nil {
|
logFile := i.logFile.Swap(nil)
|
||||||
|
if logFile != nil {
|
||||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||||
fmt.Fprintf(i.logFile, "=== Instance %s stopped at %s ===\n\n", i.name, timestamp)
|
fmt.Fprintf(logFile, "=== Instance %s stopped at %s ===\n\n", i.name, timestamp)
|
||||||
i.logFile.Close()
|
logFile.Sync() // Ensure all buffered data is written to disk
|
||||||
i.logFile = nil
|
logFile.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +119,9 @@ func (i *logger) readOutput(reader io.ReadCloser) {
|
|||||||
scanner := bufio.NewScanner(reader)
|
scanner := bufio.NewScanner(reader)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
if i.logFile != nil {
|
// Use atomic load to avoid lock contention on every line
|
||||||
fmt.Fprintln(i.logFile, line)
|
if logFile := i.logFile.Load(); logFile != nil {
|
||||||
i.logFile.Sync() // Ensure data is written to disk
|
fmt.Fprintln(logFile, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,14 +132,28 @@ func (p *process) stop() error {
|
|||||||
p.restartCancel = nil
|
p.restartCancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set status to stopped first to signal intentional stop
|
// Set status to ShuttingDown first to reject new requests
|
||||||
p.instance.SetStatus(Stopped)
|
p.instance.SetStatus(ShuttingDown)
|
||||||
|
|
||||||
// Get the monitor done channel before releasing the lock
|
// Get the monitor done channel before releasing the lock
|
||||||
monitorDone := p.monitorDone
|
monitorDone := p.monitorDone
|
||||||
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Wait for inflight requests to complete (max 30 seconds)
|
||||||
|
log.Printf("Instance %s shutting down, waiting for inflight requests to complete...", p.instance.Name)
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
inflight := p.instance.GetInflightRequests()
|
||||||
|
if inflight == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now set status to stopped to signal intentional stop
|
||||||
|
p.instance.SetStatus(Stopped)
|
||||||
|
|
||||||
// Stop the process with SIGINT if cmd exists
|
// Stop the process with SIGINT if cmd exists
|
||||||
if p.cmd != nil && p.cmd.Process != nil {
|
if p.cmd != nil && p.cmd.Process != nil {
|
||||||
if err := p.cmd.Process.Signal(syscall.SIGINT); err != nil {
|
if err := p.cmd.Process.Signal(syscall.SIGINT); err != nil {
|
||||||
@@ -156,6 +170,7 @@ func (p *process) stop() error {
|
|||||||
select {
|
select {
|
||||||
case <-monitorDone:
|
case <-monitorDone:
|
||||||
// Process exited normally
|
// Process exited normally
|
||||||
|
log.Printf("Instance %s shut down gracefully", p.instance.Name)
|
||||||
case <-time.After(30 * time.Second):
|
case <-time.After(30 * time.Second):
|
||||||
// Force kill if it doesn't exit within 30 seconds
|
// Force kill if it doesn't exit within 30 seconds
|
||||||
if p.cmd != nil && p.cmd.Process != nil {
|
if p.cmd != nil && p.cmd.Process != nil {
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ type proxy struct {
|
|||||||
proxyOnce sync.Once
|
proxyOnce sync.Once
|
||||||
proxyErr error
|
proxyErr error
|
||||||
|
|
||||||
lastRequestTime atomic.Int64
|
lastRequestTime atomic.Int64
|
||||||
timeProvider TimeProvider
|
inflightRequests atomic.Int32
|
||||||
|
timeProvider TimeProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// newProxy creates a new Proxy for the given instance
|
// newProxy creates a new Proxy for the given instance
|
||||||
@@ -153,6 +154,23 @@ func (p *proxy) build() (*httputil.ReverseProxy, error) {
|
|||||||
return proxy, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveHTTP handles HTTP requests with inflight tracking
|
||||||
|
func (p *proxy) serveHTTP(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// Get the reverse proxy
|
||||||
|
reverseProxy, err := p.get()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track inflight requests
|
||||||
|
p.incInflightRequests()
|
||||||
|
defer p.decInflightRequests()
|
||||||
|
|
||||||
|
// Serve the request
|
||||||
|
reverseProxy.ServeHTTP(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// clear resets the proxy, allowing it to be recreated when options change.
|
// clear resets the proxy, allowing it to be recreated when options change.
|
||||||
func (p *proxy) clear() {
|
func (p *proxy) clear() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -160,7 +178,7 @@ func (p *proxy) clear() {
|
|||||||
|
|
||||||
p.proxy = nil
|
p.proxy = nil
|
||||||
p.proxyErr = nil
|
p.proxyErr = nil
|
||||||
p.proxyOnce = sync.Once{} // Reset Once for next GetProxy call
|
p.proxyOnce = sync.Once{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateLastRequestTime updates the last request access time for the instance
|
// updateLastRequestTime updates the last request access time for the instance
|
||||||
@@ -199,3 +217,18 @@ func (p *proxy) shouldTimeout() bool {
|
|||||||
func (p *proxy) setTimeProvider(tp TimeProvider) {
|
func (p *proxy) setTimeProvider(tp TimeProvider) {
|
||||||
p.timeProvider = tp
|
p.timeProvider = tp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// incInflightRequests increments the inflight request counter
|
||||||
|
func (p *proxy) incInflightRequests() {
|
||||||
|
p.inflightRequests.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decInflightRequests decrements the inflight request counter
|
||||||
|
func (p *proxy) decInflightRequests() {
|
||||||
|
p.inflightRequests.Add(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInflightRequests returns the current number of inflight requests
|
||||||
|
func (p *proxy) getInflightRequests() int32 {
|
||||||
|
return p.inflightRequests.Load()
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,20 +14,23 @@ const (
|
|||||||
Running
|
Running
|
||||||
Failed
|
Failed
|
||||||
Restarting
|
Restarting
|
||||||
|
ShuttingDown
|
||||||
)
|
)
|
||||||
|
|
||||||
var nameToStatus = map[string]Status{
|
var nameToStatus = map[string]Status{
|
||||||
"stopped": Stopped,
|
"stopped": Stopped,
|
||||||
"running": Running,
|
"running": Running,
|
||||||
"failed": Failed,
|
"failed": Failed,
|
||||||
"restarting": Restarting,
|
"restarting": Restarting,
|
||||||
|
"shutting_down": ShuttingDown,
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusToName = map[Status]string{
|
var statusToName = map[Status]string{
|
||||||
Stopped: "stopped",
|
Stopped: "stopped",
|
||||||
Running: "running",
|
Running: "running",
|
||||||
Failed: "failed",
|
Failed: "failed",
|
||||||
Restarting: "restarting",
|
Restarting: "restarting",
|
||||||
|
ShuttingDown: "shutting_down",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status enum JSON marshaling methods
|
// Status enum JSON marshaling methods
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestInstanceTimeoutLogic(t *testing.T) {
|
func TestInstanceTimeoutLogic(t *testing.T) {
|
||||||
testManager := createTestManager()
|
testManager := createTestManager(t)
|
||||||
defer testManager.Shutdown()
|
defer testManager.Shutdown()
|
||||||
|
|
||||||
idleTimeout := 1 // 1 minute
|
idleTimeout := 1 // 1 minute
|
||||||
@@ -42,7 +42,7 @@ func TestInstanceTimeoutLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
|
func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
|
||||||
testManager := createTestManager()
|
testManager := createTestManager(t)
|
||||||
defer testManager.Shutdown()
|
defer testManager.Shutdown()
|
||||||
|
|
||||||
noTimeoutInst := createInstanceWithTimeout(t, testManager, "no-timeout-test", "/path/to/model.gguf", nil)
|
noTimeoutInst := createInstanceWithTimeout(t, testManager, "no-timeout-test", "/path/to/model.gguf", nil)
|
||||||
@@ -64,7 +64,7 @@ func TestInstanceWithoutTimeoutNeverExpires(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictLRUInstance_Success(t *testing.T) {
|
func TestEvictLRUInstance_Success(t *testing.T) {
|
||||||
manager := createTestManager()
|
manager := createTestManager(t)
|
||||||
defer manager.Shutdown()
|
defer manager.Shutdown()
|
||||||
|
|
||||||
// Create 3 instances with idle timeout enabled (value doesn't matter for LRU logic)
|
// Create 3 instances with idle timeout enabled (value doesn't matter for LRU logic)
|
||||||
@@ -121,7 +121,7 @@ func TestEvictLRUInstance_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
|
func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
|
||||||
manager := createTestManager()
|
manager := createTestManager(t)
|
||||||
defer manager.Shutdown()
|
defer manager.Shutdown()
|
||||||
|
|
||||||
err := manager.EvictLRUInstance()
|
err := manager.EvictLRUInstance()
|
||||||
@@ -134,7 +134,7 @@ func TestEvictLRUInstance_NoRunningInstances(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictLRUInstance_OnlyEvictsTimeoutEnabledInstances(t *testing.T) {
|
func TestEvictLRUInstance_OnlyEvictsTimeoutEnabledInstances(t *testing.T) {
|
||||||
manager := createTestManager()
|
manager := createTestManager(t)
|
||||||
defer manager.Shutdown()
|
defer manager.Shutdown()
|
||||||
|
|
||||||
// Create mix of instances: some with timeout enabled, some disabled
|
// Create mix of instances: some with timeout enabled, some disabled
|
||||||
|
|||||||
@@ -54,16 +54,10 @@ func New(globalConfig *config.AppConfig) InstanceManager {
|
|||||||
|
|
||||||
// Initialize port allocator
|
// Initialize port allocator
|
||||||
portRange := globalConfig.Instances.PortRange
|
portRange := globalConfig.Instances.PortRange
|
||||||
ports, err := newPortAllocator(portRange[0], portRange[1])
|
ports := newPortAllocator(portRange[0], portRange[1])
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to create port allocator: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize persistence
|
// Initialize persistence
|
||||||
persistence, err := newInstancePersister(globalConfig.Instances.InstancesDir)
|
persistence := newInstancePersister(globalConfig.Instances.InstancesDir)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to create instance persister: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize remote manager
|
// Initialize remote manager
|
||||||
remote := newRemoteManager(globalConfig.Nodes, 30*time.Second)
|
remote := newRemoteManager(globalConfig.Nodes, 30*time.Second)
|
||||||
@@ -116,7 +110,7 @@ func (im *instanceManager) Shutdown() {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Printf("Stopping instance %s...\n", inst.Name)
|
fmt.Printf("Stopping instance %s...\n", inst.Name)
|
||||||
if err := inst.Stop(); err != nil {
|
if err := inst.Stop(); err != nil {
|
||||||
fmt.Printf("Error stopping instance %s: %v\n", inst.Name, err)
|
log.Printf("Error stopping instance %s: %v\n", inst.Name, err)
|
||||||
}
|
}
|
||||||
}(inst)
|
}(inst)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func TestDeleteInstance_RemovesPersistenceFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConcurrentAccess(t *testing.T) {
|
func TestConcurrentAccess(t *testing.T) {
|
||||||
mgr := createTestManager()
|
mgr := createTestManager(t)
|
||||||
defer mgr.Shutdown()
|
defer mgr.Shutdown()
|
||||||
|
|
||||||
// Test concurrent operations
|
// Test concurrent operations
|
||||||
@@ -113,7 +113,7 @@ func TestConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Concurrent list operations
|
// Concurrent list operations
|
||||||
for i := 0; i < 3; i++ {
|
for range 3 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -134,16 +134,17 @@ func TestConcurrentAccess(t *testing.T) {
|
|||||||
|
|
||||||
// Helper functions for test configuration
|
// Helper functions for test configuration
|
||||||
func createTestAppConfig(instancesDir string) *config.AppConfig {
|
func createTestAppConfig(instancesDir string) *config.AppConfig {
|
||||||
// Use 'sleep' as a test command instead of 'llama-server'
|
// Use 'sh -c "sleep 999999"' as a test command instead of 'llama-server'
|
||||||
// This allows tests to run in CI environments without requiring actual LLM binaries
|
// The shell ignores all additional arguments passed after the command
|
||||||
// The sleep command will be invoked with model paths and other args, which it ignores
|
|
||||||
return &config.AppConfig{
|
return &config.AppConfig{
|
||||||
Backends: config.BackendConfig{
|
Backends: config.BackendConfig{
|
||||||
LlamaCpp: config.BackendSettings{
|
LlamaCpp: config.BackendSettings{
|
||||||
Command: "sleep",
|
Command: "sh",
|
||||||
|
Args: []string{"-c", "sleep 999999"},
|
||||||
},
|
},
|
||||||
MLX: config.BackendSettings{
|
MLX: config.BackendSettings{
|
||||||
Command: "sleep",
|
Command: "sh",
|
||||||
|
Args: []string{"-c", "sleep 999999"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Instances: config.InstancesConfig{
|
Instances: config.InstancesConfig{
|
||||||
@@ -162,28 +163,8 @@ func createTestAppConfig(instancesDir string) *config.AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTestManager() manager.InstanceManager {
|
func createTestManager(t *testing.T) manager.InstanceManager {
|
||||||
appConfig := &config.AppConfig{
|
tempDir := t.TempDir()
|
||||||
Backends: config.BackendConfig{
|
appConfig := createTestAppConfig(tempDir)
|
||||||
LlamaCpp: config.BackendSettings{
|
|
||||||
Command: "sleep",
|
|
||||||
},
|
|
||||||
MLX: config.BackendSettings{
|
|
||||||
Command: "sleep",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Instances: config.InstancesConfig{
|
|
||||||
PortRange: [2]int{8000, 9000},
|
|
||||||
LogsDir: "/tmp/test",
|
|
||||||
MaxInstances: 10,
|
|
||||||
MaxRunningInstances: 10,
|
|
||||||
DefaultAutoRestart: true,
|
|
||||||
DefaultMaxRestarts: 3,
|
|
||||||
DefaultRestartDelay: 5,
|
|
||||||
TimeoutCheckInterval: 5,
|
|
||||||
},
|
|
||||||
LocalNode: "main",
|
|
||||||
Nodes: map[string]config.NodeConfig{},
|
|
||||||
}
|
|
||||||
return manager.New(appConfig)
|
return manager.New(appConfig)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ func (im *instanceManager) DeleteInstance(name string) error {
|
|||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer im.unlockAndCleanup(name)
|
defer im.unlockAndCleanup(name)
|
||||||
|
|
||||||
if inst.IsRunning() {
|
status := inst.GetStatus()
|
||||||
|
if status == instance.Running || status == instance.Restarting {
|
||||||
return fmt.Errorf("instance with name %s is still running, stop it before deleting", name)
|
return fmt.Errorf("instance with name %s is still running, stop it before deleting", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
||||||
mngr := createTestManager()
|
mngr := createTestManager(t)
|
||||||
options := &instance.Options{
|
options := &instance.Options{
|
||||||
BackendOptions: backends.Options{
|
BackendOptions: backends.Options{
|
||||||
BackendType: backends.BackendTypeLlamaCpp,
|
BackendType: backends.BackendTypeLlamaCpp,
|
||||||
@@ -36,6 +36,7 @@ func TestCreateInstance_FailsWithDuplicateName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
appConfig := &config.AppConfig{
|
appConfig := &config.AppConfig{
|
||||||
Backends: config.BackendConfig{
|
Backends: config.BackendConfig{
|
||||||
LlamaCpp: config.BackendSettings{
|
LlamaCpp: config.BackendSettings{
|
||||||
@@ -44,6 +45,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Instances: config.InstancesConfig{
|
Instances: config.InstancesConfig{
|
||||||
PortRange: [2]int{8000, 9000},
|
PortRange: [2]int{8000, 9000},
|
||||||
|
InstancesDir: tempDir,
|
||||||
MaxInstances: 1, // Very low limit for testing
|
MaxInstances: 1, // Very low limit for testing
|
||||||
TimeoutCheckInterval: 5,
|
TimeoutCheckInterval: 5,
|
||||||
},
|
},
|
||||||
@@ -77,7 +79,7 @@ func TestCreateInstance_FailsWhenMaxInstancesReached(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
|
func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
|
||||||
manager := createTestManager()
|
manager := createTestManager(t)
|
||||||
|
|
||||||
options1 := &instance.Options{
|
options1 := &instance.Options{
|
||||||
BackendOptions: backends.Options{
|
BackendOptions: backends.Options{
|
||||||
@@ -115,7 +117,7 @@ func TestCreateInstance_FailsWithPortConflict(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
|
func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
|
||||||
manager := createTestManager()
|
manager := createTestManager(t)
|
||||||
|
|
||||||
options := &instance.Options{
|
options := &instance.Options{
|
||||||
BackendOptions: backends.Options{
|
BackendOptions: backends.Options{
|
||||||
@@ -143,7 +145,7 @@ func TestInstanceOperations_FailWithNonExistentInstance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
||||||
mgr := createTestManager()
|
mgr := createTestManager(t)
|
||||||
defer mgr.Shutdown()
|
defer mgr.Shutdown()
|
||||||
|
|
||||||
options := &instance.Options{
|
options := &instance.Options{
|
||||||
@@ -155,15 +157,13 @@ func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := mgr.CreateInstance("test-instance", options)
|
inst, err := mgr.CreateInstance("test-instance", options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateInstance failed: %v", err)
|
t.Fatalf("CreateInstance failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = mgr.StartInstance("test-instance")
|
// Simulate starting the instance
|
||||||
if err != nil {
|
inst.SetStatus(instance.Running)
|
||||||
t.Fatalf("StartInstance failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should fail to delete running instance
|
// Should fail to delete running instance
|
||||||
err = mgr.DeleteInstance("test-instance")
|
err = mgr.DeleteInstance("test-instance")
|
||||||
@@ -173,7 +173,7 @@ func TestDeleteInstance_RunningInstanceFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateInstance(t *testing.T) {
|
func TestUpdateInstance(t *testing.T) {
|
||||||
mgr := createTestManager()
|
mgr := createTestManager(t)
|
||||||
defer mgr.Shutdown()
|
defer mgr.Shutdown()
|
||||||
|
|
||||||
options := &instance.Options{
|
options := &instance.Options{
|
||||||
@@ -186,14 +186,14 @@ func TestUpdateInstance(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := mgr.CreateInstance("test-instance", options)
|
inst, err := mgr.CreateInstance("test-instance", options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateInstance failed: %v", err)
|
t.Fatalf("CreateInstance failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = mgr.StartInstance("test-instance")
|
// Start the instance (will use 'yes' command from test config)
|
||||||
if err != nil {
|
if err := inst.Start(); err != nil {
|
||||||
t.Fatalf("StartInstance failed: %v", err)
|
t.Fatalf("Failed to start instance: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update running instance with new model
|
// Update running instance with new model
|
||||||
@@ -212,9 +212,9 @@ func TestUpdateInstance(t *testing.T) {
|
|||||||
t.Fatalf("UpdateInstance failed: %v", err)
|
t.Fatalf("UpdateInstance failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should still be running after update
|
// Should be running after update (was running before, should be restarted)
|
||||||
if !updated.IsRunning() {
|
if !updated.IsRunning() {
|
||||||
t.Error("Instance should be running after update")
|
t.Errorf("Instance should be running after update, got: %v", updated.GetStatus())
|
||||||
}
|
}
|
||||||
|
|
||||||
if updated.GetOptions().BackendOptions.LlamaServerOptions.Model != "/path/to/new-model.gguf" {
|
if updated.GetOptions().BackendOptions.LlamaServerOptions.Model != "/path/to/new-model.gguf" {
|
||||||
@@ -223,7 +223,7 @@ func TestUpdateInstance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateInstance_ReleasesOldPort(t *testing.T) {
|
func TestUpdateInstance_ReleasesOldPort(t *testing.T) {
|
||||||
mgr := createTestManager()
|
mgr := createTestManager(t)
|
||||||
defer mgr.Shutdown()
|
defer mgr.Shutdown()
|
||||||
|
|
||||||
options := &instance.Options{
|
options := &instance.Options{
|
||||||
|
|||||||
@@ -15,35 +15,18 @@ import (
|
|||||||
type instancePersister struct {
|
type instancePersister struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
instancesDir string
|
instancesDir string
|
||||||
enabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newInstancePersister creates a new instance persister.
|
// newInstancePersister creates a new instance persister.
|
||||||
// If instancesDir is empty, persistence is disabled.
|
// If instancesDir is empty, persistence is disabled.
|
||||||
func newInstancePersister(instancesDir string) (*instancePersister, error) {
|
func newInstancePersister(instancesDir string) *instancePersister {
|
||||||
if instancesDir == "" {
|
|
||||||
return &instancePersister{
|
|
||||||
enabled: false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the instances directory exists
|
|
||||||
if err := os.MkdirAll(instancesDir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create instances directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &instancePersister{
|
return &instancePersister{
|
||||||
instancesDir: instancesDir,
|
instancesDir: instancesDir,
|
||||||
enabled: true,
|
}
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save persists an instance to disk with atomic write
|
// Save persists an instance to disk with atomic write
|
||||||
func (p *instancePersister) save(inst *instance.Instance) error {
|
func (p *instancePersister) save(inst *instance.Instance) error {
|
||||||
if !p.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if inst == nil {
|
if inst == nil {
|
||||||
return fmt.Errorf("cannot save nil instance")
|
return fmt.Errorf("cannot save nil instance")
|
||||||
}
|
}
|
||||||
@@ -103,10 +86,6 @@ func (p *instancePersister) save(inst *instance.Instance) error {
|
|||||||
|
|
||||||
// Delete removes an instance's persistence file from disk.
|
// Delete removes an instance's persistence file from disk.
|
||||||
func (p *instancePersister) delete(name string) error {
|
func (p *instancePersister) delete(name string) error {
|
||||||
if !p.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
validatedName, err := p.validateInstanceName(name)
|
validatedName, err := p.validateInstanceName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -131,10 +110,6 @@ func (p *instancePersister) delete(name string) error {
|
|||||||
// LoadAll loads all persisted instances from disk.
|
// LoadAll loads all persisted instances from disk.
|
||||||
// Returns a slice of instances and any errors encountered during loading.
|
// Returns a slice of instances and any errors encountered during loading.
|
||||||
func (p *instancePersister) loadAll() ([]*instance.Instance, error) {
|
func (p *instancePersister) loadAll() ([]*instance.Instance, error) {
|
||||||
if !p.enabled {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,7 @@ type portAllocator struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newPortAllocator creates a new port allocator for the given port range.
|
// newPortAllocator creates a new port allocator for the given port range.
|
||||||
// Returns an error if the port range is invalid.
|
func newPortAllocator(minPort, maxPort int) *portAllocator {
|
||||||
func newPortAllocator(minPort, maxPort int) (*portAllocator, error) {
|
|
||||||
if minPort <= 0 || maxPort <= 0 {
|
|
||||||
return nil, fmt.Errorf("invalid port range: min=%d, max=%d (must be > 0)", minPort, maxPort)
|
|
||||||
}
|
|
||||||
if minPort > maxPort {
|
|
||||||
return nil, fmt.Errorf("invalid port range: min=%d > max=%d", minPort, maxPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
rangeSize := maxPort - minPort + 1
|
rangeSize := maxPort - minPort + 1
|
||||||
bitmapSize := (rangeSize + 63) / 64 // Round up to nearest uint64
|
bitmapSize := (rangeSize + 63) / 64 // Round up to nearest uint64
|
||||||
|
|
||||||
@@ -42,7 +34,7 @@ func newPortAllocator(minPort, maxPort int) (*portAllocator, error) {
|
|||||||
minPort: minPort,
|
minPort: minPort,
|
||||||
maxPort: maxPort,
|
maxPort: maxPort,
|
||||||
rangeSize: rangeSize,
|
rangeSize: rangeSize,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allocate finds and allocates the first available port for the given instance.
|
// allocate finds and allocates the first available port for the given instance.
|
||||||
|
|||||||
@@ -66,17 +66,16 @@ func (h *Handler) LlamaCppUIProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy, err := inst.GetProxy()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to get proxy", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inst.IsRemote() {
|
if !inst.IsRemote() {
|
||||||
h.stripLlamaCppPrefix(r, inst.Name)
|
h.stripLlamaCppPrefix(r, inst.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy.ServeHTTP(w, r)
|
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||||
|
err = inst.ServeHTTP(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// Error is already handled in ServeHTTP (response written)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +109,12 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if instance is shutting down before autostart logic
|
||||||
|
if inst.GetStatus() == instance.ShuttingDown {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !inst.IsRemote() && !inst.IsRunning() {
|
if !inst.IsRemote() && !inst.IsRunning() {
|
||||||
err := h.ensureInstanceRunning(inst)
|
err := h.ensureInstanceRunning(inst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,17 +123,16 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy, err := inst.GetProxy()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to get proxy", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inst.IsRemote() {
|
if !inst.IsRemote() {
|
||||||
h.stripLlamaCppPrefix(r, inst.Name)
|
h.stripLlamaCppPrefix(r, inst.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy.ServeHTTP(w, r)
|
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||||
|
err = inst.ServeHTTP(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// Error is already handled in ServeHTTP (response written)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,12 +332,6 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy, err := inst.GetProxy()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "proxy_failed", "Failed to get proxy: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inst.IsRemote() {
|
if !inst.IsRemote() {
|
||||||
// Strip the "/api/v1/instances/<name>/proxy" prefix from the request URL
|
// Strip the "/api/v1/instances/<name>/proxy" prefix from the request URL
|
||||||
prefix := fmt.Sprintf("/api/v1/instances/%s/proxy", inst.Name)
|
prefix := fmt.Sprintf("/api/v1/instances/%s/proxy", inst.Name)
|
||||||
@@ -348,6 +342,11 @@ func (h *Handler) InstanceProxy() http.HandlerFunc {
|
|||||||
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
|
r.Header.Set("X-Forwarded-Host", r.Header.Get("Host"))
|
||||||
r.Header.Set("X-Forwarded-Proto", "http")
|
r.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
proxy.ServeHTTP(w, r)
|
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||||
|
err = inst.ServeHTTP(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// Error is already handled in ServeHTTP (response written)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"llamactl/pkg/instance"
|
||||||
"llamactl/pkg/validation"
|
"llamactl/pkg/validation"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -106,6 +107,12 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if instance is shutting down before autostart logic
|
||||||
|
if inst.GetStatus() == instance.ShuttingDown {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !inst.IsRemote() && !inst.IsRunning() {
|
if !inst.IsRemote() && !inst.IsRunning() {
|
||||||
err := h.ensureInstanceRunning(inst)
|
err := h.ensureInstanceRunning(inst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,16 +121,15 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy, err := inst.GetProxy()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "proxy_failed", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate the request body from the bytes we read
|
// Recreate the request body from the bytes we read
|
||||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
r.ContentLength = int64(len(bodyBytes))
|
r.ContentLength = int64(len(bodyBytes))
|
||||||
|
|
||||||
proxy.ServeHTTP(w, r)
|
// Use instance's ServeHTTP which tracks inflight requests and handles shutting down state
|
||||||
|
err = inst.ServeHTTP(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// Error is already handled in ServeHTTP (response written)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -159,7 +159,7 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
|||||||
|
|
||||||
// Serve WebUI files
|
// Serve WebUI files
|
||||||
if err := webui.SetupWebUI(r); err != nil {
|
if err := webui.SetupWebUI(r); err != nil {
|
||||||
fmt.Printf("Failed to set up WebUI: %v\n", err)
|
log.Printf("Failed to set up WebUI: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
|||||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||||
case "restarting":
|
case "restarting":
|
||||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||||
|
case "shutting_down":
|
||||||
|
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||||
case "stopped":
|
case "stopped":
|
||||||
return <Clock className="h-3 w-3" />;
|
return <Clock className="h-3 w-3" />;
|
||||||
case "failed":
|
case "failed":
|
||||||
@@ -36,6 +38,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
|||||||
return "outline";
|
return "outline";
|
||||||
case "restarting":
|
case "restarting":
|
||||||
return "outline";
|
return "outline";
|
||||||
|
case "shutting_down":
|
||||||
|
return "outline";
|
||||||
case "stopped":
|
case "stopped":
|
||||||
return "secondary";
|
return "secondary";
|
||||||
case "failed":
|
case "failed":
|
||||||
@@ -51,6 +55,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
|||||||
return "Starting";
|
return "Starting";
|
||||||
case "restarting":
|
case "restarting":
|
||||||
return "Restarting";
|
return "Restarting";
|
||||||
|
case "shutting_down":
|
||||||
|
return "Shutting Down";
|
||||||
case "stopped":
|
case "stopped":
|
||||||
return "Stopped";
|
return "Stopped";
|
||||||
case "failed":
|
case "failed":
|
||||||
|
|||||||
@@ -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,13 +57,13 @@ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,17 +71,17 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
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);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function useInstanceHealth(instanceName: string, instanceStatus: Instance
|
|||||||
|
|
||||||
// Trigger health check when instance status changes to active states
|
// Trigger health check when instance status changes to active states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (instanceStatus === 'running' || instanceStatus === 'restarting') {
|
if (instanceStatus === 'running' || instanceStatus === 'restarting' || instanceStatus === 'shutting_down') {
|
||||||
healthService.refreshHealth(instanceName).catch(error => {
|
healthService.refreshHealth(instanceName).catch(error => {
|
||||||
console.error(`Failed to refresh health for ${instanceName}:`, error)
|
console.error(`Failed to refresh health for ${instanceName}:`, error)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ type HealthCallback = (health: HealthStatus) => void
|
|||||||
|
|
||||||
// Polling intervals based on health state (in milliseconds)
|
// Polling intervals based on health state (in milliseconds)
|
||||||
const POLLING_INTERVALS: Record<HealthState, number> = {
|
const POLLING_INTERVALS: Record<HealthState, number> = {
|
||||||
'starting': 5000, // 5 seconds - frequent during startup
|
'starting': 5000, // 5 seconds - frequent during startup
|
||||||
'restarting': 5000, // 5 seconds - restart in progress
|
'restarting': 5000, // 5 seconds - restart in progress
|
||||||
'ready': 60000, // 60 seconds - stable state
|
'shutting_down': 3000, // 3 seconds - monitor shutdown progress
|
||||||
'stopped': 0, // No polling
|
'ready': 60000, // 60 seconds - stable state
|
||||||
'failed': 0, // No polling
|
'stopped': 0, // No polling
|
||||||
|
'failed': 0, // No polling
|
||||||
}
|
}
|
||||||
|
|
||||||
class HealthService {
|
class HealthService {
|
||||||
@@ -96,6 +97,7 @@ class HealthService {
|
|||||||
case 'running': return 'starting' // Should not happen as we check HTTP for running
|
case 'running': return 'starting' // Should not happen as we check HTTP for running
|
||||||
case 'failed': return 'failed'
|
case 'failed': return 'failed'
|
||||||
case 'restarting': return 'restarting'
|
case 'restarting': return 'restarting'
|
||||||
|
case 'shutting_down': return 'shutting_down'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ export const BackendType = {
|
|||||||
|
|
||||||
export type BackendTypeValue = typeof BackendType[keyof typeof BackendType]
|
export type BackendTypeValue = typeof BackendType[keyof typeof BackendType]
|
||||||
|
|
||||||
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting'
|
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting' | 'shutting_down'
|
||||||
|
|
||||||
export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting'
|
export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting' | 'shutting_down'
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
state: HealthState
|
state: HealthState
|
||||||
|
|||||||
Reference in New Issue
Block a user