From 019de6dc7abfd24ffe29fb5d5ec953438db8aca6 Mon Sep 17 00:00:00 2001 From: chrislu Date: Sat, 26 Jul 2025 09:22:59 -0700 Subject: [PATCH] interval field UI component --- weed/admin/handlers/maintenance_handlers.go | 72 ++------- weed/worker/tasks/balance/ui.go | 17 ++- .../tasks/erasure_coding/ec_detector.go | 97 ++++++++---- weed/worker/tasks/erasure_coding/ui.go | 138 ++++++++++-------- weed/worker/tasks/vacuum/ui.go | 42 ++++-- weed/worker/types/task_ui.go | 87 ++++++++++- 6 files changed, 284 insertions(+), 169 deletions(-) diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go index 7c5feacd6..ba5e58ad7 100644 --- a/weed/admin/handlers/maintenance_handlers.go +++ b/weed/admin/handlers/maintenance_handlers.go @@ -8,7 +8,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" - "github.com/seaweedfs/seaweedfs/weed/admin/view/components" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" @@ -124,75 +123,28 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) { return } - // Try to get templ UI provider first - temporarily disabled - // templUIProvider := getTemplUIProvider(taskType) - var configSections []components.ConfigSectionData - - // Temporarily disabled templ UI provider - // if templUIProvider != nil { - // // Use the new templ-based UI provider - // currentConfig := templUIProvider.GetCurrentConfig() - // sections, err := templUIProvider.RenderConfigSections(currentConfig) - // if err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()}) - // return - // } - // configSections = sections - // } else { - // Fallback to basic configuration for providers that haven't been migrated yet - configSections = []components.ConfigSectionData{ - { - Title: "Configuration Settings", - Icon: "fas fa-cogs", - Description: "Configure task detection and scheduling parameters", - Fields: []interface{}{ - components.CheckboxFieldData{ - FormFieldData: components.FormFieldData{ - Name: "enabled", - Label: "Enable Task", - Description: "Whether this task type should be enabled", - }, - Checked: true, - }, - components.NumberFieldData{ - FormFieldData: components.FormFieldData{ - Name: "max_concurrent", - Label: "Max Concurrent Tasks", - Description: "Maximum number of concurrent tasks", - Required: true, - }, - Value: 2, - Step: "1", - Min: floatPtr(1), - }, - components.DurationFieldData{ - FormFieldData: components.FormFieldData{ - Name: "scan_interval", - Label: "Scan Interval", - Description: "How often to scan for tasks", - Required: true, - }, - Value: "30m", - }, - }, - }, + // Get current configuration and render form using the actual UI provider + currentConfig := provider.GetCurrentConfig() + formHTML, err := provider.RenderConfigForm(currentConfig) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration form: " + err.Error()}) + return } - // } // End of disabled templ UI provider else block - // Create task configuration data using templ components - configData := &app.TaskConfigTemplData{ + // Create task configuration data using the actual form HTML + configData := &maintenance.TaskConfigData{ TaskType: taskType, TaskName: provider.GetDisplayName(), TaskIcon: provider.GetIcon(), Description: provider.GetDescription(), - ConfigSections: configSections, + ConfigFormHTML: formHTML, } - // Render HTML template using templ components + // Render HTML template c.Header("Content-Type", "text/html") - taskConfigComponent := app.TaskConfigTempl(configData) + taskConfigComponent := app.TaskConfig(configData) layoutComponent := layout.Layout(c, taskConfigComponent) - err := layoutComponent.Render(c.Request.Context(), c.Writer) + err = layoutComponent.Render(c.Request.Context(), c.Writer) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) return diff --git a/weed/worker/tasks/balance/ui.go b/weed/worker/tasks/balance/ui.go index 2cea20a76..b4d04ee8f 100644 --- a/weed/worker/tasks/balance/ui.go +++ b/weed/worker/tasks/balance/ui.go @@ -104,7 +104,7 @@ func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML true, ) - form.AddDurationField("scan_interval", "Scan Interval", "How often to scan for imbalanced volumes", secondsToDuration(config.ScanIntervalSeconds), true) + form.AddIntervalField("scan_interval", "Scan Interval", "How often to scan for imbalanced volumes", config.ScanIntervalSeconds, true) // Scheduling Settings form.AddNumberField( @@ -212,12 +212,19 @@ func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{} } // Parse scan interval - if values, ok := formData["scan_interval"]; ok && len(values) > 0 { - duration, err := time.ParseDuration(values[0]) + if values, ok := formData["scan_interval_value"]; ok && len(values) > 0 { + value, err := strconv.Atoi(values[0]) if err != nil { - return nil, fmt.Errorf("invalid scan interval: %w", err) + return nil, fmt.Errorf("invalid scan interval value: %w", err) + } + + unit := "minute" // default + if units, ok := formData["scan_interval_unit"]; ok && len(units) > 0 { + unit = units[0] } - config.ScanIntervalSeconds = int(duration.Seconds()) + + // Convert to seconds + config.ScanIntervalSeconds = types.IntervalValueUnitToSeconds(value, unit) } // Parse max concurrent diff --git a/weed/worker/tasks/erasure_coding/ec_detector.go b/weed/worker/tasks/erasure_coding/ec_detector.go index 6bcca4d64..bd303ceb3 100644 --- a/weed/worker/tasks/erasure_coding/ec_detector.go +++ b/weed/worker/tasks/erasure_coding/ec_detector.go @@ -2,6 +2,7 @@ package erasure_coding import ( "fmt" + "strings" "time" "github.com/seaweedfs/seaweedfs/weed/glog" @@ -10,11 +11,12 @@ import ( // EcDetector implements erasure coding task detection type EcDetector struct { - enabled bool - volumeAgeHours int - fullnessRatio float64 - minSizeMB int // Minimum volume size in MB before considering EC - scanInterval time.Duration + enabled bool + quietForSeconds int + fullnessRatio float64 + minSizeMB int // Minimum volume size in MB before considering EC + scanInterval time.Duration + collectionFilter string } // Compile-time interface assertions @@ -26,11 +28,12 @@ var ( // NewEcDetector creates a new erasure coding detector with configurable defaults func NewEcDetector() *EcDetector { return &EcDetector{ - enabled: true, // Enabled for testing - volumeAgeHours: 0, // No age requirement for testing (was 24) - fullnessRatio: 0.8, // 80% full by default - minSizeMB: 50, // Minimum 50MB for testing (was 100MB) - scanInterval: 30 * time.Second, // Faster scanning for testing + enabled: true, // Enabled for testing + quietForSeconds: 0, // No quiet requirement for testing (was 24) + fullnessRatio: 0.90, // 90% full by default + minSizeMB: 50, // Minimum 50MB for testing (was 100MB) + scanInterval: 30 * time.Second, // Faster scanning for testing + collectionFilter: "", // No collection filter by default } } @@ -48,11 +51,11 @@ func (d *EcDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, cl var results []*types.TaskDetectionResult now := time.Now() - ageThreshold := time.Duration(d.volumeAgeHours) * time.Hour + quietThreshold := time.Duration(d.quietForSeconds) * time.Second minSizeBytes := uint64(d.minSizeMB) * 1024 * 1024 - glog.V(2).Infof("EC detector scanning %d volumes with thresholds: age=%dh, fullness=%.2f, minSize=%dMB", - len(volumeMetrics), d.volumeAgeHours, d.fullnessRatio, d.minSizeMB) + glog.V(2).Infof("EC detector scanning %d volumes with thresholds: quietFor=%ds, fullness=%.2f, minSize=%dMB", + len(volumeMetrics), d.quietForSeconds, d.fullnessRatio, d.minSizeMB) for _, metric := range volumeMetrics { // Skip if already EC volume @@ -65,8 +68,21 @@ func (d *EcDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, cl continue } - // Check age and fullness criteria - if metric.Age >= ageThreshold && metric.FullnessRatio >= d.fullnessRatio { + // Check collection filter if specified + if d.collectionFilter != "" { + // Parse comma-separated collections + allowedCollections := make(map[string]bool) + for _, collection := range strings.Split(d.collectionFilter, ",") { + allowedCollections[strings.TrimSpace(collection)] = true + } + // Skip if volume's collection is not in the allowed list + if !allowedCollections[metric.Collection] { + continue + } + } + + // Check quiet duration and fullness criteria + if metric.Age >= quietThreshold && metric.FullnessRatio >= d.fullnessRatio { // Note: Removed read-only requirement for testing // In production, you might want to enable this: // if !metric.IsReadOnly { @@ -79,11 +95,11 @@ func (d *EcDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, cl Server: metric.Server, Collection: metric.Collection, Priority: types.TaskPriorityLow, // EC is not urgent - Reason: fmt.Sprintf("Volume meets EC criteria: age=%.1fh (>%dh), fullness=%.1f%% (>%.1f%%), size=%.1fMB (>%dMB)", - metric.Age.Hours(), d.volumeAgeHours, metric.FullnessRatio*100, d.fullnessRatio*100, + Reason: fmt.Sprintf("Volume meets EC criteria: quiet for %.1fs (>%ds), fullness=%.1f%% (>%.1f%%), size=%.1fMB (>%dMB)", + metric.Age.Seconds(), d.quietForSeconds, metric.FullnessRatio*100, d.fullnessRatio*100, float64(metric.Size)/(1024*1024), d.minSizeMB), Parameters: map[string]interface{}{ - "age_hours": int(metric.Age.Hours()), + "age_seconds": int(metric.Age.Seconds()), "fullness_ratio": metric.FullnessRatio, "size_mb": int(metric.Size / (1024 * 1024)), }, @@ -117,8 +133,8 @@ func (d *EcDetector) Configure(config map[string]interface{}) error { d.enabled = enabled } - if ageHours, ok := config["volume_age_hours"].(float64); ok { - d.volumeAgeHours = int(ageHours) + if ageSeconds, ok := config["quiet_for_seconds"].(float64); ok { + d.quietForSeconds = int(ageSeconds) } if fullnessRatio, ok := config["fullness_ratio"].(float64); ok { @@ -129,8 +145,12 @@ func (d *EcDetector) Configure(config map[string]interface{}) error { d.minSizeMB = int(minSizeMB) } - glog.V(1).Infof("EC detector configured: enabled=%v, age=%dh, fullness=%.2f, minSize=%dMB", - d.enabled, d.volumeAgeHours, d.fullnessRatio, d.minSizeMB) + if collectionFilter, ok := config["collection_filter"].(string); ok { + d.collectionFilter = collectionFilter + } + + glog.V(1).Infof("EC detector configured: enabled=%v, quietFor=%ds, fullness=%.2f, minSize=%dMB, collection_filter='%s'", + d.enabled, d.quietForSeconds, d.fullnessRatio, d.minSizeMB, d.collectionFilter) return nil } @@ -141,14 +161,26 @@ func (d *EcDetector) SetEnabled(enabled bool) { d.enabled = enabled } +func (d *EcDetector) SetVolumeAgeSeconds(seconds int) { + d.quietForSeconds = seconds +} + func (d *EcDetector) SetVolumeAgeHours(hours int) { - d.volumeAgeHours = hours + d.quietForSeconds = hours * 3600 // Convert hours to seconds +} + +func (d *EcDetector) SetQuietForSeconds(seconds int) { + d.quietForSeconds = seconds } func (d *EcDetector) SetFullnessRatio(ratio float64) { d.fullnessRatio = ratio } +func (d *EcDetector) SetCollectionFilter(filter string) { + d.collectionFilter = filter +} + func (d *EcDetector) SetScanInterval(interval time.Duration) { d.scanInterval = interval } @@ -180,9 +212,19 @@ func (d *EcDetector) ConfigureFromPolicy(policy interface{}) { } } -// GetVolumeAgeHours returns the current volume age threshold in hours +// GetVolumeAgeSeconds returns the current volume age threshold in seconds (legacy method) +func (d *EcDetector) GetVolumeAgeSeconds() int { + return d.quietForSeconds +} + +// GetVolumeAgeHours returns the current volume age threshold in hours (legacy method) func (d *EcDetector) GetVolumeAgeHours() int { - return d.volumeAgeHours + return d.quietForSeconds / 3600 // Convert seconds to hours +} + +// GetQuietForSeconds returns the current quiet duration threshold in seconds +func (d *EcDetector) GetQuietForSeconds() int { + return d.quietForSeconds } // GetFullnessRatio returns the current fullness ratio threshold @@ -190,6 +232,11 @@ func (d *EcDetector) GetFullnessRatio() float64 { return d.fullnessRatio } +// GetCollectionFilter returns the current collection filter +func (d *EcDetector) GetCollectionFilter() string { + return d.collectionFilter +} + // GetScanInterval returns the scan interval func (d *EcDetector) GetScanInterval() time.Duration { return d.scanInterval diff --git a/weed/worker/tasks/erasure_coding/ui.go b/weed/worker/tasks/erasure_coding/ui.go index e17cba89a..c61c65628 100644 --- a/weed/worker/tasks/erasure_coding/ui.go +++ b/weed/worker/tasks/erasure_coding/ui.go @@ -46,14 +46,12 @@ func (ui *UIProvider) GetIcon() string { // ErasureCodingConfig represents the erasure coding configuration type ErasureCodingConfig struct { - Enabled bool `json:"enabled"` - VolumeAgeHoursSeconds int `json:"volume_age_hours_seconds"` - FullnessRatio float64 `json:"fullness_ratio"` - ScanIntervalSeconds int `json:"scan_interval_seconds"` - MaxConcurrent int `json:"max_concurrent"` - ShardCount int `json:"shard_count"` - ParityCount int `json:"parity_count"` - CollectionFilter string `json:"collection_filter"` + Enabled bool `json:"enabled"` + QuietForSeconds int `json:"quiet_for_seconds"` + FullnessRatio float64 `json:"fullness_ratio"` + ScanIntervalSeconds int `json:"scan_interval_seconds"` + MaxConcurrent int `json:"max_concurrent"` + CollectionFilter string `json:"collection_filter"` } // Helper functions for duration conversion @@ -95,19 +93,19 @@ func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML config.Enabled, ) - form.AddNumberField( - "volume_age_hours_seconds", - "Volume Age Threshold", - "Only apply erasure coding to volumes older than this duration", - float64(config.VolumeAgeHoursSeconds), + form.AddIntervalField( + "quiet_for_seconds", + "Quiet For Duration", + "Only apply erasure coding to volumes that have not been modified for this duration", + config.QuietForSeconds, true, ) - form.AddNumberField( + form.AddIntervalField( "scan_interval_seconds", "Scan Interval", "How often to scan for volumes needing erasure coding", - float64(config.ScanIntervalSeconds), + config.ScanIntervalSeconds, true, ) @@ -120,21 +118,21 @@ func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML true, ) - // Erasure Coding Parameters + // Detection Parameters form.AddNumberField( - "shard_count", - "Data Shards", - "Number of data shards for erasure coding (recommended: 10)", - float64(config.ShardCount), + "fullness_ratio", + "Fullness Ratio (0.0-1.0)", + "Only apply erasure coding to volumes with fullness ratio above this threshold (e.g., 0.90 for 90%)", + config.FullnessRatio, true, ) - form.AddNumberField( - "parity_count", - "Parity Shards", - "Number of parity shards for erasure coding (recommended: 4)", - float64(config.ParityCount), - true, + form.AddTextField( + "collection_filter", + "Collection Filter", + "Only apply erasure coding to volumes in these collections (comma-separated, leave empty for all collections)", + config.CollectionFilter, + false, ) // Generate organized form sections using Bootstrap components @@ -168,7 +166,8 @@ func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML @@ -180,27 +179,41 @@ func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML // ParseConfigForm parses form data into configuration func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := &ErasureCodingConfig{} + config := ErasureCodingConfig{} // Parse enabled config.Enabled = len(formData["enabled"]) > 0 - // Parse volume age hours - if values, ok := formData["volume_age_hours_seconds"]; ok && len(values) > 0 { - hours, err := strconv.Atoi(values[0]) + // Parse quiet for duration + if values, ok := formData["quiet_for_seconds_value"]; ok && len(values) > 0 { + value, err := strconv.Atoi(values[0]) if err != nil { - return nil, fmt.Errorf("invalid volume age hours: %w", err) + return nil, fmt.Errorf("invalid quiet for duration value: %w", err) } - config.VolumeAgeHoursSeconds = hours + + unit := "minute" // default + if units, ok := formData["quiet_for_seconds_unit"]; ok && len(units) > 0 { + unit = units[0] + } + + // Convert to seconds using the helper function from types package + config.QuietForSeconds = types.IntervalValueUnitToSeconds(value, unit) } // Parse scan interval - if values, ok := formData["scan_interval_seconds"]; ok && len(values) > 0 { - interval, err := strconv.Atoi(values[0]) + if values, ok := formData["scan_interval_seconds_value"]; ok && len(values) > 0 { + value, err := strconv.Atoi(values[0]) if err != nil { - return nil, fmt.Errorf("invalid scan interval: %w", err) + return nil, fmt.Errorf("invalid scan interval value: %w", err) } - config.ScanIntervalSeconds = interval + + unit := "minute" // default + if units, ok := formData["scan_interval_seconds_unit"]; ok && len(units) > 0 { + unit = units[0] + } + + // Convert to seconds + config.ScanIntervalSeconds = types.IntervalValueUnitToSeconds(value, unit) } // Parse max concurrent @@ -215,28 +228,21 @@ func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{} config.MaxConcurrent = maxConcurrent } - // Parse shard count - if values, ok := formData["shard_count"]; ok && len(values) > 0 { - shardCount, err := strconv.Atoi(values[0]) + // Parse fullness ratio + if values, ok := formData["fullness_ratio"]; ok && len(values) > 0 { + fullnessRatio, err := strconv.ParseFloat(values[0], 64) if err != nil { - return nil, fmt.Errorf("invalid shard count: %w", err) + return nil, fmt.Errorf("invalid fullness ratio: %w", err) } - if shardCount < 1 { - return nil, fmt.Errorf("shard count must be at least 1") + if fullnessRatio < 0 || fullnessRatio > 1 { + return nil, fmt.Errorf("fullness ratio must be between 0.0 and 1.0") } - config.ShardCount = shardCount + config.FullnessRatio = fullnessRatio } - // Parse parity count - if values, ok := formData["parity_count"]; ok && len(values) > 0 { - parityCount, err := strconv.Atoi(values[0]) - if err != nil { - return nil, fmt.Errorf("invalid parity count: %w", err) - } - if parityCount < 1 { - return nil, fmt.Errorf("parity count must be at least 1") - } - config.ParityCount = parityCount + // Parse collection filter + if values, ok := formData["collection_filter"]; ok && len(values) > 0 { + config.CollectionFilter = values[0] } return config, nil @@ -257,7 +263,9 @@ func (ui *UIProvider) ApplyConfig(config interface{}) error { // Apply to detector if ui.detector != nil { ui.detector.SetEnabled(ecConfig.Enabled) - ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds) + ui.detector.SetQuietForSeconds(ecConfig.QuietForSeconds) + ui.detector.SetFullnessRatio(ecConfig.FullnessRatio) + ui.detector.SetCollectionFilter(ecConfig.CollectionFilter) ui.detector.SetScanInterval(secondsToDuration(ecConfig.ScanIntervalSeconds)) } @@ -267,8 +275,8 @@ func (ui *UIProvider) ApplyConfig(config interface{}) error { ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent) } - glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%v, max_concurrent=%d, shards=%d+%d", - ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent, ecConfig.ShardCount, ecConfig.ParityCount) + glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, quiet_for=%v seconds, max_concurrent=%d, fullness_ratio=%f, collection_filter=%s, shards=10+4", + ecConfig.Enabled, ecConfig.QuietForSeconds, ecConfig.MaxConcurrent, ecConfig.FullnessRatio, ecConfig.CollectionFilter) return nil } @@ -277,18 +285,20 @@ func (ui *UIProvider) ApplyConfig(config interface{}) error { func (ui *UIProvider) getCurrentECConfig() ErasureCodingConfig { config := ErasureCodingConfig{ // Default values (fallback if detectors/schedulers are nil) - Enabled: true, - VolumeAgeHoursSeconds: 24 * 3600, // 24 hours in seconds - ScanIntervalSeconds: 2 * 3600, // 2 hours in seconds - MaxConcurrent: 1, - ShardCount: 10, - ParityCount: 4, + Enabled: true, + QuietForSeconds: 24 * 3600, // Default to 24 hours in seconds + ScanIntervalSeconds: 2 * 3600, // 2 hours in seconds + MaxConcurrent: 1, + FullnessRatio: 0.90, // Default fullness ratio + CollectionFilter: "", } // Get current values from detector if ui.detector != nil { config.Enabled = ui.detector.IsEnabled() - config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours() + config.QuietForSeconds = ui.detector.GetQuietForSeconds() + config.FullnessRatio = ui.detector.GetFullnessRatio() + config.CollectionFilter = ui.detector.GetCollectionFilter() config.ScanIntervalSeconds = durationToSeconds(ui.detector.ScanInterval()) } diff --git a/weed/worker/tasks/vacuum/ui.go b/weed/worker/tasks/vacuum/ui.go index 6f67a801a..7d1eebb6d 100644 --- a/weed/worker/tasks/vacuum/ui.go +++ b/weed/worker/tasks/vacuum/ui.go @@ -101,19 +101,19 @@ func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML true, ) - form.AddDurationField( + form.AddIntervalField( "scan_interval", "Scan Interval", "How often to scan for volumes needing vacuum", - secondsToDuration(config.ScanIntervalSeconds), + config.ScanIntervalSeconds, true, ) - form.AddDurationField( + form.AddIntervalField( "min_volume_age", "Minimum Volume Age", "Only vacuum volumes older than this duration", - secondsToDuration(config.MinVolumeAgeSeconds), + config.MinVolumeAgeSeconds, true, ) @@ -189,21 +189,35 @@ func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{} } // Parse scan interval - if intervalStr := formData["scan_interval"]; len(intervalStr) > 0 { - if interval, err := time.ParseDuration(intervalStr[0]); err != nil { - return nil, fmt.Errorf("invalid scan interval: %w", err) - } else { - config.ScanIntervalSeconds = durationToSeconds(interval) + if values, ok := formData["scan_interval_value"]; ok && len(values) > 0 { + value, err := strconv.Atoi(values[0]) + if err != nil { + return nil, fmt.Errorf("invalid scan interval value: %w", err) + } + + unit := "minute" // default + if units, ok := formData["scan_interval_unit"]; ok && len(units) > 0 { + unit = units[0] } + + // Convert to seconds + config.ScanIntervalSeconds = types.IntervalValueUnitToSeconds(value, unit) } // Parse min volume age - if ageStr := formData["min_volume_age"]; len(ageStr) > 0 { - if age, err := time.ParseDuration(ageStr[0]); err != nil { - return nil, fmt.Errorf("invalid min volume age: %w", err) - } else { - config.MinVolumeAgeSeconds = durationToSeconds(age) + if values, ok := formData["min_volume_age_value"]; ok && len(values) > 0 { + value, err := strconv.Atoi(values[0]) + if err != nil { + return nil, fmt.Errorf("invalid min volume age value: %w", err) } + + unit := "minute" // default + if units, ok := formData["min_volume_age_unit"]; ok && len(units) > 0 { + unit = units[0] + } + + // Convert to seconds + config.MinVolumeAgeSeconds = types.IntervalValueUnitToSeconds(value, unit) } // Parse max concurrent diff --git a/weed/worker/types/task_ui.go b/weed/worker/types/task_ui.go index e1e2752ba..7ad5437f6 100644 --- a/weed/worker/types/task_ui.go +++ b/weed/worker/types/task_ui.go @@ -6,6 +6,40 @@ import ( "time" ) +// Helper function to convert seconds to the most appropriate interval unit +func secondsToIntervalValueUnit(totalSeconds int) (int, string) { + if totalSeconds == 0 { + return 0, "minute" + } + + // Check if it's evenly divisible by days + if totalSeconds%(24*3600) == 0 { + return totalSeconds / (24 * 3600), "day" + } + + // Check if it's evenly divisible by hours + if totalSeconds%3600 == 0 { + return totalSeconds / 3600, "hour" + } + + // Default to minutes + return totalSeconds / 60, "minute" +} + +// Helper function to convert interval value and unit to seconds +func IntervalValueUnitToSeconds(value int, unit string) int { + switch unit { + case "day": + return value * 24 * 3600 + case "hour": + return value * 3600 + case "minute": + return value * 60 + default: + return value * 60 // Default to minutes + } +} + // TaskUIProvider defines how tasks provide their configuration UI type TaskUIProvider interface { // GetTaskType returns the task type @@ -100,11 +134,14 @@ type TaskDetailsData struct { type FormField struct { Name string `json:"name"` Label string `json:"label"` - Type string `json:"type"` // text, number, checkbox, select, duration + Type string `json:"type"` // text, number, checkbox, select, duration, interval Value interface{} `json:"value"` Description string `json:"description"` Required bool `json:"required"` Options []FormOption `json:"options,omitempty"` // For select fields + // Interval-specific fields + IntervalValue int `json:"interval_value,omitempty"` // Numeric value for interval + IntervalUnit string `json:"interval_unit,omitempty"` // Unit for interval (day, hour, minute) } type FormOption struct { @@ -190,6 +227,23 @@ func (fb *FormBuilder) AddDurationField(name, label, description string, value t return fb } +// AddIntervalField adds an interval field with value and unit dropdown +func (fb *FormBuilder) AddIntervalField(name, label, description string, totalSeconds int, required bool) *FormBuilder { + // Convert seconds to the most appropriate unit + value, unit := secondsToIntervalValueUnit(totalSeconds) + + fb.fields = append(fb.fields, FormField{ + Name: name, + Label: label, + Type: "interval", + Description: description, + Required: required, + IntervalValue: value, + IntervalUnit: unit, + }) + return fb +} + // Build generates the HTML form fields with Bootstrap styling func (fb *FormBuilder) Build() template.HTML { html := "" @@ -269,6 +323,37 @@ func (fb *FormBuilder) renderField(field FormField) string { html += " required" } html += ">\n" + + case "interval": + // Create input group with number input and unit select + html += "
\n" + html += " " + unit.Label + "\n" + } + html += " \n" + html += "
\n" } // Description for non-checkbox fields