package main import ( "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "strings" ) type InstanceInfo struct { ID string `json:"id"` TemplateName string `json:"templateName,omitempty"` Port int `json:"port"` Status string `json:"status"` } func instancesFile(dataDir string) string { return filepath.Join(dataDir, "instances.json") } func loadInstances(dataDir string) (map[string]*InstanceInfo, error) { f := instancesFile(dataDir) data, err := os.ReadFile(f) if err != nil { if os.IsNotExist(err) { return make(map[string]*InstanceInfo), nil } return nil, err } var list []*InstanceInfo if err := json.Unmarshal(data, &list); err != nil { return nil, err } m := make(map[string]*InstanceInfo) for _, inst := range list { m[inst.ID] = inst } return m, nil } func saveInstances(dataDir string, instances map[string]*InstanceInfo) error { if err := os.MkdirAll(dataDir, 0755); err != nil { return err } list := make([]*InstanceInfo, 0, len(instances)) for _, inst := range instances { list = append(list, inst) } data, err := json.MarshalIndent(list, "", " ") if err != nil { return err } return os.WriteFile(instancesFile(dataDir), data, 0644) } func upsertInstance(dataDir string, inst *InstanceInfo) error { instances, err := loadInstances(dataDir) if err != nil { return err } instances[inst.ID] = inst return saveInstances(dataDir, instances) } func removeInstance(dataDir, instanceID string) error { instances, err := loadInstances(dataDir) if err != nil { return err } delete(instances, instanceID) return saveInstances(dataDir, instances) } func instanceURL(inst *InstanceInfo) string { return fmt.Sprintf("http://localhost:%d", inst.Port) } func getInstanceStatus(dataDir, instanceID string) string { dir := instanceDir(dataDir, instanceID) composeFile := filepath.Join(dir, "docker-compose.yml") if _, err := os.Stat(composeFile); err != nil { return "stopped" } engine := getContainerEngine() // Try modern JSON format first cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json") hideWindow(cmd) out, err := cmd.Output() if err == nil { outStr := strings.TrimSpace(string(out)) if outStr == "" || outStr == "[]" { return "stopped" } if strings.HasPrefix(outStr, "[") { var containers []map[string]interface{} if err := json.Unmarshal(out, &containers); err == nil { for _, c := range containers { state, _ := c["State"].(string) if state == "running" { return "running" } } return "stopped" } } else { var c map[string]interface{} if err := json.Unmarshal(out, &c); err == nil { state, _ := c["State"].(string) if state == "running" { return "running" } return "stopped" } } } // Fallback: use "ps -q" which is supported by all docker-compose versions cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q") hideWindow(cmd) out, err = cmd.Output() if err != nil { return "error" } if strings.TrimSpace(string(out)) != "" { return "running" } return "stopped" } // cleanupOrphanInstanceDirs removes instance directories that have no entry in // instances.json. This typically happens on Windows when a delete operation // could not fully remove the directory because compose.log was locked. func cleanupOrphanInstanceDirs(dataDir string) { instancesDir := filepath.Join(dataDir, "instances") inst, err := loadInstances(dataDir) if err != nil { log.Printf("cleanupOrphanInstanceDirs: loadInstances error: %v", err) return } entries, err := os.ReadDir(instancesDir) if err != nil { if !os.IsNotExist(err) { log.Printf("cleanupOrphanInstanceDirs: ReadDir error: %v", err) } return } for _, entry := range entries { if !entry.IsDir() { continue } if _, ok := inst[entry.Name()]; !ok { dir := filepath.Join(instancesDir, entry.Name()) log.Printf("cleanupOrphanInstanceDirs: removing orphan directory %s", dir) if err := os.RemoveAll(dir); err != nil { log.Printf("cleanupOrphanInstanceDirs: RemoveAll error for %s: %v", dir, err) } } } }