From 615c2ac54eefc16236b67ac7805f1be13aa9d283 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 27 Aug 2025 18:42:34 +0200 Subject: [PATCH 01/13] Add MaxRunningInstances to InstancesConfig and implement IsRunning method --- pkg/config/config.go | 9 +++++++++ pkg/instance/lifecycle.go | 6 ++++++ pkg/manager/manager.go | 16 +++++++++------- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 4486fa5..89080ed 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,6 +55,9 @@ type InstancesConfig struct { // Maximum number of instances that can be created 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 LlamaExecutable string `yaml:"llama_executable"` @@ -113,6 +116,7 @@ func LoadConfig(configPath string) (AppConfig, error) { LogsDir: filepath.Join(getDefaultDataDirectory(), "logs"), AutoCreateDirs: true, MaxInstances: -1, // -1 means unlimited + MaxRunningInstances: -1, // -1 means unlimited LlamaExecutable: "llama-server", DefaultAutoRestart: true, DefaultMaxRestarts: 3, @@ -211,6 +215,11 @@ func loadEnvVars(cfg *AppConfig) { 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 != "" { cfg.Instances.LlamaExecutable = llamaExec } diff --git a/pkg/instance/lifecycle.go b/pkg/instance/lifecycle.go index 84d5ea3..eb2fc83 100644 --- a/pkg/instance/lifecycle.go +++ b/pkg/instance/lifecycle.go @@ -11,6 +11,12 @@ import ( "time" ) +func (i *Process) IsRunning() bool { + i.mu.RLock() + defer i.mu.RUnlock() + return i.Running +} + // Start starts the llama server instance and returns an error if it fails. func (i *Process) Start() error { i.mu.Lock() diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index bac7567..31c0b01 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -28,10 +28,11 @@ type InstanceManager interface { } type instanceManager struct { - mu sync.RWMutex - instances map[string]*instance.Process - ports map[int]bool - instancesConfig config.InstancesConfig + mu sync.RWMutex + instances map[string]*instance.Process + runningInstances map[*instance.Process]struct{} + ports map[int]bool + instancesConfig config.InstancesConfig // Timeout checker timeoutChecker *time.Ticker @@ -46,9 +47,10 @@ func NewInstanceManager(instancesConfig config.InstancesConfig) InstanceManager instancesConfig.TimeoutCheckInterval = 5 // Default to 5 minutes if not set } im := &instanceManager{ - instances: make(map[string]*instance.Process), - ports: make(map[int]bool), - instancesConfig: instancesConfig, + instances: make(map[string]*instance.Process), + runningInstances: make(map[*instance.Process]struct{}), + ports: make(map[int]bool), + instancesConfig: instancesConfig, timeoutChecker: time.NewTicker(time.Duration(instancesConfig.TimeoutCheckInterval) * time.Minute), shutdownChan: make(chan struct{}), From 1443746add1f84ca4139547cc249a2c3e76fac0e Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 27 Aug 2025 19:44:38 +0200 Subject: [PATCH 02/13] Refactor instance status management: replace Running boolean with InstanceStatus enum and update related methods --- pkg/instance/instance.go | 10 ++--- pkg/instance/instance_test.go | 14 +++---- pkg/instance/lifecycle.go | 22 ++++------- pkg/instance/status.go | 72 +++++++++++++++++++++++++++++++++++ pkg/instance/timeout.go | 2 +- pkg/instance/timeout_test.go | 6 +-- pkg/manager/manager.go | 8 ++-- pkg/manager/manager_test.go | 10 ++--- pkg/manager/operations.go | 8 ++-- pkg/server/handlers.go | 4 +- 10 files changed, 111 insertions(+), 45 deletions(-) create mode 100644 pkg/instance/status.go diff --git a/pkg/instance/instance.go b/pkg/instance/instance.go index f690ca1..862c323 100644 --- a/pkg/instance/instance.go +++ b/pkg/instance/instance.go @@ -82,7 +82,7 @@ type Process struct { globalSettings *config.InstancesConfig // Status - Running bool `json:"running"` + Status InstanceStatus `json:"status"` // Creation time Created int64 `json:"created,omitempty"` // Unix timestamp when the instance was created @@ -287,12 +287,12 @@ func (i *Process) MarshalJSON() ([]byte, error) { temp := struct { Name string `json:"name"` Options *CreateInstanceOptions `json:"options,omitempty"` - Running bool `json:"running"` + Status InstanceStatus `json:"status"` Created int64 `json:"created,omitempty"` }{ Name: i.Name, Options: i.options, - Running: i.Running, + Status: i.Status, Created: i.Created, } @@ -305,7 +305,7 @@ func (i *Process) UnmarshalJSON(data []byte) error { temp := struct { Name string `json:"name"` Options *CreateInstanceOptions `json:"options,omitempty"` - Running bool `json:"running"` + Status InstanceStatus `json:"status"` Created int64 `json:"created,omitempty"` }{} @@ -315,7 +315,7 @@ func (i *Process) UnmarshalJSON(data []byte) error { // Set the fields i.Name = temp.Name - i.Running = temp.Running + i.Status = temp.Status i.Created = temp.Created // Handle options with validation but no defaults diff --git a/pkg/instance/instance_test.go b/pkg/instance/instance_test.go index 3ed6c08..5582f04 100644 --- a/pkg/instance/instance_test.go +++ b/pkg/instance/instance_test.go @@ -29,7 +29,7 @@ func TestNewInstance(t *testing.T) { if instance.Name != "test-instance" { t.Errorf("Expected name 'test-instance', got %q", instance.Name) } - if instance.Running { + if instance.IsRunning() { t.Error("New instance should not be running") } @@ -188,7 +188,7 @@ func TestMarshalJSON(t *testing.T) { } // Check that JSON contains expected fields - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) if err != nil { t.Fatalf("JSON unmarshal failed: %v", err) @@ -197,8 +197,8 @@ func TestMarshalJSON(t *testing.T) { if result["name"] != "test-instance" { t.Errorf("Expected name 'test-instance', got %v", result["name"]) } - if result["running"] != false { - t.Errorf("Expected running false, got %v", result["running"]) + if result["status"] != "stopped" { + t.Errorf("Expected status 'stopped', got %v", result["status"]) } // Check that options are included @@ -218,7 +218,7 @@ func TestMarshalJSON(t *testing.T) { func TestUnmarshalJSON(t *testing.T) { jsonData := `{ "name": "test-instance", - "running": true, + "status": "running", "options": { "model": "/path/to/model.gguf", "port": 8080, @@ -236,8 +236,8 @@ func TestUnmarshalJSON(t *testing.T) { if inst.Name != "test-instance" { t.Errorf("Expected name 'test-instance', got %q", inst.Name) } - if !inst.Running { - t.Error("Expected running to be true") + if !inst.IsRunning() { + t.Error("Expected status to be running") } opts := inst.GetOptions() diff --git a/pkg/instance/lifecycle.go b/pkg/instance/lifecycle.go index eb2fc83..53a4091 100644 --- a/pkg/instance/lifecycle.go +++ b/pkg/instance/lifecycle.go @@ -11,18 +11,12 @@ import ( "time" ) -func (i *Process) IsRunning() bool { - i.mu.RLock() - defer i.mu.RUnlock() - return i.Running -} - // Start starts the llama server instance and returns an error if it fails. func (i *Process) Start() error { i.mu.Lock() defer i.mu.Unlock() - if i.Running { + if i.IsRunning() { return fmt.Errorf("instance %s is already running", i.Name) } @@ -71,7 +65,7 @@ func (i *Process) Start() error { return fmt.Errorf("failed to start instance %s: %w", i.Name, err) } - i.Running = true + i.SetStatus(Running) // Create channel for monitor completion signaling i.monitorDone = make(chan struct{}) @@ -88,7 +82,7 @@ func (i *Process) Start() error { func (i *Process) Stop() error { i.mu.Lock() - if !i.Running { + if !i.IsRunning() { // Even if not running, cancel any pending restart if i.restartCancel != nil { i.restartCancel() @@ -105,8 +99,8 @@ func (i *Process) Stop() error { i.restartCancel = nil } - // Set running to false first to signal intentional stop - i.Running = false + // Set status to stopped first to signal intentional stop + i.SetStatus(Stopped) // Clean up the proxy i.proxy = nil @@ -151,7 +145,7 @@ func (i *Process) Stop() error { } func (i *Process) WaitForHealthy(timeout int) error { - if !i.Running { + if !i.IsRunning() { return fmt.Errorf("instance %s is not running", i.Name) } @@ -233,12 +227,12 @@ func (i *Process) monitorProcess() { i.mu.Lock() // Check if the instance was intentionally stopped - if !i.Running { + if !i.IsRunning() { i.mu.Unlock() return } - i.Running = false + i.SetStatus(Stopped) i.logger.Close() // Cancel any existing restart context since we're handling a new exit diff --git a/pkg/instance/status.go b/pkg/instance/status.go new file mode 100644 index 0000000..c7e987a --- /dev/null +++ b/pkg/instance/status.go @@ -0,0 +1,72 @@ +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) { + p.mu.Lock() + defer p.mu.Unlock() + + p.Status = status +} + +func (p *Process) GetStatus() InstanceStatus { + p.mu.RLock() + defer p.mu.RUnlock() + return p.Status +} + +// IsRunning returns true if the status is Running +func (p *Process) IsRunning() bool { + p.mu.RLock() + defer p.mu.RUnlock() + 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 +} diff --git a/pkg/instance/timeout.go b/pkg/instance/timeout.go index 19344e6..94cdc16 100644 --- a/pkg/instance/timeout.go +++ b/pkg/instance/timeout.go @@ -13,7 +13,7 @@ func (i *Process) ShouldTimeout() bool { i.mu.RLock() 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 } diff --git a/pkg/instance/timeout_test.go b/pkg/instance/timeout_test.go index 5cd67f2..e6ac531 100644 --- a/pkg/instance/timeout_test.go +++ b/pkg/instance/timeout_test.go @@ -94,7 +94,7 @@ func TestShouldTimeout_NoTimeoutConfigured(t *testing.T) { inst := instance.NewInstance("test-instance", globalSettings, options) // Simulate running state - inst.Running = true + inst.SetStatus(instance.Running) if inst.ShouldTimeout() { t.Errorf("Instance with %s should not timeout", tt.name) @@ -117,7 +117,7 @@ func TestShouldTimeout_WithinTimeLimit(t *testing.T) { } inst := instance.NewInstance("test-instance", globalSettings, options) - inst.Running = true + inst.SetStatus(instance.Running) // Update last request time to now inst.UpdateLastRequestTime() @@ -142,7 +142,7 @@ func TestShouldTimeout_ExceedsTimeLimit(t *testing.T) { } inst := instance.NewInstance("test-instance", globalSettings, options) - inst.Running = true + inst.SetStatus(instance.Running) // Use MockTimeProvider to simulate old last request time mockTime := NewMockTimeProvider(time.Now()) diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 31c0b01..4114eb1 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -150,7 +150,7 @@ func (im *instanceManager) Shutdown() { wg.Add(len(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 continue } @@ -234,7 +234,7 @@ func (im *instanceManager) loadInstance(name, path string) error { // Restore persisted fields that NewInstance doesn't set inst.Created = persistedInstance.Created - inst.Running = persistedInstance.Running + inst.SetStatus(persistedInstance.Status) // Check for port conflicts and add to maps if inst.GetOptions() != nil && inst.GetOptions().Port > 0 { @@ -254,7 +254,7 @@ func (im *instanceManager) autoStartInstances() { im.mu.RLock() var instancesToStart []*instance.Process for _, inst := range im.instances { - if inst.Running && // Was running when persisted + if inst.IsRunning() && // Was running when persisted inst.GetOptions() != nil && inst.GetOptions().AutoRestart != nil && *inst.GetOptions().AutoRestart { @@ -266,7 +266,7 @@ func (im *instanceManager) autoStartInstances() { for _, inst := range instancesToStart { log.Printf("Auto-starting instance %s", inst.Name) // Reset running state before starting (since Start() expects stopped instance) - inst.Running = false + inst.SetStatus(instance.Stopped) if err := inst.Start(); err != nil { log.Printf("Failed to auto-start instance %s: %v", inst.Name, err) } diff --git a/pkg/manager/manager_test.go b/pkg/manager/manager_test.go index 6c48366..df675dc 100644 --- a/pkg/manager/manager_test.go +++ b/pkg/manager/manager_test.go @@ -59,7 +59,7 @@ func TestCreateInstance_Success(t *testing.T) { if inst.Name != "test-instance" { 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") } if inst.GetOptions().Port != 8080 { @@ -357,7 +357,7 @@ func TestTimeoutFunctionality(t *testing.T) { inst.SetTimeProvider(mockTime) // 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) // We'll test the ShouldTimeout logic directly @@ -377,7 +377,7 @@ func TestTimeoutFunctionality(t *testing.T) { } // Reset running state to avoid shutdown issues - inst.Running = false + inst.SetStatus(instance.Stopped) // Test that instance without timeout doesn't timeout noTimeoutOptions := &instance.CreateInstanceOptions{ @@ -393,7 +393,7 @@ func TestTimeoutFunctionality(t *testing.T) { } noTimeoutInst.SetTimeProvider(mockTime) - noTimeoutInst.Running = true // Set to running for timeout check + noTimeoutInst.SetStatus(instance.Running) // Set to running for timeout check noTimeoutInst.UpdateLastRequestTime() // Even with time advanced, should not timeout @@ -402,7 +402,7 @@ func TestTimeoutFunctionality(t *testing.T) { } // Reset running state to avoid shutdown issues - noTimeoutInst.Running = false + noTimeoutInst.SetStatus(instance.Stopped) } func TestConcurrentAccess(t *testing.T) { diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index 7849152..d7d06b9 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -109,7 +109,7 @@ func (im *instanceManager) UpdateInstance(name string, options *instance.CreateI } // Check if instance is running before updating options - wasRunning := instance.Running + wasRunning := instance.IsRunning() // If the instance is running, stop it first if wasRunning { @@ -147,7 +147,7 @@ func (im *instanceManager) DeleteInstance(name string) error { 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) } @@ -173,7 +173,7 @@ func (im *instanceManager) StartInstance(name string) (*instance.Process, error) if !exists { 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) } @@ -200,7 +200,7 @@ func (im *instanceManager) StopInstance(name string) (*instance.Process, error) if !exists { 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) } diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go index 6306a31..9c5d9a9 100644 --- a/pkg/server/handlers.go +++ b/pkg/server/handlers.go @@ -451,7 +451,7 @@ func (h *Handler) ProxyToInstance() http.HandlerFunc { return } - if !inst.Running { + if !inst.IsRunning() { http.Error(w, "Instance is not running", http.StatusServiceUnavailable) return } @@ -574,7 +574,7 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc { return } - if !inst.Running { + if !inst.IsRunning() { if inst.GetOptions().OnDemandStart != nil && *inst.GetOptions().OnDemandStart { // If on-demand start is enabled, start the instance if _, err := h.InstanceManager.StartInstance(modelName); err != nil { From b41ebdc60469896a25eab56bd16b64e491431b61 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 27 Aug 2025 19:47:36 +0200 Subject: [PATCH 03/13] Set instance status to Failed when restart conditions are not met --- pkg/instance/lifecycle.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/instance/lifecycle.go b/pkg/instance/lifecycle.go index 53a4091..4de9dde 100644 --- a/pkg/instance/lifecycle.go +++ b/pkg/instance/lifecycle.go @@ -257,6 +257,7 @@ func (i *Process) handleRestart() { // Validate restart conditions and get safe parameters shouldRestart, maxRestarts, restartDelay := i.validateRestartConditions() if !shouldRestart { + i.SetStatus(Failed) i.mu.Unlock() return } From a8f3a8e0f542c1fce42c75d3c00550f459c2ec87 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 27 Aug 2025 20:11:21 +0200 Subject: [PATCH 04/13] Refactor instance status handling on the frontend --- webui/src/App.tsx | 4 +-- webui/src/__tests__/App.test.tsx | 8 +++--- webui/src/components/HealthBadge.tsx | 6 ++++ webui/src/components/InstanceCard.tsx | 14 ++++++---- webui/src/components/InstanceDialog.tsx | 17 +++++++---- .../__tests__/InstanceCard.test.tsx | 8 +++--- .../__tests__/InstanceList.test.tsx | 6 ++-- .../__tests__/InstanceModal.test.tsx | 6 ++-- webui/src/contexts/InstancesContext.tsx | 18 ++++++------ .../__tests__/InstancesContext.test.tsx | 28 +++++++++---------- webui/src/hooks/useInstanceHealth.ts | 17 +++++++---- webui/src/types/instance.ts | 6 ++-- 12 files changed, 79 insertions(+), 59 deletions(-) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 44a155b..8704e6b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -30,9 +30,9 @@ function App() { const handleSaveInstance = (name: string, options: CreateInstanceOptions) => { if (editingInstance) { - updateInstance(editingInstance.name, options); + void updateInstance(editingInstance.name, options); } else { - createInstance(name, options); + void createInstance(name, options); } }; diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 04c1e34..57f00a0 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -46,8 +46,8 @@ function renderApp() { describe('App Component - Critical Business Logic Only', () => { const mockInstances: Instance[] = [ - { name: 'test-instance-1', running: false, options: { model: 'model1.gguf' } }, - { name: 'test-instance-2', running: true, options: { model: 'model2.gguf' } } + { name: 'test-instance-1', status: 'stopped', options: { model: 'model1.gguf' } }, + { name: 'test-instance-2', status: 'running', options: { model: 'model2.gguf' } } ] beforeEach(() => { @@ -81,7 +81,7 @@ describe('App Component - Critical Business Logic Only', () => { const user = userEvent.setup() const newInstance: Instance = { name: 'new-test-instance', - running: false, + status: 'stopped', options: { model: 'new-model.gguf' } } vi.mocked(instancesApi.create).mockResolvedValue(newInstance) @@ -118,7 +118,7 @@ describe('App Component - Critical Business Logic Only', () => { const user = userEvent.setup() const updatedInstance: Instance = { name: 'test-instance-1', - running: false, + status: 'stopped', options: { model: 'updated-model.gguf' } } vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance) diff --git a/webui/src/components/HealthBadge.tsx b/webui/src/components/HealthBadge.tsx index 4bc0cfb..45c1960 100644 --- a/webui/src/components/HealthBadge.tsx +++ b/webui/src/components/HealthBadge.tsx @@ -27,6 +27,8 @@ const HealthBadge: React.FC = ({ health }) => { return ; case "unknown": return ; + case "failed": + return ; } }; @@ -40,6 +42,8 @@ const HealthBadge: React.FC = ({ health }) => { return "destructive"; case "unknown": return "secondary"; + case "failed": + return "destructive"; } }; @@ -53,6 +57,8 @@ const HealthBadge: React.FC = ({ health }) => { return "Error"; case "unknown": return "Unknown"; + case "failed": + return "Failed"; } }; diff --git a/webui/src/components/InstanceCard.tsx b/webui/src/components/InstanceCard.tsx index 5ecfcb2..b17714c 100644 --- a/webui/src/components/InstanceCard.tsx +++ b/webui/src/components/InstanceCard.tsx @@ -24,7 +24,7 @@ function InstanceCard({ editInstance, }: InstanceCardProps) { const [isLogsOpen, setIsLogsOpen] = useState(false); - const health = useInstanceHealth(instance.name, instance.running); + const health = useInstanceHealth(instance.name, instance.status); const handleStart = () => { startInstance(instance.name); @@ -50,13 +50,15 @@ function InstanceCard({ setIsLogsOpen(true); }; + const running = instance.status === "running"; + return ( <>
{instance.name} - {instance.running && } + {running && }
@@ -66,7 +68,7 @@ function InstanceCard({ size="sm" variant="outline" onClick={handleStart} - disabled={instance.running} + disabled={running} title="Start instance" data-testid="start-instance-button" > @@ -77,7 +79,7 @@ function InstanceCard({ size="sm" variant="outline" onClick={handleStop} - disabled={!instance.running} + disabled={!running} title="Stop instance" data-testid="stop-instance-button" > @@ -108,7 +110,7 @@ function InstanceCard({ size="sm" variant="destructive" onClick={handleDelete} - disabled={instance.running} + disabled={running} title="Delete instance" data-testid="delete-instance-button" > @@ -122,7 +124,7 @@ function InstanceCard({ open={isLogsOpen} onOpenChange={setIsLogsOpen} instanceName={instance.name} - isRunning={instance.running} + isRunning={running} /> ); diff --git a/webui/src/components/InstanceDialog.tsx b/webui/src/components/InstanceDialog.tsx index 9542a8c..56792e6 100644 --- a/webui/src/components/InstanceDialog.tsx +++ b/webui/src/components/InstanceDialog.tsx @@ -29,7 +29,6 @@ const InstanceDialog: React.FC = ({ instance, }) => { const isEditing = !!instance; - const isRunning = instance?.running || true; // Assume running if instance exists const [instanceName, setInstanceName] = useState(""); const [formData, setFormData] = useState({}); @@ -114,6 +113,16 @@ const InstanceDialog: React.FC = ({ // Check if auto_restart is enabled 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 ( @@ -264,11 +273,7 @@ const InstanceDialog: React.FC = ({ disabled={!instanceName.trim() || !!nameError} data-testid="dialog-save-button" > - {isEditing - ? isRunning - ? "Update & Restart Instance" - : "Update Instance" - : "Create Instance"} + {saveButtonLabel} diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx index 4429e4f..5daebe4 100644 --- a/webui/src/components/__tests__/InstanceCard.test.tsx +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -17,13 +17,13 @@ describe('InstanceCard - Instance Actions and State', () => { const stoppedInstance: Instance = { name: 'test-instance', - running: false, + status: 'stopped', options: { model: 'test-model.gguf' } } const runningInstance: Instance = { name: 'running-instance', - running: true, + status: 'running', options: { model: 'running-model.gguf' } } @@ -301,7 +301,7 @@ afterEach(() => { it('handles instance with minimal data', () => { const minimalInstance: Instance = { name: 'minimal', - running: false, + status: 'stopped', options: {} } @@ -323,7 +323,7 @@ afterEach(() => { it('handles instance with undefined options', () => { const instanceWithoutOptions: Instance = { name: 'no-options', - running: true, + status: 'running', options: undefined } diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx index b237987..a38f873 100644 --- a/webui/src/components/__tests__/InstanceList.test.tsx +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -44,9 +44,9 @@ describe('InstanceList - State Management and UI Logic', () => { const mockEditInstance = vi.fn() const mockInstances: Instance[] = [ - { name: 'instance-1', running: false, options: { model: 'model1.gguf' } }, - { name: 'instance-2', running: true, options: { model: 'model2.gguf' } }, - { name: 'instance-3', running: false, options: { model: 'model3.gguf' } } + { name: 'instance-1', status: 'stopped', options: { model: 'model1.gguf' } }, + { name: 'instance-2', status: 'running', options: { model: 'model2.gguf' } }, + { name: 'instance-3', status: 'stopped', options: { model: 'model3.gguf' } } ] const DUMMY_API_KEY = 'test-api-key-123' diff --git a/webui/src/components/__tests__/InstanceModal.test.tsx b/webui/src/components/__tests__/InstanceModal.test.tsx index 926d214..8468379 100644 --- a/webui/src/components/__tests__/InstanceModal.test.tsx +++ b/webui/src/components/__tests__/InstanceModal.test.tsx @@ -134,7 +134,7 @@ afterEach(() => { describe('Edit Mode', () => { const mockInstance: Instance = { name: 'existing-instance', - running: false, + status: 'stopped', options: { model: 'test-model.gguf', gpu_layers: 10, @@ -184,8 +184,8 @@ afterEach(() => { }) 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( { try { setError(null) await instancesApi.start(name) - - // Update only this instance's running status - updateInstanceInMap(name, { running: true }) + + // Update only this instance's status + updateInstanceInMap(name, { status: "running" }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start instance') } @@ -124,9 +124,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { try { setError(null) await instancesApi.stop(name) - - // Update only this instance's running status - updateInstanceInMap(name, { running: false }) + + // Update only this instance's status + updateInstanceInMap(name, { status: "stopped" }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to stop instance') } @@ -136,9 +136,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { try { setError(null) await instancesApi.restart(name) - - // Update only this instance's running status - updateInstanceInMap(name, { running: true }) + + // Update only this instance's status + updateInstanceInMap(name, { status: "running" }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to restart instance') } diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index aa4e8e3..c271730 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -41,7 +41,7 @@ function TestComponent() {
{instances.length}
{instances.map((instance) => (
- {instance.name}:{instance.running.toString()} + {instance.name}:{instance.status}
))} @@ -99,8 +99,8 @@ function renderWithProvider(children: ReactNode) { describe("InstancesContext", () => { const mockInstances: Instance[] = [ - { name: "instance1", running: true, options: { model: "model1.gguf" } }, - { name: "instance2", running: false, options: { model: "model2.gguf" } }, + { name: "instance1", status: "running", options: { model: "model1.gguf" } }, + { name: "instance2", status: "stopped", options: { model: "model2.gguf" } }, ]; beforeEach(() => { @@ -132,10 +132,10 @@ describe("InstancesContext", () => { expect(screen.getByTestId("loading")).toHaveTextContent("false"); expect(screen.getByTestId("instances-count")).toHaveTextContent("2"); expect(screen.getByTestId("instance-instance1")).toHaveTextContent( - "instance1:true" + "instance1:running" ); 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 () => { const newInstance: Instance = { name: "new-instance", - running: false, + status: "stopped", options: { model: "test.gguf" }, }; vi.mocked(instancesApi.create).mockResolvedValue(newInstance); @@ -181,7 +181,7 @@ describe("InstancesContext", () => { await waitFor(() => { expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); 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 () => { const updatedInstance: Instance = { name: "instance1", - running: true, + status: "running", options: { model: "updated.gguf" }, }; vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance); @@ -251,7 +251,7 @@ describe("InstancesContext", () => { expect(screen.getByTestId("loading")).toHaveTextContent("false"); // instance2 starts as not running expect(screen.getByTestId("instance-instance2")).toHaveTextContent( - "instance2:false" + "instance2:stopped" ); }); @@ -262,7 +262,7 @@ describe("InstancesContext", () => { expect(instancesApi.start).toHaveBeenCalledWith("instance2"); // The running state should be updated to true expect(screen.getByTestId("instance-instance2")).toHaveTextContent( - "instance2:true" + "instance2:running" ); }); }); @@ -276,7 +276,7 @@ describe("InstancesContext", () => { expect(screen.getByTestId("loading")).toHaveTextContent("false"); // instance1 starts as running expect(screen.getByTestId("instance-instance1")).toHaveTextContent( - "instance1:true" + "instance1:running" ); }); @@ -287,7 +287,7 @@ describe("InstancesContext", () => { expect(instancesApi.stop).toHaveBeenCalledWith("instance1"); // The running state should be updated to false 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 const newInstance: Instance = { name: "new-instance", - running: false, + status: "stopped", options: {}, }; vi.mocked(instancesApi.create).mockResolvedValue(newInstance); @@ -411,7 +411,7 @@ describe("InstancesContext", () => { expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3 // But the running state should change expect(screen.getByTestId("instance-instance2")).toHaveTextContent( - "instance2:true" + "instance2:running" ); }); }); diff --git a/webui/src/hooks/useInstanceHealth.ts b/webui/src/hooks/useInstanceHealth.ts index ceb5271..2741ea4 100644 --- a/webui/src/hooks/useInstanceHealth.ts +++ b/webui/src/hooks/useInstanceHealth.ts @@ -1,14 +1,19 @@ // ui/src/hooks/useInstanceHealth.ts import { useState, useEffect } from 'react' -import type { HealthStatus } from '@/types/instance' +import type { HealthStatus, InstanceStatus } from '@/types/instance' 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() useEffect(() => { - if (!isRunning) { - setHealth(undefined) + if (instanceStatus == "stopped") { + setHealth({ status: "unknown", lastChecked: new Date() }) + return + } + + if (instanceStatus == "failed") { + setHealth({ status: instanceStatus, lastChecked: new Date() }) return } @@ -17,9 +22,9 @@ export function useInstanceHealth(instanceName: string, isRunning: boolean): Hea setHealth(healthStatus) }) - // Cleanup subscription on unmount or when running changes + // Cleanup subscription on unmount or when instanceStatus changes return unsubscribe - }, [instanceName, isRunning]) + }, [instanceName, instanceStatus]) return health } \ No newline at end of file diff --git a/webui/src/types/instance.ts b/webui/src/types/instance.ts index 0ebbf5f..f530f80 100644 --- a/webui/src/types/instance.ts +++ b/webui/src/types/instance.ts @@ -2,14 +2,16 @@ import type { CreateInstanceOptions } from '@/schemas/instanceOptions' export { type CreateInstanceOptions } from '@/schemas/instanceOptions' +export type InstanceStatus = 'running' | 'stopped' | 'failed' + export interface HealthStatus { - status: 'ok' | 'loading' | 'error' | 'unknown' + status: 'ok' | 'loading' | 'error' | 'unknown' | 'failed' message?: string lastChecked: Date } export interface Instance { name: string; - running: boolean; + status: InstanceStatus; options?: CreateInstanceOptions; } \ No newline at end of file From ae37055331e7738470bb5ce253246442b4d82ca8 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 27 Aug 2025 20:54:26 +0200 Subject: [PATCH 05/13] Add onStatusChange callback to instance management for status updates --- pkg/instance/instance.go | 7 +++++-- pkg/instance/instance_test.go | 30 ++++++++++++++++++++++++------ pkg/instance/status.go | 8 ++++++-- pkg/instance/timeout_test.go | 30 ++++++++++++++++++++++++------ pkg/manager/manager.go | 21 ++++++++++++++++++--- pkg/manager/operations.go | 6 +++++- 6 files changed, 82 insertions(+), 20 deletions(-) diff --git a/pkg/instance/instance.go b/pkg/instance/instance.go index 862c323..3780f84 100644 --- a/pkg/instance/instance.go +++ b/pkg/instance/instance.go @@ -82,7 +82,8 @@ type Process struct { globalSettings *config.InstancesConfig // Status - Status InstanceStatus `json:"status"` + Status InstanceStatus `json:"status"` + onStatusChange func(oldStatus, newStatus InstanceStatus) // Creation time 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 -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 optionsCopy := validateAndCopyOptions(name, options) // Apply defaults @@ -208,6 +209,8 @@ func NewInstance(name string, globalSettings *config.InstancesConfig, options *C logger: logger, timeProvider: realTimeProvider{}, Created: time.Now().Unix(), + Status: Stopped, + onStatusChange: onStatusChange, } } diff --git a/pkg/instance/instance_test.go b/pkg/instance/instance_test.go index 5582f04..4f30ab6 100644 --- a/pkg/instance/instance_test.go +++ b/pkg/instance/instance_test.go @@ -24,7 +24,10 @@ 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" { t.Errorf("Expected name 'test-instance', got %q", instance.Name) @@ -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() // 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 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 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) if err != nil { @@ -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() if opts.MaxRestarts == nil { diff --git a/pkg/instance/status.go b/pkg/instance/status.go index c7e987a..3738502 100644 --- a/pkg/instance/status.go +++ b/pkg/instance/status.go @@ -28,9 +28,13 @@ var statusToName = map[InstanceStatus]string{ func (p *Process) SetStatus(status InstanceStatus) { p.mu.Lock() - defer p.mu.Unlock() - + oldStatus := p.Status p.Status = status + p.mu.Unlock() + + if p.onStatusChange != nil { + p.onStatusChange(oldStatus, status) + } } func (p *Process) GetStatus() InstanceStatus { diff --git a/pkg/instance/timeout_test.go b/pkg/instance/timeout_test.go index e6ac531..05abd04 100644 --- a/pkg/instance/timeout_test.go +++ b/pkg/instance/timeout_test.go @@ -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 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 if inst.ShouldTimeout() { @@ -85,6 +91,9 @@ func TestShouldTimeout_NoTimeoutConfigured(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Mock onStatusChange function + mockOnStatusChange := func(oldStatus, newStatus instance.InstanceStatus) {} + options := &instance.CreateInstanceOptions{ IdleTimeout: tt.idleTimeout, LlamaServerOptions: llamacpp.LlamaServerOptions{ @@ -92,7 +101,7 @@ func TestShouldTimeout_NoTimeoutConfigured(t *testing.T) { }, } - inst := instance.NewInstance("test-instance", globalSettings, options) + inst := instance.NewInstance("test-instance", globalSettings, options, mockOnStatusChange) // Simulate running state inst.SetStatus(instance.Running) @@ -116,7 +125,10 @@ func TestShouldTimeout_WithinTimeLimit(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) inst.SetStatus(instance.Running) // Update last request time to now @@ -141,7 +153,10 @@ func TestShouldTimeout_ExceedsTimeLimit(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) inst.SetStatus(instance.Running) // Use MockTimeProvider to simulate old last request time @@ -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() if opts.IdleTimeout == nil || *opts.IdleTimeout != tt.expectedTimeout { diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 4114eb1..90ef27b 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -30,7 +30,7 @@ type InstanceManager interface { type instanceManager struct { mu sync.RWMutex instances map[string]*instance.Process - runningInstances map[*instance.Process]struct{} + runningInstances map[string]struct{} ports map[int]bool instancesConfig config.InstancesConfig @@ -48,7 +48,7 @@ func NewInstanceManager(instancesConfig config.InstancesConfig) InstanceManager } im := &instanceManager{ instances: make(map[string]*instance.Process), - runningInstances: make(map[*instance.Process]struct{}), + runningInstances: make(map[string]struct{}), ports: make(map[int]bool), instancesConfig: instancesConfig, @@ -229,8 +229,12 @@ func (im *instanceManager) loadInstance(name, path string) error { 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) - 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 inst.Created = persistedInstance.Created @@ -272,3 +276,14 @@ func (im *instanceManager) autoStartInstances() { } } } + +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) + } +} diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index d7d06b9..73b3c70 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -65,7 +65,11 @@ func (im *instanceManager) CreateInstance(name string, options *instance.CreateI 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.ports[options.Port] = true From 0b058237fed3ea96f1565f119b3b559bd53d7527 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 27 Aug 2025 21:18:38 +0200 Subject: [PATCH 06/13] Enforce maximum running instances limit in StartInstance method --- pkg/manager/operations.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index 73b3c70..619100e 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -181,6 +181,10 @@ func (im *instanceManager) StartInstance(name string) (*instance.Process, error) 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, fmt.Errorf("maximum number of running instances (%d) reached", im.instancesConfig.MaxRunningInstances) + } + if err := instance.Start(); err != nil { return nil, fmt.Errorf("failed to start instance %s: %w", name, err) } From 227ca7927addbee98c1e0531c14ec5006b45f8e3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 18:59:26 +0200 Subject: [PATCH 07/13] Refactor SetStatus method to capture onStatusChange callback reference before unlocking mutex --- pkg/instance/status.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/instance/status.go b/pkg/instance/status.go index 3738502..35ecfc6 100644 --- a/pkg/instance/status.go +++ b/pkg/instance/status.go @@ -30,10 +30,12 @@ func (p *Process) SetStatus(status InstanceStatus) { p.mu.Lock() oldStatus := p.Status p.Status = status + callback := p.onStatusChange // Capture callback reference p.mu.Unlock() - if p.onStatusChange != nil { - p.onStatusChange(oldStatus, status) + // Call callback outside the lock to prevent deadlocks + if callback != nil { + callback(oldStatus, status) } } From b698c1d0eacdf9ea405ef52c6ecdcfe91eb777b1 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 19:08:20 +0200 Subject: [PATCH 08/13] Remove locks from SetStatus --- pkg/instance/status.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/instance/status.go b/pkg/instance/status.go index 35ecfc6..0361e7e 100644 --- a/pkg/instance/status.go +++ b/pkg/instance/status.go @@ -27,15 +27,11 @@ var statusToName = map[InstanceStatus]string{ } func (p *Process) SetStatus(status InstanceStatus) { - p.mu.Lock() oldStatus := p.Status p.Status = status - callback := p.onStatusChange // Capture callback reference - p.mu.Unlock() - // Call callback outside the lock to prevent deadlocks - if callback != nil { - callback(oldStatus, status) + if p.onStatusChange != nil { + p.onStatusChange(oldStatus, status) } } From e319731239fb2cbdc8b676c2769e6d7796ba94c6 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 19:19:28 +0200 Subject: [PATCH 09/13] Remove unnecessary read locks from GetStatus and IsRunning methods --- pkg/instance/status.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/instance/status.go b/pkg/instance/status.go index 0361e7e..e07fe03 100644 --- a/pkg/instance/status.go +++ b/pkg/instance/status.go @@ -36,15 +36,11 @@ func (p *Process) SetStatus(status InstanceStatus) { } func (p *Process) GetStatus() InstanceStatus { - p.mu.RLock() - defer p.mu.RUnlock() return p.Status } // IsRunning returns true if the status is Running func (p *Process) IsRunning() bool { - p.mu.RLock() - defer p.mu.RUnlock() return p.Status == Running } From 7d5c68e6718766d45d0fc921523a5f6715d8dd4d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 19:19:55 +0200 Subject: [PATCH 10/13] Add launch configuration for Go server in VSCode --- .vscode/launch.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7f4175c --- /dev/null +++ b/.vscode/launch.json @@ -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", + } + ] +} \ No newline at end of file From 41d8c4118819fb5d8ea7a796a9ae026b2274f93b Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 20:07:03 +0200 Subject: [PATCH 11/13] Introduce MaxRunningInstancesError type and handle it in StartInstance handler --- pkg/manager/operations.go | 6 ++++-- pkg/server/handlers.go | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index 619100e..c81a1ca 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -8,6 +8,8 @@ import ( "path/filepath" ) +type MaxRunningInstancesError error + // ListInstances returns a list of all instances managed by the instance manager. func (im *instanceManager) ListInstances() ([]*instance.Process, error) { im.mu.RLock() @@ -42,7 +44,7 @@ func (im *instanceManager) CreateInstance(name string, options *instance.CreateI // Check max instances limit after acquiring the lock if len(im.instances) >= im.instancesConfig.MaxInstances && im.instancesConfig.MaxInstances != -1 { - return nil, fmt.Errorf("maximum number of instances (%d) reached", im.instancesConfig.MaxInstances) + return nil, MaxRunningInstancesError(fmt.Errorf("maximum number of instances (%d) reached", im.instancesConfig.MaxInstances)) } // Check if instance with this name already exists @@ -182,7 +184,7 @@ func (im *instanceManager) StartInstance(name string) (*instance.Process, error) } if len(im.runningInstances) >= im.instancesConfig.MaxRunningInstances && im.instancesConfig.MaxRunningInstances != -1 { - return nil, fmt.Errorf("maximum number of running instances (%d) reached", im.instancesConfig.MaxRunningInstances) + return nil, MaxRunningInstancesError(fmt.Errorf("maximum number of running instances (%d) reached", im.instancesConfig.MaxRunningInstances)) } if err := instance.Start(); err != nil { diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go index 9c5d9a9..7fe2224 100644 --- a/pkg/server/handlers.go +++ b/pkg/server/handlers.go @@ -272,6 +272,12 @@ func (h *Handler) StartInstance() http.HandlerFunc { inst, err := h.InstanceManager.StartInstance(name) 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) return } From 9d548e6dda95ac9d78c2c3ecb3be86b826a260ca Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 20:42:56 +0200 Subject: [PATCH 12/13] Remove wrong MaxRunningInstancesError type --- pkg/manager/operations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index c81a1ca..21f9727 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -44,7 +44,7 @@ func (im *instanceManager) CreateInstance(name string, options *instance.CreateI // Check max instances limit after acquiring the lock if len(im.instances) >= im.instancesConfig.MaxInstances && im.instancesConfig.MaxInstances != -1 { - return nil, MaxRunningInstancesError(fmt.Errorf("maximum number of instances (%d) reached", im.instancesConfig.MaxInstances)) + return nil, fmt.Errorf("maximum number of instances (%d) reached", im.instancesConfig.MaxInstances) } // Check if instance with this name already exists From c4ed745ba9ae03c5eb3c4a5a1417372f5cef0b1d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 28 Aug 2025 20:43:41 +0200 Subject: [PATCH 13/13] Fix comparison operators in useInstanceHealth hook --- webui/src/hooks/useInstanceHealth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webui/src/hooks/useInstanceHealth.ts b/webui/src/hooks/useInstanceHealth.ts index 2741ea4..eaa1597 100644 --- a/webui/src/hooks/useInstanceHealth.ts +++ b/webui/src/hooks/useInstanceHealth.ts @@ -7,12 +7,12 @@ export function useInstanceHealth(instanceName: string, instanceStatus: Instance const [health, setHealth] = useState() useEffect(() => { - if (instanceStatus == "stopped") { + if (instanceStatus === "stopped") { setHealth({ status: "unknown", lastChecked: new Date() }) return } - - if (instanceStatus == "failed") { + + if (instanceStatus === "failed") { setHealth({ status: instanceStatus, lastChecked: new Date() }) return }