diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go index 137b6a148..8b5c25577 100644 --- a/weed/admin/handlers/maintenance_handlers.go +++ b/weed/admin/handlers/maintenance_handlers.go @@ -1,16 +1,24 @@ package handlers import ( + "fmt" "net/http" + "reflect" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/seaweedfs/seaweedfs/weed/admin/config" "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/layout" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -161,19 +169,10 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) { } } -// UpdateTaskConfig updates configuration for a specific task type +// UpdateTaskConfig updates task configuration from form func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) { taskTypeName := c.Param("taskType") - - // Get the task type - taskType := maintenance.GetMaintenanceTaskType(taskTypeName) - if taskType == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "Task type not found"}) - return - } - - // Try to get templ UI provider first - temporarily disabled - // templUIProvider := getTemplUIProvider(taskType) + taskType := types.TaskType(taskTypeName) // Parse form data err := c.Request.ParseForm() @@ -182,31 +181,51 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) { return } - // Convert form data to map - formData := make(map[string][]string) - for key, values := range c.Request.PostForm { - formData[key] = values + // Get the task configuration schema + schema := tasks.GetTaskConfigSchema(taskTypeName) + if schema == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Schema not found for task type: " + taskTypeName}) + return } + // Create a new config instance based on task type and apply schema defaults var config interface{} + switch taskType { + case types.TaskTypeVacuum: + config = &vacuum.VacuumConfig{} + case types.TaskTypeBalance: + config = &balance.BalanceConfig{} + case types.TaskTypeErasureCoding: + config = &erasure_coding.ErasureCodingConfig{} + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported task type: " + taskTypeName}) + return + } - // Temporarily disabled templ UI provider - // if templUIProvider != nil { - // // Use the new templ-based UI provider - // config, err = templUIProvider.ParseConfigForm(formData) - // if err != nil { - // c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) - // return - // } - // // Apply configuration using templ provider - // err = templUIProvider.ApplyConfig(config) - // if err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) - // return - // } - // } else { - // Fallback to old UI provider for tasks that haven't been migrated yet - // Fallback to old UI provider for tasks that haven't been migrated yet + // Apply schema defaults first + if err := schema.ApplyDefaults(config); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply defaults: " + err.Error()}) + return + } + + // Parse form data using schema-based approach + err = h.parseTaskConfigFromForm(c.Request.PostForm, schema, config) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) + return + } + + // Validate the configuration + if validationErrors := schema.ValidateConfig(config); len(validationErrors) > 0 { + errorMessages := make([]string, len(validationErrors)) + for i, err := range validationErrors { + errorMessages[i] = err.Error() + } + c.JSON(http.StatusBadRequest, gin.H{"error": "Configuration validation failed", "details": errorMessages}) + return + } + + // Apply configuration using UIProvider uiRegistry := tasks.GetGlobalUIRegistry() typesRegistry := tasks.GetGlobalTypesRegistry() @@ -223,25 +242,123 @@ func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) { return } - // Parse configuration from form using old provider - config, err = provider.ParseConfigForm(formData) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()}) - return - } - - // Apply configuration using old provider + // Apply configuration using provider err = provider.ApplyConfig(config) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) return } - // } // End of disabled templ UI provider else block // Redirect back to task configuration page c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName) } +// parseTaskConfigFromForm parses form data using schema definitions +func (h *MaintenanceHandlers) parseTaskConfigFromForm(formData map[string][]string, schema *tasks.TaskConfigSchema, config interface{}) error { + configValue := reflect.ValueOf(config) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + if configValue.Kind() != reflect.Struct { + return fmt.Errorf("config must be a struct or pointer to struct") + } + + configType := configValue.Type() + + for i := 0; i < configValue.NumField(); i++ { + field := configValue.Field(i) + fieldType := configType.Field(i) + + // Get JSON tag name + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" { + continue + } + + // Remove options like ",omitempty" + if commaIdx := strings.Index(jsonTag, ","); commaIdx > 0 { + jsonTag = jsonTag[:commaIdx] + } + + // Find corresponding schema field + schemaField := schema.GetFieldByName(jsonTag) + if schemaField == nil { + continue + } + + // Parse value based on field type + if err := h.parseFieldFromForm(formData, schemaField, field); err != nil { + return fmt.Errorf("error parsing field %s: %w", schemaField.DisplayName, err) + } + } + + return nil +} + +// parseFieldFromForm parses a single field value from form data +func (h *MaintenanceHandlers) parseFieldFromForm(formData map[string][]string, schemaField *config.Field, fieldValue reflect.Value) error { + if !fieldValue.CanSet() { + return nil + } + + switch schemaField.Type { + case config.FieldTypeBool: + // Checkbox fields - present means true, absent means false + _, exists := formData[schemaField.JSONName] + fieldValue.SetBool(exists) + + case config.FieldTypeInt: + if values, ok := formData[schemaField.JSONName]; ok && len(values) > 0 { + if intVal, err := strconv.Atoi(values[0]); err != nil { + return fmt.Errorf("invalid integer value: %s", values[0]) + } else { + fieldValue.SetInt(int64(intVal)) + } + } + + case config.FieldTypeFloat: + if values, ok := formData[schemaField.JSONName]; ok && len(values) > 0 { + if floatVal, err := strconv.ParseFloat(values[0], 64); err != nil { + return fmt.Errorf("invalid float value: %s", values[0]) + } else { + fieldValue.SetFloat(floatVal) + } + } + + case config.FieldTypeString: + if values, ok := formData[schemaField.JSONName]; ok && len(values) > 0 { + fieldValue.SetString(values[0]) + } + + case config.FieldTypeInterval: + // Parse interval fields with value + unit + valueKey := schemaField.JSONName + "_value" + unitKey := schemaField.JSONName + "_unit" + + if valueStrs, ok := formData[valueKey]; ok && len(valueStrs) > 0 { + value, err := strconv.Atoi(valueStrs[0]) + if err != nil { + return fmt.Errorf("invalid interval value: %s", valueStrs[0]) + } + + unit := "minutes" // default + if unitStrs, ok := formData[unitKey]; ok && len(unitStrs) > 0 { + unit = unitStrs[0] + } + + // Convert to seconds + seconds := config.IntervalValueUnitToSeconds(value, unit) + fieldValue.SetInt(int64(seconds)) + } + + default: + return fmt.Errorf("unsupported field type: %s", schemaField.Type) + } + + return nil +} + // UpdateMaintenanceConfig updates maintenance configuration from form func (h *MaintenanceHandlers) UpdateMaintenanceConfig(c *gin.Context) { var config maintenance.MaintenanceConfig diff --git a/weed/worker/tasks/balance/ui.go b/weed/worker/tasks/balance/ui.go index b4d04ee8f..e7c8496f2 100644 --- a/weed/worker/tasks/balance/ui.go +++ b/weed/worker/tasks/balance/ui.go @@ -1,12 +1,10 @@ package balance import ( - "fmt" - "html/template" - "strconv" "time" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -44,253 +42,69 @@ func (ui *UIProvider) GetIcon() string { return "fas fa-balance-scale text-secondary" } -// BalanceConfig represents the balance configuration +// BalanceConfig represents the balance configuration matching the schema type BalanceConfig struct { Enabled bool `json:"enabled"` ImbalanceThreshold float64 `json:"imbalance_threshold"` ScanIntervalSeconds int `json:"scan_interval_seconds"` MaxConcurrent int `json:"max_concurrent"` MinServerCount int `json:"min_server_count"` - MoveDuringOffHours bool `json:"move_during_off_hours"` - OffHoursStart string `json:"off_hours_start"` - OffHoursEnd string `json:"off_hours_end"` - MinIntervalSeconds int `json:"min_interval_seconds"` } -// Helper functions for duration conversion -func secondsToDuration(seconds int) time.Duration { - return time.Duration(seconds) * time.Second -} - -func durationToSeconds(d time.Duration) int { - return int(d.Seconds()) -} - -// formatDurationForUser formats seconds as a user-friendly duration string -func formatDurationForUser(seconds int) string { - d := secondsToDuration(seconds) - if d < time.Minute { - return fmt.Sprintf("%ds", seconds) - } - if d < time.Hour { - return fmt.Sprintf("%.0fm", d.Minutes()) - } - if d < 24*time.Hour { - return fmt.Sprintf("%.1fh", d.Hours()) - } - return fmt.Sprintf("%.1fd", d.Hours()/24) -} - -// RenderConfigForm renders the configuration form HTML -func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML, error) { - config := ui.getCurrentBalanceConfig() - - // Build form using the FormBuilder helper - form := types.NewFormBuilder() - - // Detection Settings - form.AddCheckboxField( - "enabled", - "Enable Balance Tasks", - "Whether balance tasks should be automatically created", - config.Enabled, - ) - - form.AddNumberField( - "imbalance_threshold", - "Imbalance Threshold (%)", - "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)", - config.ImbalanceThreshold, - true, - ) - - form.AddIntervalField("scan_interval", "Scan Interval", "How often to scan for imbalanced volumes", config.ScanIntervalSeconds, true) - - // Scheduling Settings - form.AddNumberField( - "max_concurrent", - "Max Concurrent Tasks", - "Maximum number of balance tasks that can run simultaneously", - float64(config.MaxConcurrent), - true, - ) - - form.AddNumberField( - "min_server_count", - "Minimum Server Count", - "Only balance when at least this many servers are available", - float64(config.MinServerCount), - true, - ) - - // Timing Settings - form.AddCheckboxField( - "move_during_off_hours", - "Restrict to Off-Hours", - "Only perform balance operations during off-peak hours", - config.MoveDuringOffHours, - ) - - form.AddTextField( - "off_hours_start", - "Off-Hours Start Time", - "Start time for off-hours window (e.g., 23:00)", - config.OffHoursStart, - false, - ) - - form.AddTextField( - "off_hours_end", - "Off-Hours End Time", - "End time for off-hours window (e.g., 06:00)", - config.OffHoursEnd, - false, - ) - - // Timing constraints - form.AddDurationField("min_interval", "Min Interval", "Minimum time between balance operations", secondsToDuration(config.MinIntervalSeconds), true) - - // Generate organized form sections using Bootstrap components - html := ` -
-
-
-
-
- - Balance Configuration -
-
-
-` + string(form.Build()) + ` -
-
-
-
- -
-
-
-
-
- - Performance Considerations -
-
-
- -
-
-
-
` - - return template.HTML(html), nil -} - -// ParseConfigForm parses form data into configuration -func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := &BalanceConfig{} - - // Parse enabled - config.Enabled = len(formData["enabled"]) > 0 - - // Parse imbalance threshold - if values, ok := formData["imbalance_threshold"]; ok && len(values) > 0 { - threshold, err := strconv.ParseFloat(values[0], 64) - if err != nil { - return nil, fmt.Errorf("invalid imbalance threshold: %w", err) - } - if threshold < 0 || threshold > 1 { - return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0") - } - config.ImbalanceThreshold = threshold - } - - // Parse scan 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 max concurrent - if values, ok := formData["max_concurrent"]; ok && len(values) > 0 { - maxConcurrent, err := strconv.Atoi(values[0]) - if err != nil { - return nil, fmt.Errorf("invalid max concurrent: %w", err) - } - if maxConcurrent < 1 { - return nil, fmt.Errorf("max concurrent must be at least 1") - } - config.MaxConcurrent = maxConcurrent - } - - // Parse min server count - if values, ok := formData["min_server_count"]; ok && len(values) > 0 { - minServerCount, err := strconv.Atoi(values[0]) - if err != nil { - return nil, fmt.Errorf("invalid min server count: %w", err) - } - if minServerCount < 2 { - return nil, fmt.Errorf("min server count must be at least 2") - } - config.MinServerCount = minServerCount - } - - // Parse off-hours settings - config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0 - - if values, ok := formData["off_hours_start"]; ok && len(values) > 0 { - config.OffHoursStart = values[0] +// GetCurrentConfig returns the current configuration +func (ui *UIProvider) GetCurrentConfig() interface{} { + config := &BalanceConfig{ + // Default values from schema (matching task_config_schema.go) + Enabled: true, + ImbalanceThreshold: 0.1, // 10% + ScanIntervalSeconds: 6 * 60 * 60, // 6 hours + MaxConcurrent: 2, + MinServerCount: 3, } - if values, ok := formData["off_hours_end"]; ok && len(values) > 0 { - config.OffHoursEnd = values[0] + // Get current values from detector + if ui.detector != nil { + config.Enabled = ui.detector.IsEnabled() + config.ImbalanceThreshold = ui.detector.GetThreshold() + config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) } - // Parse min interval - if values, ok := formData["min_interval"]; ok && len(values) > 0 { - duration, err := time.ParseDuration(values[0]) - if err != nil { - return nil, fmt.Errorf("invalid min interval: %w", err) - } - config.MinIntervalSeconds = int(duration.Seconds()) + // Get current values from scheduler + if ui.scheduler != nil { + config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() + config.MinServerCount = ui.scheduler.GetMinServerCount() } - return config, nil -} - -// GetCurrentConfig returns the current configuration -func (ui *UIProvider) GetCurrentConfig() interface{} { - return ui.getCurrentBalanceConfig() + return config } // ApplyConfig applies the new configuration func (ui *UIProvider) ApplyConfig(config interface{}) error { balanceConfig, ok := config.(*BalanceConfig) if !ok { - return fmt.Errorf("invalid config type, expected *BalanceConfig") + // Try to get the configuration from the schema-based system + schema := tasks.GetBalanceTaskConfigSchema() + if schema != nil { + // Apply defaults to ensure we have a complete config + if err := schema.ApplyDefaults(config); err != nil { + return err + } + + // Use reflection to convert to BalanceConfig - simplified approach + if bc, ok := config.(*BalanceConfig); ok { + balanceConfig = bc + } else { + glog.Warningf("Config type conversion failed, using current config") + balanceConfig = ui.GetCurrentConfig().(*BalanceConfig) + } + } } // Apply to detector if ui.detector != nil { ui.detector.SetEnabled(balanceConfig.Enabled) ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold) - ui.detector.SetMinCheckInterval(secondsToDuration(balanceConfig.ScanIntervalSeconds)) + ui.detector.SetMinCheckInterval(time.Duration(balanceConfig.ScanIntervalSeconds) * time.Second) } // Apply to scheduler @@ -298,52 +112,15 @@ func (ui *UIProvider) ApplyConfig(config interface{}) error { ui.scheduler.SetEnabled(balanceConfig.Enabled) ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent) ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount) - ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours) - ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart) - ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd) } - glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v", + glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d", balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent, - balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours) + balanceConfig.MinServerCount) return nil } -// getCurrentBalanceConfig gets the current configuration from detector and scheduler -func (ui *UIProvider) getCurrentBalanceConfig() *BalanceConfig { - config := &BalanceConfig{ - // Default values (fallback if detectors/schedulers are nil) - Enabled: true, - ImbalanceThreshold: 0.1, // 10% imbalance - ScanIntervalSeconds: durationToSeconds(4 * time.Hour), - MaxConcurrent: 1, - MinServerCount: 3, - MoveDuringOffHours: true, - OffHoursStart: "23:00", - OffHoursEnd: "06:00", - MinIntervalSeconds: durationToSeconds(1 * time.Hour), - } - - // Get current values from detector - if ui.detector != nil { - config.Enabled = ui.detector.IsEnabled() - config.ImbalanceThreshold = ui.detector.GetThreshold() - config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) - } - - // Get current values from scheduler - if ui.scheduler != nil { - config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() - config.MinServerCount = ui.scheduler.GetMinServerCount() - config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours() - config.OffHoursStart = ui.scheduler.GetOffHoursStart() - config.OffHoursEnd = ui.scheduler.GetOffHoursEnd() - } - - return config -} - // RegisterUI registers the balance UI provider with the UI registry func RegisterUI(uiRegistry *types.UIRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) { uiProvider := NewUIProvider(detector, scheduler) @@ -356,13 +133,9 @@ func RegisterUI(uiRegistry *types.UIRegistry, detector *BalanceDetector, schedul func DefaultBalanceConfig() *BalanceConfig { return &BalanceConfig{ Enabled: false, - ImbalanceThreshold: 0.3, - ScanIntervalSeconds: durationToSeconds(4 * time.Hour), - MaxConcurrent: 1, + ImbalanceThreshold: 0.1, // 10% + ScanIntervalSeconds: 6 * 60 * 60, // 6 hours + MaxConcurrent: 2, MinServerCount: 3, - MoveDuringOffHours: false, - OffHoursStart: "22:00", - OffHoursEnd: "06:00", - MinIntervalSeconds: durationToSeconds(1 * time.Hour), } } diff --git a/weed/worker/tasks/erasure_coding/ui.go b/weed/worker/tasks/erasure_coding/ui.go index c61c65628..d9a10b0b2 100644 --- a/weed/worker/tasks/erasure_coding/ui.go +++ b/weed/worker/tasks/erasure_coding/ui.go @@ -1,12 +1,10 @@ package erasure_coding import ( - "fmt" - "html/template" - "strconv" "time" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -44,220 +42,65 @@ func (ui *UIProvider) GetIcon() string { return "fas fa-shield-alt text-info" } -// ErasureCodingConfig represents the erasure coding configuration +// ErasureCodingConfig represents the erasure coding configuration matching the schema type ErasureCodingConfig struct { 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"` + FullnessRatio float64 `json:"fullness_ratio"` CollectionFilter string `json:"collection_filter"` } -// Helper functions for duration conversion -func secondsToDuration(seconds int) time.Duration { - return time.Duration(seconds) * time.Second -} - -func durationToSeconds(d time.Duration) int { - return int(d.Seconds()) -} - -// formatDurationForUser formats seconds as a user-friendly duration string -func formatDurationForUser(seconds int) string { - d := secondsToDuration(seconds) - if d < time.Minute { - return fmt.Sprintf("%ds", seconds) - } - if d < time.Hour { - return fmt.Sprintf("%.0fm", d.Minutes()) - } - if d < 24*time.Hour { - return fmt.Sprintf("%.1fh", d.Hours()) - } - return fmt.Sprintf("%.1fd", d.Hours()/24) -} - -// RenderConfigForm renders the configuration form HTML -func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML, error) { - config := ui.getCurrentECConfig() - - // Build form using the FormBuilder helper - form := types.NewFormBuilder() - - // Detection Settings - form.AddCheckboxField( - "enabled", - "Enable Erasure Coding Tasks", - "Whether erasure coding tasks should be automatically created", - config.Enabled, - ) - - 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.AddIntervalField( - "scan_interval_seconds", - "Scan Interval", - "How often to scan for volumes needing erasure coding", - config.ScanIntervalSeconds, - true, - ) - - // Scheduling Settings - form.AddNumberField( - "max_concurrent", - "Max Concurrent Tasks", - "Maximum number of erasure coding tasks that can run simultaneously", - float64(config.MaxConcurrent), - true, - ) - - // Detection Parameters - form.AddNumberField( - "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.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 - html := ` -
-
-
-
-
- - Erasure Coding Configuration -
-
-
-` + string(form.Build()) + ` -
-
-
-
- -
-
-
-
-
- - Performance Impact -
-
-
- -
-
-
-
` - - return template.HTML(html), nil -} - -// ParseConfigForm parses form data into configuration -func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := ErasureCodingConfig{} - - // Parse enabled - config.Enabled = len(formData["enabled"]) > 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 quiet for duration value: %w", err) - } - - 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_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_seconds_unit"]; ok && len(units) > 0 { - unit = units[0] - } - - // Convert to seconds - config.ScanIntervalSeconds = types.IntervalValueUnitToSeconds(value, unit) - } - - // Parse max concurrent - if values, ok := formData["max_concurrent"]; ok && len(values) > 0 { - maxConcurrent, err := strconv.Atoi(values[0]) - if err != nil { - return nil, fmt.Errorf("invalid max concurrent: %w", err) - } - if maxConcurrent < 1 { - return nil, fmt.Errorf("max concurrent must be at least 1") - } - config.MaxConcurrent = maxConcurrent +// GetCurrentConfig returns the current configuration +func (ui *UIProvider) GetCurrentConfig() interface{} { + config := ErasureCodingConfig{ + // Default values from schema (matching task_config_schema.go) + Enabled: true, + QuietForSeconds: 7 * 24 * 60 * 60, // 7 days + ScanIntervalSeconds: 12 * 60 * 60, // 12 hours + MaxConcurrent: 1, + FullnessRatio: 0.9, // 90% + CollectionFilter: "", } - // 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 fullness ratio: %w", err) - } - if fullnessRatio < 0 || fullnessRatio > 1 { - return nil, fmt.Errorf("fullness ratio must be between 0.0 and 1.0") - } - config.FullnessRatio = fullnessRatio + // Get current values from detector + if ui.detector != nil { + config.Enabled = ui.detector.IsEnabled() + config.QuietForSeconds = ui.detector.GetQuietForSeconds() + config.FullnessRatio = ui.detector.GetFullnessRatio() + config.CollectionFilter = ui.detector.GetCollectionFilter() + config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) } - // Parse collection filter - if values, ok := formData["collection_filter"]; ok && len(values) > 0 { - config.CollectionFilter = values[0] + // Get current values from scheduler + if ui.scheduler != nil { + config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() } - return config, nil -} - -// GetCurrentConfig returns the current configuration -func (ui *UIProvider) GetCurrentConfig() interface{} { - return ui.getCurrentECConfig() + return config } // ApplyConfig applies the new configuration func (ui *UIProvider) ApplyConfig(config interface{}) error { ecConfig, ok := config.(ErasureCodingConfig) if !ok { - return fmt.Errorf("invalid config type, expected ErasureCodingConfig") + // Try to get the configuration from the schema-based system + schema := tasks.GetErasureCodingTaskConfigSchema() + if schema != nil { + // Apply defaults to ensure we have a complete config + if err := schema.ApplyDefaults(config); err != nil { + return err + } + + // Use reflection to convert to ErasureCodingConfig - simplified approach + if ec, ok := config.(ErasureCodingConfig); ok { + ecConfig = ec + } else { + glog.Warningf("Config type conversion failed, using current config") + ecConfig = ui.GetCurrentConfig().(ErasureCodingConfig) + } + } } // Apply to detector @@ -266,7 +109,7 @@ func (ui *UIProvider) ApplyConfig(config interface{}) error { ui.detector.SetQuietForSeconds(ecConfig.QuietForSeconds) ui.detector.SetFullnessRatio(ecConfig.FullnessRatio) ui.detector.SetCollectionFilter(ecConfig.CollectionFilter) - ui.detector.SetScanInterval(secondsToDuration(ecConfig.ScanIntervalSeconds)) + ui.detector.SetScanInterval(time.Duration(ecConfig.ScanIntervalSeconds) * time.Second) } // Apply to scheduler @@ -275,41 +118,12 @@ func (ui *UIProvider) ApplyConfig(config interface{}) error { ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent) } - 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", + glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, quiet_for=%v seconds, max_concurrent=%d, fullness_ratio=%f, collection_filter=%s", ecConfig.Enabled, ecConfig.QuietForSeconds, ecConfig.MaxConcurrent, ecConfig.FullnessRatio, ecConfig.CollectionFilter) return nil } -// getCurrentECConfig gets the current configuration from detector and scheduler -func (ui *UIProvider) getCurrentECConfig() ErasureCodingConfig { - config := ErasureCodingConfig{ - // Default values (fallback if detectors/schedulers are nil) - 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.QuietForSeconds = ui.detector.GetQuietForSeconds() - config.FullnessRatio = ui.detector.GetFullnessRatio() - config.CollectionFilter = ui.detector.GetCollectionFilter() - config.ScanIntervalSeconds = durationToSeconds(ui.detector.ScanInterval()) - } - - // Get current values from scheduler - if ui.scheduler != nil { - config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() - } - - return config -} - // RegisterUI registers the erasure coding UI provider with the UI registry func RegisterUI(uiRegistry *types.UIRegistry, detector *EcDetector, scheduler *Scheduler) { uiProvider := NewUIProvider(detector, scheduler) diff --git a/weed/worker/tasks/vacuum/ui.go b/weed/worker/tasks/vacuum/ui.go index 043bb6727..468163c5d 100644 --- a/weed/worker/tasks/vacuum/ui.go +++ b/weed/worker/tasks/vacuum/ui.go @@ -1,12 +1,10 @@ package vacuum import ( - "fmt" - "html/template" - "strconv" "time" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -44,7 +42,7 @@ func (ui *UIProvider) GetIcon() string { return "fas fa-broom text-primary" } -// VacuumConfig represents the vacuum configuration +// VacuumConfig represents the vacuum configuration matching the schema type VacuumConfig struct { Enabled bool `json:"enabled"` GarbageThreshold float64 `json:"garbage_threshold"` @@ -54,267 +52,78 @@ type VacuumConfig struct { MinIntervalSeconds int `json:"min_interval_seconds"` } -// Helper functions for duration conversion -func secondsToDuration(seconds int) time.Duration { - return time.Duration(seconds) * time.Second -} - -func durationToSeconds(d time.Duration) int { - return int(d.Seconds()) -} - -// formatDurationForUser formats seconds as a user-friendly duration string -func formatDurationForUser(seconds int) string { - d := secondsToDuration(seconds) - if d < time.Minute { - return fmt.Sprintf("%ds", seconds) - } - if d < time.Hour { - return fmt.Sprintf("%.0fm", d.Minutes()) - } - if d < 24*time.Hour { - return fmt.Sprintf("%.1fh", d.Hours()) - } - return fmt.Sprintf("%.1fd", d.Hours()/24) -} - -// RenderConfigForm renders the configuration form HTML -func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML, error) { - config := ui.getCurrentVacuumConfig() - - // Build form using the FormBuilder helper - form := types.NewFormBuilder() - - // Detection Settings - form.AddCheckboxField( - "enabled", - "Enable Vacuum Tasks", - "Whether vacuum tasks should be automatically created", - config.Enabled, - ) - - form.AddNumberField( - "garbage_threshold", - "Garbage Percentage Threshold", - "Trigger vacuum when garbage ratio exceeds this percentage (0-100)", - config.GarbageThreshold*100, // Convert 0.0-1.0 to 0-100 for display - true, - ) - - form.AddIntervalField( - "scan_interval_seconds", - "Scan Interval", - "How often to scan for volumes needing vacuum", - config.ScanIntervalSeconds, - true, - ) - - form.AddIntervalField( - "min_volume_age_seconds", - "Minimum Volume Age", - "Only vacuum volumes older than this duration", - config.MinVolumeAgeSeconds, - true, - ) - - // Scheduling Settings - form.AddNumberField( - "max_concurrent", - "Max Concurrent Tasks", - "Maximum number of vacuum tasks that can run simultaneously", - float64(config.MaxConcurrent), - true, - ) - - form.AddIntervalField( - "min_interval_seconds", - "Minimum Interval", - "Minimum time between vacuum operations on the same volume", - config.MinIntervalSeconds, - true, - ) - - // Generate organized form sections using Bootstrap components - html := ` -
-
-
-
-
- - Detection Settings -
-
-
-` + string(form.Build()) + ` -
-
-
-
- - -` - - return template.HTML(html), nil -} - -// ParseConfigForm parses form data into configuration -func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) { - config := &VacuumConfig{} - - // Parse enabled checkbox - config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on" - - // Parse garbage threshold (convert from 0-100 to 0.0-1.0) - if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 { - if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil { - return nil, fmt.Errorf("invalid garbage percentage threshold: %w", err) - } else if threshold < 0 || threshold > 100 { - return nil, fmt.Errorf("garbage percentage threshold must be between 0 and 100") - } else { - config.GarbageThreshold = threshold / 100.0 // Convert percentage to decimal - } - } - - // Parse scan interval - 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 value: %w", err) - } - - unit := "minute" // default - if units, ok := formData["scan_interval_seconds_unit"]; ok && len(units) > 0 { - unit = units[0] - } - - // Convert to seconds using the helper function from types package - config.ScanIntervalSeconds = types.IntervalValueUnitToSeconds(value, unit) - } - - // Parse min volume age - if values, ok := formData["min_volume_age_seconds_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_seconds_unit"]; ok && len(units) > 0 { - unit = units[0] - } - - // Convert to seconds - config.MinVolumeAgeSeconds = types.IntervalValueUnitToSeconds(value, unit) +// GetCurrentConfig returns the current configuration +func (ui *UIProvider) GetCurrentConfig() interface{} { + config := &VacuumConfig{ + // Default values from schema (matching task_config_schema.go) + Enabled: true, + GarbageThreshold: 0.3, // 30% + ScanIntervalSeconds: 2 * 60 * 60, // 2 hours + MaxConcurrent: 2, + MinVolumeAgeSeconds: 24 * 60 * 60, // 24 hours + MinIntervalSeconds: 7 * 24 * 60 * 60, // 7 days } - // Parse max concurrent - if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 { - if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil { - return nil, fmt.Errorf("invalid max concurrent: %w", err) - } else if concurrent < 1 { - return nil, fmt.Errorf("max concurrent must be at least 1") - } else { - config.MaxConcurrent = concurrent - } + // Get current values from detector + if ui.detector != nil { + config.Enabled = ui.detector.IsEnabled() + config.GarbageThreshold = ui.detector.GetGarbageThreshold() + config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds()) + config.MinVolumeAgeSeconds = int(ui.detector.GetMinVolumeAge().Seconds()) } - // Parse min interval - if values, ok := formData["min_interval_seconds_value"]; ok && len(values) > 0 { - value, err := strconv.Atoi(values[0]) - if err != nil { - return nil, fmt.Errorf("invalid min interval value: %w", err) - } - - unit := "minute" // default - if units, ok := formData["min_interval_seconds_unit"]; ok && len(units) > 0 { - unit = units[0] - } - - // Convert to seconds - config.MinIntervalSeconds = types.IntervalValueUnitToSeconds(value, unit) + // Get current values from scheduler + if ui.scheduler != nil { + config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() + config.MinIntervalSeconds = int(ui.scheduler.GetMinInterval().Seconds()) } - return config, nil -} - -// GetCurrentConfig returns the current configuration -func (ui *UIProvider) GetCurrentConfig() interface{} { - return ui.getCurrentVacuumConfig() + return config } // ApplyConfig applies the new configuration func (ui *UIProvider) ApplyConfig(config interface{}) error { vacuumConfig, ok := config.(*VacuumConfig) if !ok { - return fmt.Errorf("invalid config type, expected *VacuumConfig") + // Try to get the configuration from the schema-based system + schema := tasks.GetVacuumTaskConfigSchema() + if schema != nil { + // Apply defaults to ensure we have a complete config + if err := schema.ApplyDefaults(config); err != nil { + return err + } + + // Use reflection to convert to VacuumConfig - simplified approach + if vc, ok := config.(*VacuumConfig); ok { + vacuumConfig = vc + } else { + glog.Warningf("Config type conversion failed, using current config") + vacuumConfig = ui.GetCurrentConfig().(*VacuumConfig) + } + } } // Apply to detector if ui.detector != nil { ui.detector.SetEnabled(vacuumConfig.Enabled) ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold) - ui.detector.SetScanInterval(secondsToDuration(vacuumConfig.ScanIntervalSeconds)) - ui.detector.SetMinVolumeAge(secondsToDuration(vacuumConfig.MinVolumeAgeSeconds)) + ui.detector.SetScanInterval(time.Duration(vacuumConfig.ScanIntervalSeconds) * time.Second) + ui.detector.SetMinVolumeAge(time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second) } // Apply to scheduler if ui.scheduler != nil { ui.scheduler.SetEnabled(vacuumConfig.Enabled) ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent) - ui.scheduler.SetMinInterval(secondsToDuration(vacuumConfig.MinIntervalSeconds)) + ui.scheduler.SetMinInterval(time.Duration(vacuumConfig.MinIntervalSeconds) * time.Second) } - glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d", - vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationForUser(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent) + glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%ds, max_concurrent=%d", + vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, vacuumConfig.ScanIntervalSeconds, vacuumConfig.MaxConcurrent) return nil } -// getCurrentVacuumConfig gets the current configuration from detector and scheduler -func (ui *UIProvider) getCurrentVacuumConfig() *VacuumConfig { - config := &VacuumConfig{ - // Default values (fallback if detectors/schedulers are nil) - Enabled: true, - GarbageThreshold: 0.3, - ScanIntervalSeconds: 30 * 60, - MinVolumeAgeSeconds: 1 * 60 * 60, - MaxConcurrent: 2, - MinIntervalSeconds: 6 * 60 * 60, - } - - // Get current values from detector - if ui.detector != nil { - config.Enabled = ui.detector.IsEnabled() - config.GarbageThreshold = ui.detector.GetGarbageThreshold() - config.ScanIntervalSeconds = durationToSeconds(ui.detector.ScanInterval()) - config.MinVolumeAgeSeconds = durationToSeconds(ui.detector.GetMinVolumeAge()) - } - - // Get current values from scheduler - if ui.scheduler != nil { - config.MaxConcurrent = ui.scheduler.GetMaxConcurrent() - config.MinIntervalSeconds = durationToSeconds(ui.scheduler.GetMinInterval()) - } - - return config -} - // RegisterUI registers the vacuum UI provider with the UI registry func RegisterUI(uiRegistry *types.UIRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) { uiProvider := NewUIProvider(detector, scheduler) @@ -323,7 +132,7 @@ func RegisterUI(uiRegistry *types.UIRegistry, detector *VacuumDetector, schedule glog.V(1).Infof("✅ Registered vacuum task UI provider") } -// Example: How to get the UI provider for external use +// GetUIProvider returns the UI provider for external use func GetUIProvider(uiRegistry *types.UIRegistry) *UIProvider { provider := uiRegistry.GetProvider(types.TaskTypeVacuum) if provider == nil { diff --git a/weed/worker/types/task_ui.go b/weed/worker/types/task_ui.go index 7ad5437f6..69840e00f 100644 --- a/weed/worker/types/task_ui.go +++ b/weed/worker/types/task_ui.go @@ -1,8 +1,6 @@ package types import ( - "fmt" - "html/template" "time" ) @@ -41,6 +39,7 @@ func IntervalValueUnitToSeconds(value int, unit string) int { } // TaskUIProvider defines how tasks provide their configuration UI +// This interface is simplified to work with schema-driven configuration type TaskUIProvider interface { // GetTaskType returns the task type GetTaskType() TaskType @@ -54,12 +53,6 @@ type TaskUIProvider interface { // GetIcon returns the icon CSS class or HTML for this task type GetIcon() string - // RenderConfigForm renders the configuration form HTML - RenderConfigForm(currentConfig interface{}) (template.HTML, error) - - // ParseConfigForm parses form data into configuration - ParseConfigForm(formData map[string][]string) (interface{}, error) - // GetCurrentConfig returns the current configuration GetCurrentConfig() interface{} @@ -121,246 +114,10 @@ type TaskListData struct { } type TaskDetailsData struct { - Task *Task `json:"task"` - TaskType TaskType `json:"task_type"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Stats *TaskStats `json:"stats"` - ConfigForm template.HTML `json:"config_form"` - LastUpdated time.Time `json:"last_updated"` -} - -// Common form field types for simple form building -type FormField struct { - Name string `json:"name"` - Label string `json:"label"` - 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 { - Value string `json:"value"` - Label string `json:"label"` -} - -// Helper for building forms in code -type FormBuilder struct { - fields []FormField -} - -// NewFormBuilder creates a new form builder -func NewFormBuilder() *FormBuilder { - return &FormBuilder{ - fields: make([]FormField, 0), - } -} - -// AddTextField adds a text input field -func (fb *FormBuilder) AddTextField(name, label, description string, value string, required bool) *FormBuilder { - fb.fields = append(fb.fields, FormField{ - Name: name, - Label: label, - Type: "text", - Value: value, - Description: description, - Required: required, - }) - return fb -} - -// AddNumberField adds a number input field -func (fb *FormBuilder) AddNumberField(name, label, description string, value float64, required bool) *FormBuilder { - fb.fields = append(fb.fields, FormField{ - Name: name, - Label: label, - Type: "number", - Value: value, - Description: description, - Required: required, - }) - return fb -} - -// AddCheckboxField adds a checkbox field -func (fb *FormBuilder) AddCheckboxField(name, label, description string, value bool) *FormBuilder { - fb.fields = append(fb.fields, FormField{ - Name: name, - Label: label, - Type: "checkbox", - Value: value, - Description: description, - Required: false, - }) - return fb -} - -// AddSelectField adds a select dropdown field -func (fb *FormBuilder) AddSelectField(name, label, description string, value string, options []FormOption, required bool) *FormBuilder { - fb.fields = append(fb.fields, FormField{ - Name: name, - Label: label, - Type: "select", - Value: value, - Description: description, - Required: required, - Options: options, - }) - return fb -} - -// AddDurationField adds a duration input field -func (fb *FormBuilder) AddDurationField(name, label, description string, value time.Duration, required bool) *FormBuilder { - fb.fields = append(fb.fields, FormField{ - Name: name, - Label: label, - Type: "duration", - Value: value.String(), - Description: description, - Required: required, - }) - 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 := "" - - for _, field := range fb.fields { - html += fb.renderField(field) - } - - return template.HTML(html) -} - -// renderField renders a single form field with Bootstrap classes -func (fb *FormBuilder) renderField(field FormField) string { - html := "
\n" - - // Special handling for checkbox fields - if field.Type == "checkbox" { - checked := "" - if field.Value.(bool) { - checked = " checked" - } - html += "
\n" - html += " \n" - html += " \n" - html += "
\n" - // Description for checkbox - if field.Description != "" { - html += "
" + field.Description + "
\n" - } - html += "
\n" - return html - } - - // Label for non-checkbox fields - required := "" - if field.Required { - required = " *" - } - html += " \n" - - // Input based on type - switch field.Type { - case "text": - html += " " + option.Label + "\n" - } - html += " \n" - - case "duration": - html += " \n" - html += " " + unit.Label + "\n" - } - html += " \n" - html += " \n" - } - - // Description for non-checkbox fields - if field.Description != "" { - html += "
" + field.Description + "
\n" - } - - html += "\n" - return html + Task *Task `json:"task"` + TaskType TaskType `json:"task_type"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Stats *TaskStats `json:"stats"` + LastUpdated time.Time `json:"last_updated"` }