mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 09:04:27 +00:00
Merge pull request #24 from lordmathis/feat/max-running-instances
feat: Implement max running instances constraint and refactor instances status
This commit is contained in:
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Server",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}/cmd/server/main.go",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ type InstancesConfig struct {
|
|||||||
// Maximum number of instances that can be created
|
// Maximum number of instances that can be created
|
||||||
MaxInstances int `yaml:"max_instances"`
|
MaxInstances int `yaml:"max_instances"`
|
||||||
|
|
||||||
|
// Maximum number of instances that can be running at the same time
|
||||||
|
MaxRunningInstances int `yaml:"max_running_instances,omitempty"`
|
||||||
|
|
||||||
// Path to llama-server executable
|
// Path to llama-server executable
|
||||||
LlamaExecutable string `yaml:"llama_executable"`
|
LlamaExecutable string `yaml:"llama_executable"`
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ func LoadConfig(configPath string) (AppConfig, error) {
|
|||||||
LogsDir: filepath.Join(getDefaultDataDirectory(), "logs"),
|
LogsDir: filepath.Join(getDefaultDataDirectory(), "logs"),
|
||||||
AutoCreateDirs: true,
|
AutoCreateDirs: true,
|
||||||
MaxInstances: -1, // -1 means unlimited
|
MaxInstances: -1, // -1 means unlimited
|
||||||
|
MaxRunningInstances: -1, // -1 means unlimited
|
||||||
LlamaExecutable: "llama-server",
|
LlamaExecutable: "llama-server",
|
||||||
DefaultAutoRestart: true,
|
DefaultAutoRestart: true,
|
||||||
DefaultMaxRestarts: 3,
|
DefaultMaxRestarts: 3,
|
||||||
@@ -211,6 +215,11 @@ func loadEnvVars(cfg *AppConfig) {
|
|||||||
cfg.Instances.MaxInstances = m
|
cfg.Instances.MaxInstances = m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if maxRunning := os.Getenv("LLAMACTL_MAX_RUNNING_INSTANCES"); maxRunning != "" {
|
||||||
|
if m, err := strconv.Atoi(maxRunning); err == nil {
|
||||||
|
cfg.Instances.MaxRunningInstances = m
|
||||||
|
}
|
||||||
|
}
|
||||||
if llamaExec := os.Getenv("LLAMACTL_LLAMA_EXECUTABLE"); llamaExec != "" {
|
if llamaExec := os.Getenv("LLAMACTL_LLAMA_EXECUTABLE"); llamaExec != "" {
|
||||||
cfg.Instances.LlamaExecutable = llamaExec
|
cfg.Instances.LlamaExecutable = llamaExec
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ type Process struct {
|
|||||||
globalSettings *config.InstancesConfig
|
globalSettings *config.InstancesConfig
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
Running bool `json:"running"`
|
Status InstanceStatus `json:"status"`
|
||||||
|
onStatusChange func(oldStatus, newStatus InstanceStatus)
|
||||||
|
|
||||||
// Creation time
|
// Creation time
|
||||||
Created int64 `json:"created,omitempty"` // Unix timestamp when the instance was created
|
Created int64 `json:"created,omitempty"` // Unix timestamp when the instance was created
|
||||||
@@ -193,7 +194,7 @@ func applyDefaultOptions(options *CreateInstanceOptions, globalSettings *config.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewInstance creates a new instance with the given name, log path, and options
|
// NewInstance creates a new instance with the given name, log path, and options
|
||||||
func NewInstance(name string, globalSettings *config.InstancesConfig, options *CreateInstanceOptions) *Process {
|
func NewInstance(name string, globalSettings *config.InstancesConfig, options *CreateInstanceOptions, onStatusChange func(oldStatus, newStatus InstanceStatus)) *Process {
|
||||||
// Validate and copy options
|
// Validate and copy options
|
||||||
optionsCopy := validateAndCopyOptions(name, options)
|
optionsCopy := validateAndCopyOptions(name, options)
|
||||||
// Apply defaults
|
// Apply defaults
|
||||||
@@ -208,6 +209,8 @@ func NewInstance(name string, globalSettings *config.InstancesConfig, options *C
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
timeProvider: realTimeProvider{},
|
timeProvider: realTimeProvider{},
|
||||||
Created: time.Now().Unix(),
|
Created: time.Now().Unix(),
|
||||||
|
Status: Stopped,
|
||||||
|
onStatusChange: onStatusChange,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,12 +290,12 @@ func (i *Process) MarshalJSON() ([]byte, error) {
|
|||||||
temp := struct {
|
temp := struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Options *CreateInstanceOptions `json:"options,omitempty"`
|
Options *CreateInstanceOptions `json:"options,omitempty"`
|
||||||
Running bool `json:"running"`
|
Status InstanceStatus `json:"status"`
|
||||||
Created int64 `json:"created,omitempty"`
|
Created int64 `json:"created,omitempty"`
|
||||||
}{
|
}{
|
||||||
Name: i.Name,
|
Name: i.Name,
|
||||||
Options: i.options,
|
Options: i.options,
|
||||||
Running: i.Running,
|
Status: i.Status,
|
||||||
Created: i.Created,
|
Created: i.Created,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +308,7 @@ func (i *Process) UnmarshalJSON(data []byte) error {
|
|||||||
temp := struct {
|
temp := struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Options *CreateInstanceOptions `json:"options,omitempty"`
|
Options *CreateInstanceOptions `json:"options,omitempty"`
|
||||||
Running bool `json:"running"`
|
Status InstanceStatus `json:"status"`
|
||||||
Created int64 `json:"created,omitempty"`
|
Created int64 `json:"created,omitempty"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
@@ -315,7 +318,7 @@ func (i *Process) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// Set the fields
|
// Set the fields
|
||||||
i.Name = temp.Name
|
i.Name = temp.Name
|
||||||
i.Running = temp.Running
|
i.Status = temp.Status
|
||||||
i.Created = temp.Created
|
i.Created = temp.Created
|
||||||
|
|
||||||
// Handle options with validation but no defaults
|
// Handle options with validation but no defaults
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ func TestNewInstance(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
instance := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
|
||||||
if instance.Name != "test-instance" {
|
if instance.Name != "test-instance" {
|
||||||
t.Errorf("Expected name 'test-instance', got %q", instance.Name)
|
t.Errorf("Expected name 'test-instance', got %q", instance.Name)
|
||||||
}
|
}
|
||||||
if instance.Running {
|
if instance.IsRunning() {
|
||||||
t.Error("New instance should not be running")
|
t.Error("New instance should not be running")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +79,10 @@ func TestNewInstance_WithRestartOptions(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
instance := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
opts := instance.GetOptions()
|
opts := instance.GetOptions()
|
||||||
|
|
||||||
// Check that explicit values override defaults
|
// Check that explicit values override defaults
|
||||||
@@ -106,7 +112,10 @@ func TestSetOptions(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, initialOptions)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, initialOptions, mockOnStatusChange)
|
||||||
|
|
||||||
// Update options
|
// Update options
|
||||||
newOptions := &instance.CreateInstanceOptions{
|
newOptions := &instance.CreateInstanceOptions{
|
||||||
@@ -144,7 +153,10 @@ func TestGetProxy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
|
||||||
// Get proxy for the first time
|
// Get proxy for the first time
|
||||||
proxy1, err := inst.GetProxy()
|
proxy1, err := inst.GetProxy()
|
||||||
@@ -180,7 +192,10 @@ func TestMarshalJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
instance := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
|
||||||
data, err := json.Marshal(instance)
|
data, err := json.Marshal(instance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,7 +203,7 @@ func TestMarshalJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check that JSON contains expected fields
|
// Check that JSON contains expected fields
|
||||||
var result map[string]interface{}
|
var result map[string]any
|
||||||
err = json.Unmarshal(data, &result)
|
err = json.Unmarshal(data, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||||
@@ -197,8 +212,8 @@ func TestMarshalJSON(t *testing.T) {
|
|||||||
if result["name"] != "test-instance" {
|
if result["name"] != "test-instance" {
|
||||||
t.Errorf("Expected name 'test-instance', got %v", result["name"])
|
t.Errorf("Expected name 'test-instance', got %v", result["name"])
|
||||||
}
|
}
|
||||||
if result["running"] != false {
|
if result["status"] != "stopped" {
|
||||||
t.Errorf("Expected running false, got %v", result["running"])
|
t.Errorf("Expected status 'stopped', got %v", result["status"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that options are included
|
// Check that options are included
|
||||||
@@ -218,7 +233,7 @@ func TestMarshalJSON(t *testing.T) {
|
|||||||
func TestUnmarshalJSON(t *testing.T) {
|
func TestUnmarshalJSON(t *testing.T) {
|
||||||
jsonData := `{
|
jsonData := `{
|
||||||
"name": "test-instance",
|
"name": "test-instance",
|
||||||
"running": true,
|
"status": "running",
|
||||||
"options": {
|
"options": {
|
||||||
"model": "/path/to/model.gguf",
|
"model": "/path/to/model.gguf",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
@@ -236,8 +251,8 @@ func TestUnmarshalJSON(t *testing.T) {
|
|||||||
if inst.Name != "test-instance" {
|
if inst.Name != "test-instance" {
|
||||||
t.Errorf("Expected name 'test-instance', got %q", inst.Name)
|
t.Errorf("Expected name 'test-instance', got %q", inst.Name)
|
||||||
}
|
}
|
||||||
if !inst.Running {
|
if !inst.IsRunning() {
|
||||||
t.Error("Expected running to be true")
|
t.Error("Expected status to be running")
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := inst.GetOptions()
|
opts := inst.GetOptions()
|
||||||
@@ -303,7 +318,10 @@ func TestCreateInstanceOptionsValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
instance := instance.NewInstance("test", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
instance := instance.NewInstance("test", globalSettings, options, mockOnStatusChange)
|
||||||
opts := instance.GetOptions()
|
opts := instance.GetOptions()
|
||||||
|
|
||||||
if opts.MaxRestarts == nil {
|
if opts.MaxRestarts == nil {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func (i *Process) Start() error {
|
|||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
defer i.mu.Unlock()
|
defer i.mu.Unlock()
|
||||||
|
|
||||||
if i.Running {
|
if i.IsRunning() {
|
||||||
return fmt.Errorf("instance %s is already running", i.Name)
|
return fmt.Errorf("instance %s is already running", i.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ func (i *Process) Start() error {
|
|||||||
return fmt.Errorf("failed to start instance %s: %w", i.Name, err)
|
return fmt.Errorf("failed to start instance %s: %w", i.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
i.Running = true
|
i.SetStatus(Running)
|
||||||
|
|
||||||
// Create channel for monitor completion signaling
|
// Create channel for monitor completion signaling
|
||||||
i.monitorDone = make(chan struct{})
|
i.monitorDone = make(chan struct{})
|
||||||
@@ -82,7 +82,7 @@ func (i *Process) Start() error {
|
|||||||
func (i *Process) Stop() error {
|
func (i *Process) Stop() error {
|
||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
|
|
||||||
if !i.Running {
|
if !i.IsRunning() {
|
||||||
// Even if not running, cancel any pending restart
|
// Even if not running, cancel any pending restart
|
||||||
if i.restartCancel != nil {
|
if i.restartCancel != nil {
|
||||||
i.restartCancel()
|
i.restartCancel()
|
||||||
@@ -99,8 +99,8 @@ func (i *Process) Stop() error {
|
|||||||
i.restartCancel = nil
|
i.restartCancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set running to false first to signal intentional stop
|
// Set status to stopped first to signal intentional stop
|
||||||
i.Running = false
|
i.SetStatus(Stopped)
|
||||||
|
|
||||||
// Clean up the proxy
|
// Clean up the proxy
|
||||||
i.proxy = nil
|
i.proxy = nil
|
||||||
@@ -145,7 +145,7 @@ func (i *Process) Stop() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Process) WaitForHealthy(timeout int) error {
|
func (i *Process) WaitForHealthy(timeout int) error {
|
||||||
if !i.Running {
|
if !i.IsRunning() {
|
||||||
return fmt.Errorf("instance %s is not running", i.Name)
|
return fmt.Errorf("instance %s is not running", i.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,12 +227,12 @@ func (i *Process) monitorProcess() {
|
|||||||
i.mu.Lock()
|
i.mu.Lock()
|
||||||
|
|
||||||
// Check if the instance was intentionally stopped
|
// Check if the instance was intentionally stopped
|
||||||
if !i.Running {
|
if !i.IsRunning() {
|
||||||
i.mu.Unlock()
|
i.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
i.Running = false
|
i.SetStatus(Stopped)
|
||||||
i.logger.Close()
|
i.logger.Close()
|
||||||
|
|
||||||
// Cancel any existing restart context since we're handling a new exit
|
// Cancel any existing restart context since we're handling a new exit
|
||||||
@@ -257,6 +257,7 @@ func (i *Process) handleRestart() {
|
|||||||
// Validate restart conditions and get safe parameters
|
// Validate restart conditions and get safe parameters
|
||||||
shouldRestart, maxRestarts, restartDelay := i.validateRestartConditions()
|
shouldRestart, maxRestarts, restartDelay := i.validateRestartConditions()
|
||||||
if !shouldRestart {
|
if !shouldRestart {
|
||||||
|
i.SetStatus(Failed)
|
||||||
i.mu.Unlock()
|
i.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
70
pkg/instance/status.go
Normal file
70
pkg/instance/status.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package instance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum for instance status
|
||||||
|
type InstanceStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Stopped InstanceStatus = iota
|
||||||
|
Running
|
||||||
|
Failed
|
||||||
|
)
|
||||||
|
|
||||||
|
var nameToStatus = map[string]InstanceStatus{
|
||||||
|
"stopped": Stopped,
|
||||||
|
"running": Running,
|
||||||
|
"failed": Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusToName = map[InstanceStatus]string{
|
||||||
|
Stopped: "stopped",
|
||||||
|
Running: "running",
|
||||||
|
Failed: "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) SetStatus(status InstanceStatus) {
|
||||||
|
oldStatus := p.Status
|
||||||
|
p.Status = status
|
||||||
|
|
||||||
|
if p.onStatusChange != nil {
|
||||||
|
p.onStatusChange(oldStatus, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) GetStatus() InstanceStatus {
|
||||||
|
return p.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if the status is Running
|
||||||
|
func (p *Process) IsRunning() bool {
|
||||||
|
return p.Status == Running
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s InstanceStatus) MarshalJSON() ([]byte, error) {
|
||||||
|
name, ok := statusToName[s]
|
||||||
|
if !ok {
|
||||||
|
name = "stopped" // Default to "stopped" for unknown status
|
||||||
|
}
|
||||||
|
return json.Marshal(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler
|
||||||
|
func (s *InstanceStatus) UnmarshalJSON(data []byte) error {
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(data, &str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, ok := nameToStatus[str]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("Unknown instance status: %s", str)
|
||||||
|
status = Stopped // Default to Stopped on unknown status
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = status
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func (i *Process) ShouldTimeout() bool {
|
|||||||
i.mu.RLock()
|
i.mu.RLock()
|
||||||
defer i.mu.RUnlock()
|
defer i.mu.RUnlock()
|
||||||
|
|
||||||
if !i.Running || i.options.IdleTimeout == nil || *i.options.IdleTimeout <= 0 {
|
if !i.IsRunning() || i.options.IdleTimeout == nil || *i.options.IdleTimeout <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ func TestUpdateLastRequestTime(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
|
||||||
// Test that UpdateLastRequestTime doesn't panic
|
// Test that UpdateLastRequestTime doesn't panic
|
||||||
inst.UpdateLastRequestTime()
|
inst.UpdateLastRequestTime()
|
||||||
@@ -61,7 +64,10 @@ func TestShouldTimeout_NotRunning(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
|
||||||
// Instance is not running, should not timeout regardless of configuration
|
// Instance is not running, should not timeout regardless of configuration
|
||||||
if inst.ShouldTimeout() {
|
if inst.ShouldTimeout() {
|
||||||
@@ -85,6 +91,9 @@ func TestShouldTimeout_NoTimeoutConfigured(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
options := &instance.CreateInstanceOptions{
|
options := &instance.CreateInstanceOptions{
|
||||||
IdleTimeout: tt.idleTimeout,
|
IdleTimeout: tt.idleTimeout,
|
||||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||||
@@ -92,9 +101,9 @@ func TestShouldTimeout_NoTimeoutConfigured(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
// Simulate running state
|
// Simulate running state
|
||||||
inst.Running = true
|
inst.SetStatus(instance.Running)
|
||||||
|
|
||||||
if inst.ShouldTimeout() {
|
if inst.ShouldTimeout() {
|
||||||
t.Errorf("Instance with %s should not timeout", tt.name)
|
t.Errorf("Instance with %s should not timeout", tt.name)
|
||||||
@@ -116,8 +125,11 @@ func TestShouldTimeout_WithinTimeLimit(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
inst.Running = true
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
inst.SetStatus(instance.Running)
|
||||||
|
|
||||||
// Update last request time to now
|
// Update last request time to now
|
||||||
inst.UpdateLastRequestTime()
|
inst.UpdateLastRequestTime()
|
||||||
@@ -141,8 +153,11 @@ func TestShouldTimeout_ExceedsTimeLimit(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
inst.Running = true
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
|
inst.SetStatus(instance.Running)
|
||||||
|
|
||||||
// Use MockTimeProvider to simulate old last request time
|
// Use MockTimeProvider to simulate old last request time
|
||||||
mockTime := NewMockTimeProvider(time.Now())
|
mockTime := NewMockTimeProvider(time.Now())
|
||||||
@@ -184,7 +199,10 @@ func TestTimeoutConfiguration_Validation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance("test-instance", globalSettings, options)
|
// Mock onStatusChange function
|
||||||
|
mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {}
|
||||||
|
|
||||||
|
inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange)
|
||||||
opts := inst.GetOptions()
|
opts := inst.GetOptions()
|
||||||
|
|
||||||
if opts.IdleTimeout == nil || *opts.IdleTimeout != tt.expectedTimeout {
|
if opts.IdleTimeout == nil || *opts.IdleTimeout != tt.expectedTimeout {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type InstanceManager interface {
|
|||||||
type instanceManager struct {
|
type instanceManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
instances map[string]*instance.Process
|
instances map[string]*instance.Process
|
||||||
|
runningInstances map[string]struct{}
|
||||||
ports map[int]bool
|
ports map[int]bool
|
||||||
instancesConfig config.InstancesConfig
|
instancesConfig config.InstancesConfig
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ func NewInstanceManager(instancesConfig config.InstancesConfig) InstanceManager
|
|||||||
}
|
}
|
||||||
im := &instanceManager{
|
im := &instanceManager{
|
||||||
instances: make(map[string]*instance.Process),
|
instances: make(map[string]*instance.Process),
|
||||||
|
runningInstances: make(map[string]struct{}),
|
||||||
ports: make(map[int]bool),
|
ports: make(map[int]bool),
|
||||||
instancesConfig: instancesConfig,
|
instancesConfig: instancesConfig,
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ func (im *instanceManager) Shutdown() {
|
|||||||
wg.Add(len(im.instances))
|
wg.Add(len(im.instances))
|
||||||
|
|
||||||
for name, inst := range im.instances {
|
for name, inst := range im.instances {
|
||||||
if !inst.Running {
|
if !inst.IsRunning() {
|
||||||
wg.Done() // If instance is not running, just mark it as done
|
wg.Done() // If instance is not running, just mark it as done
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -227,12 +229,16 @@ func (im *instanceManager) loadInstance(name, path string) error {
|
|||||||
return fmt.Errorf("instance name mismatch: file=%s, instance.Name=%s", name, persistedInstance.Name)
|
return fmt.Errorf("instance name mismatch: file=%s, instance.Name=%s", name, persistedInstance.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusCallback := func(oldStatus, newStatus instance.InstanceStatus) {
|
||||||
|
im.onStatusChange(persistedInstance.Name, oldStatus, newStatus)
|
||||||
|
}
|
||||||
|
|
||||||
// Create new inst using NewInstance (handles validation, defaults, setup)
|
// Create new inst using NewInstance (handles validation, defaults, setup)
|
||||||
inst := instance.NewInstance(name, &im.instancesConfig, persistedInstance.GetOptions())
|
inst := instance.NewInstance(name, &im.instancesConfig, persistedInstance.GetOptions(), statusCallback)
|
||||||
|
|
||||||
// Restore persisted fields that NewInstance doesn't set
|
// Restore persisted fields that NewInstance doesn't set
|
||||||
inst.Created = persistedInstance.Created
|
inst.Created = persistedInstance.Created
|
||||||
inst.Running = persistedInstance.Running
|
inst.SetStatus(persistedInstance.Status)
|
||||||
|
|
||||||
// Check for port conflicts and add to maps
|
// Check for port conflicts and add to maps
|
||||||
if inst.GetOptions() != nil && inst.GetOptions().Port > 0 {
|
if inst.GetOptions() != nil && inst.GetOptions().Port > 0 {
|
||||||
@@ -252,7 +258,7 @@ func (im *instanceManager) autoStartInstances() {
|
|||||||
im.mu.RLock()
|
im.mu.RLock()
|
||||||
var instancesToStart []*instance.Process
|
var instancesToStart []*instance.Process
|
||||||
for _, inst := range im.instances {
|
for _, inst := range im.instances {
|
||||||
if inst.Running && // Was running when persisted
|
if inst.IsRunning() && // Was running when persisted
|
||||||
inst.GetOptions() != nil &&
|
inst.GetOptions() != nil &&
|
||||||
inst.GetOptions().AutoRestart != nil &&
|
inst.GetOptions().AutoRestart != nil &&
|
||||||
*inst.GetOptions().AutoRestart {
|
*inst.GetOptions().AutoRestart {
|
||||||
@@ -264,9 +270,20 @@ func (im *instanceManager) autoStartInstances() {
|
|||||||
for _, inst := range instancesToStart {
|
for _, inst := range instancesToStart {
|
||||||
log.Printf("Auto-starting instance %s", inst.Name)
|
log.Printf("Auto-starting instance %s", inst.Name)
|
||||||
// Reset running state before starting (since Start() expects stopped instance)
|
// Reset running state before starting (since Start() expects stopped instance)
|
||||||
inst.Running = false
|
inst.SetStatus(instance.Stopped)
|
||||||
if err := inst.Start(); err != nil {
|
if err := inst.Start(); err != nil {
|
||||||
log.Printf("Failed to auto-start instance %s: %v", inst.Name, err)
|
log.Printf("Failed to auto-start instance %s: %v", inst.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (im *instanceManager) onStatusChange(name string, oldStatus, newStatus instance.InstanceStatus) {
|
||||||
|
im.mu.Lock()
|
||||||
|
defer im.mu.Unlock()
|
||||||
|
|
||||||
|
if newStatus == instance.Running {
|
||||||
|
im.runningInstances[name] = struct{}{}
|
||||||
|
} else {
|
||||||
|
delete(im.runningInstances, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestCreateInstance_Success(t *testing.T) {
|
|||||||
if inst.Name != "test-instance" {
|
if inst.Name != "test-instance" {
|
||||||
t.Errorf("Expected instance name 'test-instance', got %q", inst.Name)
|
t.Errorf("Expected instance name 'test-instance', got %q", inst.Name)
|
||||||
}
|
}
|
||||||
if inst.Running {
|
if inst.GetStatus() != instance.Stopped {
|
||||||
t.Error("New instance should not be running")
|
t.Error("New instance should not be running")
|
||||||
}
|
}
|
||||||
if inst.GetOptions().Port != 8080 {
|
if inst.GetOptions().Port != 8080 {
|
||||||
@@ -357,7 +357,7 @@ func TestTimeoutFunctionality(t *testing.T) {
|
|||||||
inst.SetTimeProvider(mockTime)
|
inst.SetTimeProvider(mockTime)
|
||||||
|
|
||||||
// Set instance to running state so timeout logic can work
|
// Set instance to running state so timeout logic can work
|
||||||
inst.Running = true
|
inst.SetStatus(instance.Running)
|
||||||
|
|
||||||
// Simulate instance being "running" for timeout check (without actual process)
|
// Simulate instance being "running" for timeout check (without actual process)
|
||||||
// We'll test the ShouldTimeout logic directly
|
// We'll test the ShouldTimeout logic directly
|
||||||
@@ -377,7 +377,7 @@ func TestTimeoutFunctionality(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset running state to avoid shutdown issues
|
// Reset running state to avoid shutdown issues
|
||||||
inst.Running = false
|
inst.SetStatus(instance.Stopped)
|
||||||
|
|
||||||
// Test that instance without timeout doesn't timeout
|
// Test that instance without timeout doesn't timeout
|
||||||
noTimeoutOptions := &instance.CreateInstanceOptions{
|
noTimeoutOptions := &instance.CreateInstanceOptions{
|
||||||
@@ -393,7 +393,7 @@ func TestTimeoutFunctionality(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
noTimeoutInst.SetTimeProvider(mockTime)
|
noTimeoutInst.SetTimeProvider(mockTime)
|
||||||
noTimeoutInst.Running = true // Set to running for timeout check
|
noTimeoutInst.SetStatus(instance.Running) // Set to running for timeout check
|
||||||
noTimeoutInst.UpdateLastRequestTime()
|
noTimeoutInst.UpdateLastRequestTime()
|
||||||
|
|
||||||
// Even with time advanced, should not timeout
|
// Even with time advanced, should not timeout
|
||||||
@@ -402,7 +402,7 @@ func TestTimeoutFunctionality(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset running state to avoid shutdown issues
|
// Reset running state to avoid shutdown issues
|
||||||
noTimeoutInst.Running = false
|
noTimeoutInst.SetStatus(instance.Stopped)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConcurrentAccess(t *testing.T) {
|
func TestConcurrentAccess(t *testing.T) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MaxRunningInstancesError error
|
||||||
|
|
||||||
// ListInstances returns a list of all instances managed by the instance manager.
|
// ListInstances returns a list of all instances managed by the instance manager.
|
||||||
func (im *instanceManager) ListInstances() ([]*instance.Process, error) {
|
func (im *instanceManager) ListInstances() ([]*instance.Process, error) {
|
||||||
im.mu.RLock()
|
im.mu.RLock()
|
||||||
@@ -65,7 +67,11 @@ func (im *instanceManager) CreateInstance(name string, options *instance.CreateI
|
|||||||
im.ports[options.Port] = true
|
im.ports[options.Port] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
inst := instance.NewInstance(name, &im.instancesConfig, options)
|
statusCallback := func(oldStatus, newStatus instance.InstanceStatus) {
|
||||||
|
im.onStatusChange(name, oldStatus, newStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
inst := instance.NewInstance(name, &im.instancesConfig, options, statusCallback)
|
||||||
im.instances[inst.Name] = inst
|
im.instances[inst.Name] = inst
|
||||||
im.ports[options.Port] = true
|
im.ports[options.Port] = true
|
||||||
|
|
||||||
@@ -109,7 +115,7 @@ func (im *instanceManager) UpdateInstance(name string, options *instance.CreateI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if instance is running before updating options
|
// Check if instance is running before updating options
|
||||||
wasRunning := instance.Running
|
wasRunning := instance.IsRunning()
|
||||||
|
|
||||||
// If the instance is running, stop it first
|
// If the instance is running, stop it first
|
||||||
if wasRunning {
|
if wasRunning {
|
||||||
@@ -147,7 +153,7 @@ func (im *instanceManager) DeleteInstance(name string) error {
|
|||||||
return fmt.Errorf("instance with name %s not found", name)
|
return fmt.Errorf("instance with name %s not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if instance.Running {
|
if instance.IsRunning() {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +179,14 @@ func (im *instanceManager) StartInstance(name string) (*instance.Process, error)
|
|||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("instance with name %s not found", name)
|
return nil, fmt.Errorf("instance with name %s not found", name)
|
||||||
}
|
}
|
||||||
if instance.Running {
|
if instance.IsRunning() {
|
||||||
return instance, fmt.Errorf("instance with name %s is already running", name)
|
return instance, fmt.Errorf("instance with name %s is already running", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(im.runningInstances) >= im.instancesConfig.MaxRunningInstances && im.instancesConfig.MaxRunningInstances != -1 {
|
||||||
|
return nil, MaxRunningInstancesError(fmt.Errorf("maximum number of running instances (%d) reached", im.instancesConfig.MaxRunningInstances))
|
||||||
|
}
|
||||||
|
|
||||||
if err := instance.Start(); err != nil {
|
if err := instance.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to start instance %s: %w", name, err)
|
return nil, fmt.Errorf("failed to start instance %s: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -200,7 +210,7 @@ func (im *instanceManager) StopInstance(name string) (*instance.Process, error)
|
|||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("instance with name %s not found", name)
|
return nil, fmt.Errorf("instance with name %s not found", name)
|
||||||
}
|
}
|
||||||
if !instance.Running {
|
if !instance.IsRunning() {
|
||||||
return instance, fmt.Errorf("instance with name %s is already stopped", name)
|
return instance, fmt.Errorf("instance with name %s is already stopped", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,12 @@ func (h *Handler) StartInstance() http.HandlerFunc {
|
|||||||
|
|
||||||
inst, err := h.InstanceManager.StartInstance(name)
|
inst, err := h.InstanceManager.StartInstance(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Check if error is due to maximum running instances limit
|
||||||
|
if _, ok := err.(manager.MaxRunningInstancesError); ok {
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
http.Error(w, "Failed to start instance: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Failed to start instance: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -451,7 +457,7 @@ func (h *Handler) ProxyToInstance() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !inst.Running {
|
if !inst.IsRunning() {
|
||||||
http.Error(w, "Instance is not running", http.StatusServiceUnavailable)
|
http.Error(w, "Instance is not running", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -574,7 +580,7 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !inst.Running {
|
if !inst.IsRunning() {
|
||||||
if inst.GetOptions().OnDemandStart != nil && *inst.GetOptions().OnDemandStart {
|
if inst.GetOptions().OnDemandStart != nil && *inst.GetOptions().OnDemandStart {
|
||||||
// If on-demand start is enabled, start the instance
|
// If on-demand start is enabled, start the instance
|
||||||
if _, err := h.InstanceManager.StartInstance(modelName); err != nil {
|
if _, err := h.InstanceManager.StartInstance(modelName); err != nil {
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ function App() {
|
|||||||
|
|
||||||
const handleSaveInstance = (name: string, options: CreateInstanceOptions) => {
|
const handleSaveInstance = (name: string, options: CreateInstanceOptions) => {
|
||||||
if (editingInstance) {
|
if (editingInstance) {
|
||||||
updateInstance(editingInstance.name, options);
|
void updateInstance(editingInstance.name, options);
|
||||||
} else {
|
} else {
|
||||||
createInstance(name, options);
|
void createInstance(name, options);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ function renderApp() {
|
|||||||
|
|
||||||
describe('App Component - Critical Business Logic Only', () => {
|
describe('App Component - Critical Business Logic Only', () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'test-instance-1', running: false, options: { model: 'model1.gguf' } },
|
{ name: 'test-instance-1', status: 'stopped', options: { model: 'model1.gguf' } },
|
||||||
{ name: 'test-instance-2', running: true, options: { model: 'model2.gguf' } }
|
{ name: 'test-instance-2', status: 'running', options: { model: 'model2.gguf' } }
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -81,7 +81,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
name: 'new-test-instance',
|
name: 'new-test-instance',
|
||||||
running: false,
|
status: 'stopped',
|
||||||
options: { model: 'new-model.gguf' }
|
options: { model: 'new-model.gguf' }
|
||||||
}
|
}
|
||||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
||||||
@@ -118,7 +118,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
name: 'test-instance-1',
|
name: 'test-instance-1',
|
||||||
running: false,
|
status: 'stopped',
|
||||||
options: { model: 'updated-model.gguf' }
|
options: { model: 'updated-model.gguf' }
|
||||||
}
|
}
|
||||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
|
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
|||||||
return <XCircle className="h-3 w-3" />;
|
return <XCircle className="h-3 w-3" />;
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||||
|
case "failed":
|
||||||
|
return <XCircle className="h-3 w-3" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,6 +42,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
|||||||
return "destructive";
|
return "destructive";
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return "secondary";
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +57,8 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
|
|||||||
return "Error";
|
return "Error";
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
|
case "failed":
|
||||||
|
return "Failed";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function InstanceCard({
|
|||||||
editInstance,
|
editInstance,
|
||||||
}: InstanceCardProps) {
|
}: InstanceCardProps) {
|
||||||
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
||||||
const health = useInstanceHealth(instance.name, instance.running);
|
const health = useInstanceHealth(instance.name, instance.status);
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
startInstance(instance.name);
|
startInstance(instance.name);
|
||||||
@@ -50,13 +50,15 @@ function InstanceCard({
|
|||||||
setIsLogsOpen(true);
|
setIsLogsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const running = instance.status === "running";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-lg">{instance.name}</CardTitle>
|
<CardTitle className="text-lg">{instance.name}</CardTitle>
|
||||||
{instance.running && <HealthBadge health={health} />}
|
{running && <HealthBadge health={health} />}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ function InstanceCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={instance.running}
|
disabled={running}
|
||||||
title="Start instance"
|
title="Start instance"
|
||||||
data-testid="start-instance-button"
|
data-testid="start-instance-button"
|
||||||
>
|
>
|
||||||
@@ -77,7 +79,7 @@ function InstanceCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!instance.running}
|
disabled={!running}
|
||||||
title="Stop instance"
|
title="Stop instance"
|
||||||
data-testid="stop-instance-button"
|
data-testid="stop-instance-button"
|
||||||
>
|
>
|
||||||
@@ -108,7 +110,7 @@ function InstanceCard({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={instance.running}
|
disabled={running}
|
||||||
title="Delete instance"
|
title="Delete instance"
|
||||||
data-testid="delete-instance-button"
|
data-testid="delete-instance-button"
|
||||||
>
|
>
|
||||||
@@ -122,7 +124,7 @@ function InstanceCard({
|
|||||||
open={isLogsOpen}
|
open={isLogsOpen}
|
||||||
onOpenChange={setIsLogsOpen}
|
onOpenChange={setIsLogsOpen}
|
||||||
instanceName={instance.name}
|
instanceName={instance.name}
|
||||||
isRunning={instance.running}
|
isRunning={running}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
instance,
|
instance,
|
||||||
}) => {
|
}) => {
|
||||||
const isEditing = !!instance;
|
const isEditing = !!instance;
|
||||||
const isRunning = instance?.running || true; // Assume running if instance exists
|
|
||||||
|
|
||||||
const [instanceName, setInstanceName] = useState("");
|
const [instanceName, setInstanceName] = useState("");
|
||||||
const [formData, setFormData] = useState<CreateInstanceOptions>({});
|
const [formData, setFormData] = useState<CreateInstanceOptions>({});
|
||||||
@@ -114,6 +113,16 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
// Check if auto_restart is enabled
|
// Check if auto_restart is enabled
|
||||||
const isAutoRestartEnabled = formData.auto_restart === true;
|
const isAutoRestartEnabled = formData.auto_restart === true;
|
||||||
|
|
||||||
|
// Save button label logic
|
||||||
|
let saveButtonLabel = "Create Instance";
|
||||||
|
if (isEditing) {
|
||||||
|
if (instance?.status === "running") {
|
||||||
|
saveButtonLabel = "Update & Restart Instance";
|
||||||
|
} else {
|
||||||
|
saveButtonLabel = "Update Instance";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
@@ -264,11 +273,7 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
|||||||
disabled={!instanceName.trim() || !!nameError}
|
disabled={!instanceName.trim() || !!nameError}
|
||||||
data-testid="dialog-save-button"
|
data-testid="dialog-save-button"
|
||||||
>
|
>
|
||||||
{isEditing
|
{saveButtonLabel}
|
||||||
? isRunning
|
|
||||||
? "Update & Restart Instance"
|
|
||||||
: "Update Instance"
|
|
||||||
: "Create Instance"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ describe('InstanceCard - Instance Actions and State', () => {
|
|||||||
|
|
||||||
const stoppedInstance: Instance = {
|
const stoppedInstance: Instance = {
|
||||||
name: 'test-instance',
|
name: 'test-instance',
|
||||||
running: false,
|
status: 'stopped',
|
||||||
options: { model: 'test-model.gguf' }
|
options: { model: 'test-model.gguf' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningInstance: Instance = {
|
const runningInstance: Instance = {
|
||||||
name: 'running-instance',
|
name: 'running-instance',
|
||||||
running: true,
|
status: 'running',
|
||||||
options: { model: 'running-model.gguf' }
|
options: { model: 'running-model.gguf' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ afterEach(() => {
|
|||||||
it('handles instance with minimal data', () => {
|
it('handles instance with minimal data', () => {
|
||||||
const minimalInstance: Instance = {
|
const minimalInstance: Instance = {
|
||||||
name: 'minimal',
|
name: 'minimal',
|
||||||
running: false,
|
status: 'stopped',
|
||||||
options: {}
|
options: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ afterEach(() => {
|
|||||||
it('handles instance with undefined options', () => {
|
it('handles instance with undefined options', () => {
|
||||||
const instanceWithoutOptions: Instance = {
|
const instanceWithoutOptions: Instance = {
|
||||||
name: 'no-options',
|
name: 'no-options',
|
||||||
running: true,
|
status: 'running',
|
||||||
options: undefined
|
options: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ describe('InstanceList - State Management and UI Logic', () => {
|
|||||||
const mockEditInstance = vi.fn()
|
const mockEditInstance = vi.fn()
|
||||||
|
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: 'instance-1', running: false, options: { model: 'model1.gguf' } },
|
{ name: 'instance-1', status: 'stopped', options: { model: 'model1.gguf' } },
|
||||||
{ name: 'instance-2', running: true, options: { model: 'model2.gguf' } },
|
{ name: 'instance-2', status: 'running', options: { model: 'model2.gguf' } },
|
||||||
{ name: 'instance-3', running: false, options: { model: 'model3.gguf' } }
|
{ name: 'instance-3', status: 'stopped', options: { model: 'model3.gguf' } }
|
||||||
]
|
]
|
||||||
|
|
||||||
const DUMMY_API_KEY = 'test-api-key-123'
|
const DUMMY_API_KEY = 'test-api-key-123'
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ afterEach(() => {
|
|||||||
describe('Edit Mode', () => {
|
describe('Edit Mode', () => {
|
||||||
const mockInstance: Instance = {
|
const mockInstance: Instance = {
|
||||||
name: 'existing-instance',
|
name: 'existing-instance',
|
||||||
running: false,
|
status: 'stopped',
|
||||||
options: {
|
options: {
|
||||||
model: 'test-model.gguf',
|
model: 'test-model.gguf',
|
||||||
gpu_layers: 10,
|
gpu_layers: 10,
|
||||||
@@ -184,7 +184,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct button text for running vs stopped instances', () => {
|
it('shows correct button text for running vs stopped instances', () => {
|
||||||
const runningInstance: Instance = { ...mockInstance, running: true }
|
const runningInstance: Instance = { ...mockInstance, status: 'running' }
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<InstanceDialog
|
<InstanceDialog
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
|||||||
setError(null)
|
setError(null)
|
||||||
await instancesApi.start(name)
|
await instancesApi.start(name)
|
||||||
|
|
||||||
// Update only this instance's running status
|
// Update only this instance's status
|
||||||
updateInstanceInMap(name, { running: true })
|
updateInstanceInMap(name, { status: "running" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to start instance')
|
setError(err instanceof Error ? err.message : 'Failed to start instance')
|
||||||
}
|
}
|
||||||
@@ -125,8 +125,8 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
|||||||
setError(null)
|
setError(null)
|
||||||
await instancesApi.stop(name)
|
await instancesApi.stop(name)
|
||||||
|
|
||||||
// Update only this instance's running status
|
// Update only this instance's status
|
||||||
updateInstanceInMap(name, { running: false })
|
updateInstanceInMap(name, { status: "stopped" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to stop instance')
|
setError(err instanceof Error ? err.message : 'Failed to stop instance')
|
||||||
}
|
}
|
||||||
@@ -137,8 +137,8 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
|||||||
setError(null)
|
setError(null)
|
||||||
await instancesApi.restart(name)
|
await instancesApi.restart(name)
|
||||||
|
|
||||||
// Update only this instance's running status
|
// Update only this instance's status
|
||||||
updateInstanceInMap(name, { running: true })
|
updateInstanceInMap(name, { status: "running" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to restart instance')
|
setError(err instanceof Error ? err.message : 'Failed to restart instance')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function TestComponent() {
|
|||||||
<div data-testid="instances-count">{instances.length}</div>
|
<div data-testid="instances-count">{instances.length}</div>
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => (
|
||||||
<div key={instance.name} data-testid={`instance-${instance.name}`}>
|
<div key={instance.name} data-testid={`instance-${instance.name}`}>
|
||||||
{instance.name}:{instance.running.toString()}
|
{instance.name}:{instance.status}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -99,8 +99,8 @@ function renderWithProvider(children: ReactNode) {
|
|||||||
|
|
||||||
describe("InstancesContext", () => {
|
describe("InstancesContext", () => {
|
||||||
const mockInstances: Instance[] = [
|
const mockInstances: Instance[] = [
|
||||||
{ name: "instance1", running: true, options: { model: "model1.gguf" } },
|
{ name: "instance1", status: "running", options: { model: "model1.gguf" } },
|
||||||
{ name: "instance2", running: false, options: { model: "model2.gguf" } },
|
{ name: "instance2", status: "stopped", options: { model: "model2.gguf" } },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -132,10 +132,10 @@ describe("InstancesContext", () => {
|
|||||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||||
"instance1:true"
|
"instance1:running"
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||||
"instance2:false"
|
"instance2:stopped"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -158,7 +158,7 @@ describe("InstancesContext", () => {
|
|||||||
it("creates instance and adds it to state", async () => {
|
it("creates instance and adds it to state", async () => {
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
running: false,
|
status: "stopped",
|
||||||
options: { model: "test.gguf" },
|
options: { model: "test.gguf" },
|
||||||
};
|
};
|
||||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||||
@@ -181,7 +181,7 @@ describe("InstancesContext", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3");
|
expect(screen.getByTestId("instances-count")).toHaveTextContent("3");
|
||||||
expect(screen.getByTestId("instance-new-instance")).toHaveTextContent(
|
expect(screen.getByTestId("instance-new-instance")).toHaveTextContent(
|
||||||
"new-instance:false"
|
"new-instance:stopped"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -214,7 +214,7 @@ describe("InstancesContext", () => {
|
|||||||
it("updates instance and maintains it in state", async () => {
|
it("updates instance and maintains it in state", async () => {
|
||||||
const updatedInstance: Instance = {
|
const updatedInstance: Instance = {
|
||||||
name: "instance1",
|
name: "instance1",
|
||||||
running: true,
|
status: "running",
|
||||||
options: { model: "updated.gguf" },
|
options: { model: "updated.gguf" },
|
||||||
};
|
};
|
||||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
|
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
|
||||||
@@ -251,7 +251,7 @@ describe("InstancesContext", () => {
|
|||||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||||
// instance2 starts as not running
|
// instance2 starts as not running
|
||||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||||
"instance2:false"
|
"instance2:stopped"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ describe("InstancesContext", () => {
|
|||||||
expect(instancesApi.start).toHaveBeenCalledWith("instance2");
|
expect(instancesApi.start).toHaveBeenCalledWith("instance2");
|
||||||
// The running state should be updated to true
|
// The running state should be updated to true
|
||||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||||
"instance2:true"
|
"instance2:running"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -276,7 +276,7 @@ describe("InstancesContext", () => {
|
|||||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||||
// instance1 starts as running
|
// instance1 starts as running
|
||||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||||
"instance1:true"
|
"instance1:running"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ describe("InstancesContext", () => {
|
|||||||
expect(instancesApi.stop).toHaveBeenCalledWith("instance1");
|
expect(instancesApi.stop).toHaveBeenCalledWith("instance1");
|
||||||
// The running state should be updated to false
|
// The running state should be updated to false
|
||||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||||
"instance1:false"
|
"instance1:stopped"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -383,7 +383,7 @@ describe("InstancesContext", () => {
|
|||||||
// Test that operations don't interfere with each other
|
// Test that operations don't interfere with each other
|
||||||
const newInstance: Instance = {
|
const newInstance: Instance = {
|
||||||
name: "new-instance",
|
name: "new-instance",
|
||||||
running: false,
|
status: "stopped",
|
||||||
options: {},
|
options: {},
|
||||||
};
|
};
|
||||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||||
@@ -411,7 +411,7 @@ describe("InstancesContext", () => {
|
|||||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3
|
expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3
|
||||||
// But the running state should change
|
// But the running state should change
|
||||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||||
"instance2:true"
|
"instance2:running"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
// ui/src/hooks/useInstanceHealth.ts
|
// ui/src/hooks/useInstanceHealth.ts
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { HealthStatus } from '@/types/instance'
|
import type { HealthStatus, InstanceStatus } from '@/types/instance'
|
||||||
import { healthService } from '@/lib/healthService'
|
import { healthService } from '@/lib/healthService'
|
||||||
|
|
||||||
export function useInstanceHealth(instanceName: string, isRunning: boolean): HealthStatus | undefined {
|
export function useInstanceHealth(instanceName: string, instanceStatus: InstanceStatus): HealthStatus | undefined {
|
||||||
const [health, setHealth] = useState<HealthStatus | undefined>()
|
const [health, setHealth] = useState<HealthStatus | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRunning) {
|
if (instanceStatus === "stopped") {
|
||||||
setHealth(undefined)
|
setHealth({ status: "unknown", lastChecked: new Date() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instanceStatus === "failed") {
|
||||||
|
setHealth({ status: instanceStatus, lastChecked: new Date() })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,9 +22,9 @@ export function useInstanceHealth(instanceName: string, isRunning: boolean): Hea
|
|||||||
setHealth(healthStatus)
|
setHealth(healthStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup subscription on unmount or when running changes
|
// Cleanup subscription on unmount or when instanceStatus changes
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [instanceName, isRunning])
|
}, [instanceName, instanceStatus])
|
||||||
|
|
||||||
return health
|
return health
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,16 @@ import type { CreateInstanceOptions } from '@/schemas/instanceOptions'
|
|||||||
|
|
||||||
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||||
|
|
||||||
|
export type InstanceStatus = 'running' | 'stopped' | 'failed'
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
status: 'ok' | 'loading' | 'error' | 'unknown'
|
status: 'ok' | 'loading' | 'error' | 'unknown' | 'failed'
|
||||||
message?: string
|
message?: string
|
||||||
lastChecked: Date
|
lastChecked: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
name: string;
|
name: string;
|
||||||
running: boolean;
|
status: InstanceStatus;
|
||||||
options?: CreateInstanceOptions;
|
options?: CreateInstanceOptions;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user