diff --git a/cmd/zfs-client/main.go b/cmd/zfs-client/main.go index 99441df..a11deb4 100644 --- a/cmd/zfs-client/main.go +++ b/cmd/zfs-client/main.go @@ -23,9 +23,16 @@ func main() { switch command { case "snap", "snapshot": // Create snapshot and send to server (auto full/incremental) + // Optional: specify dataset as argument + targetDataset := "" + if len(os.Args) > 2 { + targetDataset = os.Args[2] + fmt.Printf("→ Using dataset: %s\n", targetDataset) + } + fmt.Println("=== Creating and sending snapshot ===\n") - snapshot, err := c.CreateAndSend() + snapshot, err := c.CreateAndSend(targetDataset) if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) @@ -56,11 +63,12 @@ func main() { func printUsage() { fmt.Println("ZFS Snapshot Backup Client - Simple Version") - fmt.Println("\nUsage: zfs-client [command]") + fmt.Println("\nUsage: zfs-client [command] [dataset]") fmt.Println("\nCommands:") - fmt.Println(" snap - Create snapshot and send to server") - fmt.Println(" status - Check server connection and quota") - fmt.Println(" help - Show this help message") + fmt.Println(" snap [dataset] - Create snapshot and send to server") + fmt.Println(" If dataset not specified, uses LOCAL_DATASET from config") + fmt.Println(" status - Check server connection and quota") + fmt.Println(" help - Show this help message") fmt.Println("\nEnvironment Variables (can be set in .env file):") fmt.Println(" CLIENT_ID - Client identifier (default: client1)") fmt.Println(" API_KEY - API key for authentication (default: secret123)") @@ -68,6 +76,7 @@ func printUsage() { fmt.Println(" LOCAL_DATASET - ZFS dataset to backup (default: tank/data)") fmt.Println(" COMPRESS - Enable LZ4 compression (default: true)") fmt.Println("\nExamples:") - fmt.Println(" zfs-client snap") + fmt.Println(" zfs-client snap # Use configured dataset") + fmt.Println(" zfs-client snap tank/data # Backup specific dataset") fmt.Println(" zfs-client status") } diff --git a/internal/client/client.go b/internal/client/client.go index a8bb7cf..19c9f09 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -38,7 +38,13 @@ func New(config *Config) *Client { // It automatically detects if this is a full or incremental backup: // - If no bookmark exists, does a full backup // - If bookmark exists, does an incremental backup from the bookmark -func (c *Client) CreateAndSend() (*SnapshotResult, error) { +// If targetDataset is provided, it overrides the configured dataset. +func (c *Client) CreateAndSend(targetDataset string) (*SnapshotResult, error) { + // Use provided dataset or fall back to config + if targetDataset == "" { + targetDataset = c.config.LocalDataset + } + // Check for existing bookmark to determine backup type lastBookmark, err := c.GetLastBookmark() if err != nil { @@ -46,7 +52,7 @@ func (c *Client) CreateAndSend() (*SnapshotResult, error) { } // Create new snapshot - snapshot, err := c.CreateSnapshot() + snapshot, err := c.CreateSnapshot(targetDataset) if err != nil { return nil, fmt.Errorf("failed to create snapshot: %v", err) } @@ -55,13 +61,13 @@ func (c *Client) CreateAndSend() (*SnapshotResult, error) { if isFullBackup { fmt.Println("→ No previous backup found, doing FULL backup...") // Send as full (no base) - if err := c.SendIncrementalHTTP(snapshot, ""); err != nil { + if err := c.SendIncrementalHTTP(snapshot, targetDataset, ""); err != nil { return nil, fmt.Errorf("failed to send snapshot: %v", err) } } else { fmt.Printf("→ Found previous backup, doing INCREMENTAL from %s...\n", lastBookmark) // Send as incremental from bookmark - if err := c.SendIncrementalHTTP(snapshot, lastBookmark); err != nil { + if err := c.SendIncrementalHTTP(snapshot, targetDataset, lastBookmark); err != nil { return nil, fmt.Errorf("failed to send incremental: %v", err) } } @@ -78,8 +84,8 @@ func (c *Client) CreateAndSend() (*SnapshotResult, error) { } // CreateSnapshot creates a local ZFS snapshot of the configured dataset. -func (c *Client) CreateSnapshot() (*zfs.Dataset, error) { - ds, err := zfs.GetDataset(c.config.LocalDataset) +func (c *Client) CreateSnapshot(dataset string) (*zfs.Dataset, error) { + ds, err := zfs.GetDataset(dataset) if err != nil { return nil, fmt.Errorf("failed to get dataset: %v", err) } @@ -105,7 +111,8 @@ func (c *Client) GetSnapshotSize(snapshot *zfs.Dataset) int64 { // SendIncrementalHTTP sends a snapshot to the server via HTTP. // The server then handles storage (S3 or local ZFS). -func (c *Client) SendIncrementalHTTP(snapshot *zfs.Dataset, base string) error { +// datasetName should be the ZFS dataset being backed up (e.g., "tank/data") +func (c *Client) SendIncrementalHTTP(snapshot *zfs.Dataset, datasetName, base string) error { estimatedSize := c.GetSnapshotSize(snapshot) // Determine if this is incremental or full @@ -115,7 +122,7 @@ func (c *Client) SendIncrementalHTTP(snapshot *zfs.Dataset, base string) error { uploadReq := map[string]interface{}{ "client_id": c.config.ClientID, "api_key": c.config.APIKey, - "dataset_name": c.config.LocalDataset, + "dataset_name": datasetName, "timestamp": time.Now().Format(time.RFC3339), "compressed": c.config.Compress, "estimated_size": estimatedSize, diff --git a/internal/client/snapshot.go b/internal/client/snapshot.go index b6e081f..7818924 100644 --- a/internal/client/snapshot.go +++ b/internal/client/snapshot.go @@ -72,6 +72,6 @@ func (c *Client) GetLastSnapshot() (*zfs.Dataset, error) { } // SendIncremental is kept for API compatibility - now just calls HTTP version -func (c *Client) SendIncremental(snapshot *zfs.Dataset, base string) error { - return c.SendIncrementalHTTP(snapshot, base) +func (c *Client) SendIncremental(snapshot *zfs.Dataset, datasetName, base string) error { + return c.SendIncrementalHTTP(snapshot, datasetName, base) } diff --git a/internal/server/admin_handlers.go b/internal/server/admin_handlers.go index b9a0b5f..f6e39dd 100644 --- a/internal/server/admin_handlers.go +++ b/internal/server/admin_handlers.go @@ -8,6 +8,8 @@ import ( "fmt" "io/fs" "net/http" + "strconv" + "strings" "time" "git.ma-al.com/goc_marek/zfs/internal/server/templates/pages" @@ -518,6 +520,118 @@ func (s *Server) handleAdminGetStats(w http.ResponseWriter, r *http.Request) { }) } +// handleAdminGetDatasets returns all datasets, optionally filtered by client +func (s *Server) handleAdminGetDatasets(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clientID := r.URL.Query().Get("client_id") + + var datasets []*DatasetConfig + if clientID != "" { + datasets, _ = s.db.GetDatasetsByClient(clientID) + } else { + datasets, _ = s.db.GetAllDatasets() + } + + // Get snapshot counts for each dataset + type DatasetResponse struct { + ID int64 `json:"id"` + ClientID string `json:"client_id"` + DatasetName string `json:"dataset_name"` + StorageType string `json:"storage_type"` + Enabled bool `json:"enabled"` + SnapshotCount int `json:"snapshot_count"` + } + + response := make([]DatasetResponse, len(datasets)) + for i, d := range datasets { + snapshotCount, _ := s.db.GetSnapshotCountByDataset(d.ClientID, d.DatasetName) + response[i] = DatasetResponse{ + ID: d.ID, + ClientID: d.ClientID, + DatasetName: d.DatasetName, + StorageType: d.StorageType, + Enabled: d.Enabled, + SnapshotCount: snapshotCount, + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// handleAdminUpdateDeleteDataset handles PUT and DELETE for a specific dataset +func (s *Server) handleAdminUpdateDeleteDataset(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Extract dataset ID from URL + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 4 { + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + datasetID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) + if err != nil { + http.Error(w, "Invalid dataset ID", http.StatusBadRequest) + return + } + + // Get dataset from database + dataset, err := s.db.GetDatasetByID(datasetID) + if err != nil || dataset == nil { + http.Error(w, "Dataset not found", http.StatusNotFound) + return + } + + if r.Method == http.MethodDelete { + // Delete dataset + if err := s.db.DeleteDataset(datasetID); err != nil { + http.Error(w, "Failed to delete dataset", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Dataset deleted successfully", + }) + return + } + + if r.Method == http.MethodPut { + // Update dataset + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + dataset.Enabled = req.Enabled + if err := s.db.SaveDataset(dataset); err != nil { + http.Error(w, "Failed to update dataset", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Dataset updated successfully", + }) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + // Admin management handlers // handleAdminGetAdmins returns all admins diff --git a/internal/server/admin_html.go b/internal/server/admin_html.go index 3d5765f..3ee46ee 100644 --- a/internal/server/admin_html.go +++ b/internal/server/admin_html.go @@ -92,6 +92,7 @@ const adminPanelHTML = `
| Client | +Dataset Name | +Storage Type | +Status | +Snapshots | +Actions | +
|---|