diff --git a/install.sh b/install.sh index d10aedf..3093362 100755 --- a/install.sh +++ b/install.sh @@ -438,7 +438,16 @@ MAINGO chmod 755 "$INSTALL_DIR/bin/atlas-api" chmod 755 "$INSTALL_DIR/bin/atlas-tui" - echo -e "${GREEN}Binaries built successfully${NC}" + # Create symlinks in /usr/local/bin for global access + echo -e "${GREEN}Creating symlinks for global access...${NC}" + ln -sf "$INSTALL_DIR/bin/atlas-api" /usr/local/bin/atlas-api + ln -sf "$INSTALL_DIR/bin/atlas-tui" /usr/local/bin/atlas-tui + chmod 755 /usr/local/bin/atlas-api + chmod 755 /usr/local/bin/atlas-tui + + echo -e "${GREEN}Binaries built and installed successfully${NC}" + echo " Binaries available at: $INSTALL_DIR/bin/" + echo " Global commands: atlas-api, atlas-tui" } # Create systemd service diff --git a/internal/tui/app.go b/internal/tui/app.go index 439ea47..25f2074 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -5,13 +5,15 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "strings" ) // App represents the TUI application type App struct { - client *APIClient - reader *bufio.Reader + client *APIClient + reader *bufio.Reader + currentUser map[string]interface{} } // NewApp creates a new TUI application @@ -47,6 +49,10 @@ func (a *App) Run() error { a.handleSystemMenu() case "5": a.handleBackupMenu() + case "6": + a.handleUserMenu() + case "7": + a.handleServiceMenu() case "0", "q", "exit": fmt.Println("Goodbye!") return nil @@ -67,11 +73,30 @@ func (a *App) login() error { username := a.readInput("Username: ") password := a.readPassword("Password: ") - token, err := a.client.Login(username, password) + token, user, err := a.client.Login(username, password) if err != nil { return err } + // Store current user info + if user != nil { + a.currentUser = user + } else { + // Fallback: get user info from API + data, err := a.client.Get("/api/v1/users") + if err == nil { + var users []map[string]interface{} + if json.Unmarshal(data, &users) == nil { + for _, u := range users { + if uname, ok := u["username"].(string); ok && uname == username { + a.currentUser = u + break + } + } + } + } + } + fmt.Println("Login successful!") _ = token return nil @@ -100,6 +125,8 @@ func (a *App) showMainMenu() { fmt.Println("3. Snapshots") fmt.Println("4. System Information") fmt.Println("5. Backup & Restore") + fmt.Println("6. User Management") + fmt.Println("7. Service Management") fmt.Println("0. Exit") fmt.Println() } @@ -109,9 +136,20 @@ func (a *App) handleZFSMenu() { for { fmt.Println("\n=== ZFS Management ===") fmt.Println("1. List Pools") - fmt.Println("2. List Datasets") - fmt.Println("3. List ZVOLs") - fmt.Println("4. List Disks") + fmt.Println("2. Create Pool") + fmt.Println("3. Delete Pool") + fmt.Println("4. Import Pool") + fmt.Println("5. Export Pool") + fmt.Println("6. List Available Pools") + fmt.Println("7. Start Scrub") + fmt.Println("8. Get Scrub Status") + fmt.Println("9. List Datasets") + fmt.Println("10. Create Dataset") + fmt.Println("11. Delete Dataset") + fmt.Println("12. List ZVOLs") + fmt.Println("13. Create ZVOL") + fmt.Println("14. Delete ZVOL") + fmt.Println("15. List Disks") fmt.Println("0. Back") fmt.Println() @@ -121,10 +159,32 @@ func (a *App) handleZFSMenu() { case "1": a.listPools() case "2": - a.listDatasets() + a.createPool() case "3": - a.listZVOLs() + a.deletePool() case "4": + a.importPool() + case "5": + a.exportPool() + case "6": + a.listAvailablePools() + case "7": + a.startScrub() + case "8": + a.getScrubStatus() + case "9": + a.listDatasets() + case "10": + a.createDataset() + case "11": + a.deleteDataset() + case "12": + a.listZVOLs() + case "13": + a.createZVOL() + case "14": + a.deleteZVOL() + case "15": a.listDisks() case "0": return @@ -887,3 +947,672 @@ func (a *App) restoreBackup() { } } } + +// ===== ZFS Pool CRUD Operations ===== + +// createPool creates a new ZFS pool +func (a *App) createPool() { + name := a.readInput("Pool name: ") + vdevsStr := a.readInput("VDEVs (comma-separated, e.g., /dev/sdb,/dev/sdc): ") + + vdevs := []string{} + if vdevsStr != "" { + vdevs = strings.Split(vdevsStr, ",") + for i := range vdevs { + vdevs[i] = strings.TrimSpace(vdevs[i]) + } + } + + reqBody := map[string]interface{}{ + "name": name, + "vdevs": vdevs, + } + + data, err := a.client.Post("/api/v1/pools", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("Pool created successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if name, ok := result["name"].(string); ok { + fmt.Printf("Pool: %s\n", name) + } + } +} + +// deletePool deletes a ZFS pool +func (a *App) deletePool() { + a.listPools() + name := a.readInput("Pool name to delete: ") + + confirm := a.readInput("WARNING: This will destroy the pool and all data! Type 'yes' to confirm: ") + if confirm != "yes" { + fmt.Println("Deletion cancelled.") + return + } + + err := a.client.Delete("/api/v1/pools/" + name) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Pool %s deleted successfully!\n", name) +} + +// importPool imports a ZFS pool +func (a *App) importPool() { + a.listAvailablePools() + name := a.readInput("Pool name to import: ") + + readonly := a.readInput("Import as read-only? (yes/no, default: no): ") + + reqBody := map[string]interface{}{ + "name": name, + } + + if readonly == "yes" { + reqBody["options"] = map[string]string{ + "readonly": "on", + } + } + + data, err := a.client.Post("/api/v1/pools/import", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("Pool imported successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if msg, ok := result["message"].(string); ok { + fmt.Printf("%s\n", msg) + } + } +} + +// exportPool exports a ZFS pool +func (a *App) exportPool() { + a.listPools() + name := a.readInput("Pool name to export: ") + + forceStr := a.readInput("Force export? (yes/no, default: no): ") + force := forceStr == "yes" + + reqBody := map[string]interface{}{ + "force": force, + } + + data, err := a.client.Post("/api/v1/pools/"+name+"/export", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("Pool exported successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if msg, ok := result["message"].(string); ok { + fmt.Printf("%s\n", msg) + } + } +} + +// listAvailablePools lists pools available for import +func (a *App) listAvailablePools() { + data, err := a.client.Get("/api/v1/pools/available") + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + fmt.Printf("Error parsing response: %v\n", err) + return + } + + fmt.Println("\n=== Available Pools (for import) ===") + if pools, ok := result["pools"].([]interface{}); ok { + if len(pools) == 0 { + fmt.Println("No pools available for import.") + return + } + for i, pool := range pools { + fmt.Printf("%d. %v\n", i+1, pool) + } + } else { + fmt.Println("No pools available for import.") + } +} + +// startScrub starts a scrub operation on a pool +func (a *App) startScrub() { + a.listPools() + name := a.readInput("Pool name to scrub: ") + + data, err := a.client.Post("/api/v1/pools/"+name+"/scrub", map[string]interface{}{}) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("Scrub started successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if msg, ok := result["message"].(string); ok { + fmt.Printf("%s\n", msg) + } + } +} + +// getScrubStatus gets scrub status for a pool +func (a *App) getScrubStatus() { + a.listPools() + name := a.readInput("Pool name: ") + + data, err := a.client.Get("/api/v1/pools/" + name + "/scrub") + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + var status map[string]interface{} + if err := json.Unmarshal(data, &status); err != nil { + fmt.Printf("Error parsing response: %v\n", err) + return + } + + fmt.Println("\n=== Scrub Status ===") + if state, ok := status["state"].(string); ok { + fmt.Printf("State: %s\n", state) + } + if progress, ok := status["progress"].(float64); ok { + fmt.Printf("Progress: %.2f%%\n", progress) + } + if elapsed, ok := status["elapsed"].(string); ok { + fmt.Printf("Elapsed: %s\n", elapsed) + } + if remaining, ok := status["remaining"].(string); ok { + fmt.Printf("Remaining: %s\n", remaining) + } + if speed, ok := status["speed"].(string); ok { + fmt.Printf("Speed: %s\n", speed) + } + if errors, ok := status["errors"].(float64); ok { + fmt.Printf("Errors: %.0f\n", errors) + } +} + +// createDataset creates a new dataset +func (a *App) createDataset() { + name := a.readInput("Dataset name (e.g., pool/dataset): ") + quota := a.readInput("Quota (optional, e.g., 10G): ") + compression := a.readInput("Compression (optional, e.g., lz4, zstd): ") + + reqBody := map[string]interface{}{ + "name": name, + } + + if quota != "" { + reqBody["quota"] = quota + } + if compression != "" { + reqBody["compression"] = compression + } + + data, err := a.client.Post("/api/v1/datasets", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("Dataset created successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if name, ok := result["name"].(string); ok { + fmt.Printf("Dataset: %s\n", name) + } + } +} + +// deleteDataset deletes a dataset +func (a *App) deleteDataset() { + a.listDatasets() + name := a.readInput("Dataset name to delete: ") + + confirm := a.readInput("Delete dataset? (yes/no): ") + if confirm != "yes" { + fmt.Println("Deletion cancelled.") + return + } + + err := a.client.Delete("/api/v1/datasets/" + name) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Dataset %s deleted successfully!\n", name) +} + +// createZVOL creates a new ZVOL +func (a *App) createZVOL() { + name := a.readInput("ZVOL name (e.g., pool/zvol): ") + sizeStr := a.readInput("Size (e.g., 10G): ") + + reqBody := map[string]interface{}{ + "name": name, + "size": sizeStr, + } + + data, err := a.client.Post("/api/v1/zvols", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("ZVOL created successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if name, ok := result["name"].(string); ok { + fmt.Printf("ZVOL: %s\n", name) + } + } +} + +// deleteZVOL deletes a ZVOL +func (a *App) deleteZVOL() { + a.listZVOLs() + name := a.readInput("ZVOL name to delete: ") + + confirm := a.readInput("Delete ZVOL? (yes/no): ") + if confirm != "yes" { + fmt.Println("Deletion cancelled.") + return + } + + err := a.client.Delete("/api/v1/zvols/" + name) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("ZVOL %s deleted successfully!\n", name) +} + +// ===== User Management ===== + +// handleUserMenu handles user management menu +func (a *App) handleUserMenu() { + for { + fmt.Println("\n=== User Management ===") + fmt.Println("1. List Users") + fmt.Println("2. Create User") + fmt.Println("3. Update User") + fmt.Println("4. Delete User") + fmt.Println("5. Change Password") + fmt.Println("0. Back") + fmt.Println() + + choice := a.readInput("Select option: ") + + switch choice { + case "1": + a.listUsers() + case "2": + a.createUser() + case "3": + a.updateUser() + case "4": + a.deleteUser() + case "5": + a.changePassword() + case "0": + return + default: + fmt.Println("Invalid option.") + } + } +} + +// listUsers lists all users +func (a *App) listUsers() { + data, err := a.client.Get("/api/v1/users") + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + var users []map[string]interface{} + if err := json.Unmarshal(data, &users); err != nil { + fmt.Printf("Error parsing response: %v\n", err) + return + } + + fmt.Println("\n=== Users ===") + if len(users) == 0 { + fmt.Println("No users found.") + return + } + + for i, user := range users { + fmt.Printf("%d. %s\n", i+1, user["username"]) + if email, ok := user["email"].(string); ok && email != "" { + fmt.Printf(" Email: %s\n", email) + } + if role, ok := user["role"].(string); ok { + fmt.Printf(" Role: %s\n", role) + } + if active, ok := user["active"].(bool); ok { + fmt.Printf(" Active: %v\n", active) + } + fmt.Println() + } +} + +// createUser creates a new user +func (a *App) createUser() { + username := a.readInput("Username: ") + email := a.readInput("Email (optional): ") + password := a.readPassword("Password: ") + role := a.readInput("Role (Administrator/Operator/Viewer, default: Viewer): ") + + if role == "" { + role = "Viewer" + } + + reqBody := map[string]interface{}{ + "username": username, + "password": password, + "role": role, + } + + if email != "" { + reqBody["email"] = email + } + + data, err := a.client.Post("/api/v1/users", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("User created successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if username, ok := result["username"].(string); ok { + fmt.Printf("User: %s\n", username) + } + } +} + +// updateUser updates a user +func (a *App) updateUser() { + a.listUsers() + userID := a.readInput("User ID: ") + + email := a.readInput("New email (press Enter to skip): ") + role := a.readInput("New role (Administrator/Operator/Viewer, press Enter to skip): ") + activeStr := a.readInput("Active (true/false, press Enter to skip): ") + + reqBody := map[string]interface{}{} + + if email != "" { + reqBody["email"] = email + } + if role != "" { + reqBody["role"] = role + } + if activeStr != "" { + reqBody["active"] = activeStr == "true" + } + + if len(reqBody) == 0 { + fmt.Println("No changes specified.") + return + } + + data, err := a.client.Put("/api/v1/users/"+userID, reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("User updated successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if username, ok := result["username"].(string); ok { + fmt.Printf("User: %s\n", username) + } + } +} + +// deleteUser deletes a user +func (a *App) deleteUser() { + a.listUsers() + userID := a.readInput("User ID to delete: ") + + confirm := a.readInput("Delete user? (yes/no): ") + if confirm != "yes" { + fmt.Println("Deletion cancelled.") + return + } + + err := a.client.Delete("/api/v1/users/" + userID) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("User %s deleted successfully!\n", userID) +} + +// changePassword changes the current user's password +func (a *App) changePassword() { + // Get user ID from current user or ask for username + var userID string + if a.currentUser != nil { + if id, ok := a.currentUser["id"].(string); ok { + userID = id + } + } + + if userID == "" { + // Fallback: ask for username and look it up + username := a.readInput("Your username: ") + data, err := a.client.Get("/api/v1/users") + if err != nil { + fmt.Printf("Error getting users: %v\n", err) + return + } + + var users []map[string]interface{} + if err := json.Unmarshal(data, &users); err != nil { + fmt.Printf("Error parsing users: %v\n", err) + return + } + + for _, user := range users { + if u, ok := user["username"].(string); ok && u == username { + if id, ok := user["id"].(string); ok { + userID = id + break + } + } + } + + if userID == "" { + fmt.Printf("User '%s' not found.\n", username) + return + } + } + + oldPassword := a.readPassword("Current password: ") + newPassword := a.readPassword("New password: ") + confirmPassword := a.readPassword("Confirm new password: ") + + if newPassword != confirmPassword { + fmt.Println("Passwords do not match.") + return + } + + reqBody := map[string]interface{}{ + "old_password": oldPassword, + "new_password": newPassword, + } + + data, err := a.client.Put("/api/v1/users/"+userID+"/password", reqBody) + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("Password changed successfully!") + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err == nil { + if msg, ok := result["message"].(string); ok { + fmt.Printf("%s\n", msg) + } + } +} + +// ===== Service Management ===== + +// handleServiceMenu handles service management menu +func (a *App) handleServiceMenu() { + for { + fmt.Println("\n=== Service Management ===") + fmt.Println("1. Service Status") + fmt.Println("2. Start Service") + fmt.Println("3. Stop Service") + fmt.Println("4. Restart Service") + fmt.Println("5. Reload Service") + fmt.Println("6. View Service Logs") + fmt.Println("0. Back") + fmt.Println() + + choice := a.readInput("Select option: ") + + switch choice { + case "1": + a.serviceStatus() + case "2": + a.serviceStart() + case "3": + a.serviceStop() + case "4": + a.serviceRestart() + case "5": + a.serviceReload() + case "6": + a.serviceLogs() + case "0": + return + default: + fmt.Println("Invalid option.") + } + } +} + +// serviceStatus shows service status +func (a *App) serviceStatus() { + fmt.Println("\n=== Service Status ===") + fmt.Println("Checking atlas-api service status...") + + // Use systemctl to check status + cmd := exec.Command("systemctl", "status", "atlas-api", "--no-pager") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error checking status: %v\n", err) + fmt.Println(string(output)) + return + } + + fmt.Println(string(output)) +} + +// serviceStart starts the service +func (a *App) serviceStart() { + fmt.Println("\n=== Start Service ===") + + cmd := exec.Command("systemctl", "start", "atlas-api") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error starting service: %v\n", err) + fmt.Println(string(output)) + return + } + + fmt.Println("Service started successfully!") +} + +// serviceStop stops the service +func (a *App) serviceStop() { + fmt.Println("\n=== Stop Service ===") + + cmd := exec.Command("systemctl", "stop", "atlas-api") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error stopping service: %v\n", err) + fmt.Println(string(output)) + return + } + + fmt.Println("Service stopped successfully!") +} + +// serviceRestart restarts the service +func (a *App) serviceRestart() { + fmt.Println("\n=== Restart Service ===") + + cmd := exec.Command("systemctl", "restart", "atlas-api") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error restarting service: %v\n", err) + fmt.Println(string(output)) + return + } + + fmt.Println("Service restarted successfully!") +} + +// serviceReload reloads the service +func (a *App) serviceReload() { + fmt.Println("\n=== Reload Service ===") + + cmd := exec.Command("systemctl", "reload", "atlas-api") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error reloading service: %v\n", err) + fmt.Println(string(output)) + return + } + + fmt.Println("Service reloaded successfully!") +} + +// serviceLogs shows service logs +func (a *App) serviceLogs() { + linesStr := a.readInput("Number of log lines (default: 50): ") + lines := "50" + if linesStr != "" { + lines = linesStr + } + + fmt.Printf("\n=== Service Logs (last %s lines) ===\n", lines) + + cmd := exec.Command("journalctl", "-u", "atlas-api", "-n", lines, "--no-pager") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error viewing logs: %v\n", err) + fmt.Println(string(output)) + return + } + + fmt.Println(string(output)) +} diff --git a/internal/tui/client.go b/internal/tui/client.go index 6e1995d..98c5868 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -31,8 +31,8 @@ func (c *APIClient) SetToken(token string) { c.token = token } -// Login authenticates with the API -func (c *APIClient) Login(username, password string) (string, error) { +// Login authenticates with the API and returns user info +func (c *APIClient) Login(username, password string) (string, map[string]interface{}, error) { reqBody := map[string]string{ "username": username, "password": password, @@ -40,39 +40,46 @@ func (c *APIClient) Login(username, password string) (string, error) { body, err := json.Marshal(reqBody) if err != nil { - return "", err + return "", nil, err } req, err := http.NewRequest("POST", c.baseURL+"/api/v1/auth/login", bytes.NewReader(body)) if err != nil { - return "", err + return "", nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { - return "", fmt.Errorf("request failed: %w", err) + return "", nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("login failed: %s", string(body)) + return "", nil, fmt.Errorf("login failed: %s", string(body)) } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err + return "", nil, err } token, ok := result["token"].(string) if !ok { - return "", fmt.Errorf("no token in response") + return "", nil, fmt.Errorf("no token in response") } c.SetToken(token) - return token, nil + + // Extract user info if available + var user map[string]interface{} + if u, ok := result["user"].(map[string]interface{}); ok { + user = u + } + + return token, user, nil } // Get performs a GET request @@ -163,3 +170,38 @@ func (c *APIClient) Delete(path string) error { return nil } + +// Put performs a PUT request +func (c *APIClient) Put(path string, data interface{}) ([]byte, error) { + body, err := json.Marshal(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +}