diff --git a/weed/admin/config/schema.go b/weed/admin/config/schema.go new file mode 100644 index 000000000..df897d896 --- /dev/null +++ b/weed/admin/config/schema.go @@ -0,0 +1,350 @@ +package config + +import ( + "fmt" + "reflect" + "strings" + "time" +) + +// FieldType defines the type of a configuration field +type FieldType string + +const ( + FieldTypeBool FieldType = "bool" + FieldTypeInt FieldType = "int" + FieldTypeDuration FieldType = "duration" + FieldTypeInterval FieldType = "interval" + FieldTypeString FieldType = "string" + FieldTypeFloat FieldType = "float" +) + +// FieldUnit defines the unit for display purposes +type FieldUnit string + +const ( + UnitSeconds FieldUnit = "seconds" + UnitMinutes FieldUnit = "minutes" + UnitHours FieldUnit = "hours" + UnitDays FieldUnit = "days" + UnitCount FieldUnit = "count" + UnitNone FieldUnit = "" +) + +// Field defines a configuration field with all its metadata +type Field struct { + // Field identification + Name string `json:"name"` + JSONName string `json:"json_name"` + Type FieldType `json:"type"` + + // Default value and validation + DefaultValue interface{} `json:"default_value"` + MinValue interface{} `json:"min_value,omitempty"` + MaxValue interface{} `json:"max_value,omitempty"` + Required bool `json:"required"` + + // UI display + DisplayName string `json:"display_name"` + Description string `json:"description"` + HelpText string `json:"help_text"` + Placeholder string `json:"placeholder"` + Unit FieldUnit `json:"unit"` + + // Form rendering + InputType string `json:"input_type"` // "checkbox", "number", "text", "interval", etc. + CSSClasses string `json:"css_classes,omitempty"` +} + +// GetDisplayValue returns the value formatted for display in the specified unit +func (f *Field) GetDisplayValue(value interface{}) interface{} { + if (f.Type == FieldTypeDuration || f.Type == FieldTypeInterval) && f.Unit != UnitSeconds { + if duration, ok := value.(time.Duration); ok { + switch f.Unit { + case UnitMinutes: + return int(duration.Minutes()) + case UnitHours: + return int(duration.Hours()) + case UnitDays: + return int(duration.Hours() / 24) + } + } + if seconds, ok := value.(int); ok { + switch f.Unit { + case UnitMinutes: + return seconds / 60 + case UnitHours: + return seconds / 3600 + case UnitDays: + return seconds / (24 * 3600) + } + } + } + return value +} + +// GetIntervalDisplayValue returns the value and unit for interval fields +func (f *Field) GetIntervalDisplayValue(value interface{}) (int, string) { + if f.Type != FieldTypeInterval { + return 0, "minutes" + } + + seconds := 0 + if duration, ok := value.(time.Duration); ok { + seconds = int(duration.Seconds()) + } else if s, ok := value.(int); ok { + seconds = s + } + + return SecondsToIntervalValueUnit(seconds) +} + +// SecondsToIntervalValueUnit converts seconds to the most appropriate interval unit +func SecondsToIntervalValueUnit(totalSeconds int) (int, string) { + if totalSeconds == 0 { + return 0, "minutes" + } + + // Check if it's evenly divisible by days + if totalSeconds%(24*3600) == 0 { + return totalSeconds / (24 * 3600), "days" + } + + // Check if it's evenly divisible by hours + if totalSeconds%3600 == 0 { + return totalSeconds / 3600, "hours" + } + + // Default to minutes + return totalSeconds / 60, "minutes" +} + +// IntervalValueUnitToSeconds converts interval value and unit to seconds +func IntervalValueUnitToSeconds(value int, unit string) int { + switch unit { + case "days": + return value * 24 * 3600 + case "hours": + return value * 3600 + case "minutes": + return value * 60 + default: + return value * 60 // Default to minutes + } +} + +// ParseDisplayValue converts a display value back to the storage format +func (f *Field) ParseDisplayValue(displayValue interface{}) interface{} { + if (f.Type == FieldTypeDuration || f.Type == FieldTypeInterval) && f.Unit != UnitSeconds { + if val, ok := displayValue.(int); ok { + switch f.Unit { + case UnitMinutes: + return val * 60 + case UnitHours: + return val * 3600 + case UnitDays: + return val * 24 * 3600 + } + } + } + return displayValue +} + +// ParseIntervalFormData parses form data for interval fields (value + unit) +func (f *Field) ParseIntervalFormData(valueStr, unitStr string) (int, error) { + if f.Type != FieldTypeInterval { + return 0, fmt.Errorf("field %s is not an interval field", f.Name) + } + + value := 0 + if valueStr != "" { + var err error + value, err = fmt.Sscanf(valueStr, "%d", &value) + if err != nil { + return 0, fmt.Errorf("invalid interval value: %s", valueStr) + } + } + + return IntervalValueUnitToSeconds(value, unitStr), nil +} + +// ValidateValue validates a value against the field constraints +func (f *Field) ValidateValue(value interface{}) error { + if f.Required && (value == nil || value == "" || value == 0) { + return fmt.Errorf("%s is required", f.DisplayName) + } + + if f.MinValue != nil { + if !f.compareValues(value, f.MinValue, ">=") { + return fmt.Errorf("%s must be >= %v", f.DisplayName, f.MinValue) + } + } + + if f.MaxValue != nil { + if !f.compareValues(value, f.MaxValue, "<=") { + return fmt.Errorf("%s must be <= %v", f.DisplayName, f.MaxValue) + } + } + + return nil +} + +// compareValues compares two values based on the operator +func (f *Field) compareValues(a, b interface{}, op string) bool { + switch f.Type { + case FieldTypeInt: + aVal, aOk := a.(int) + bVal, bOk := b.(int) + if !aOk || !bOk { + return false + } + switch op { + case ">=": + return aVal >= bVal + case "<=": + return aVal <= bVal + } + case FieldTypeFloat: + aVal, aOk := a.(float64) + bVal, bOk := b.(float64) + if !aOk || !bOk { + return false + } + switch op { + case ">=": + return aVal >= bVal + case "<=": + return aVal <= bVal + } + } + return true +} + +// Schema provides common functionality for configuration schemas +type Schema struct { + Fields []*Field `json:"fields"` +} + +// GetFieldByName returns a field by its JSON name +func (s *Schema) GetFieldByName(jsonName string) *Field { + for _, field := range s.Fields { + if field.JSONName == jsonName { + return field + } + } + return nil +} + +// ApplyDefaults applies default values to a configuration struct using reflection +func (s *Schema) ApplyDefaults(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 := s.GetFieldByName(jsonTag) + if schemaField == nil { + continue + } + + // Apply default if field is zero value + if field.CanSet() && isZeroValue(field) { + defaultValue := reflect.ValueOf(schemaField.DefaultValue) + if defaultValue.Type().ConvertibleTo(field.Type()) { + field.Set(defaultValue.Convert(field.Type())) + } + } + } + + return nil +} + +// ValidateConfig validates a configuration against the schema +func (s *Schema) ValidateConfig(config interface{}) []error { + var errors []error + + configValue := reflect.ValueOf(config) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + if configValue.Kind() != reflect.Struct { + errors = append(errors, fmt.Errorf("config must be a struct or pointer to struct")) + return errors + } + + 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 := s.GetFieldByName(jsonTag) + if schemaField == nil { + continue + } + + // Validate field value + fieldValue := field.Interface() + if err := schemaField.ValidateValue(fieldValue); err != nil { + errors = append(errors, err) + } + } + + return errors +} + +// isZeroValue checks if a reflect.Value represents a zero value +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.String: + return v.String() == "" + case reflect.Slice, reflect.Map, reflect.Array: + return v.IsNil() || v.Len() == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() + } + return false +} diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go index 4f14651b4..137b6a148 100644 --- a/weed/admin/handlers/maintenance_handlers.go +++ b/weed/admin/handlers/maintenance_handlers.go @@ -110,20 +110,20 @@ func (h *MaintenanceHandlers) ShowMaintenanceConfig(c *gin.Context) { func (h *MaintenanceHandlers) ShowTaskConfig(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"}) + // Get the schema for this task type + schema := tasks.GetTaskConfigSchema(taskTypeName) + if schema == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Task type not found or no schema available"}) return } - // Get the UI provider for this task type + // Get the UI provider for current configuration uiRegistry := tasks.GetGlobalUIRegistry() typesRegistry := tasks.GetGlobalTypesRegistry() var provider types.TaskUIProvider for workerTaskType := range typesRegistry.GetAllDetectors() { - if string(workerTaskType) == string(taskType) { + if string(workerTaskType) == taskTypeName { provider = uiRegistry.GetProvider(workerTaskType) break } @@ -134,28 +134,27 @@ func (h *MaintenanceHandlers) ShowTaskConfig(c *gin.Context) { return } - // Get current configuration and render form using the actual UI provider + // Get current configuration 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 + + // Apply schema defaults to ensure all fields have values + if err := schema.ApplyDefaults(currentConfig); err != nil { + glog.Errorf("Failed to apply schema defaults for %s: %v", taskTypeName, err) } - // Create task configuration data using the actual form HTML + // Create task configuration data configData := &maintenance.TaskConfigData{ - TaskType: taskType, - TaskName: provider.GetDisplayName(), - TaskIcon: provider.GetIcon(), - Description: provider.GetDescription(), - ConfigFormHTML: formHTML, + TaskType: maintenance.MaintenanceTaskType(taskTypeName), + TaskName: schema.DisplayName, + TaskIcon: schema.Icon, + Description: schema.Description, } - // Render HTML template + // Render HTML template using schema-based approach c.Header("Content-Type", "text/html") - taskConfigComponent := app.TaskConfig(configData) + taskConfigComponent := app.TaskConfigSchema(configData, schema, currentConfig) 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/admin/maintenance/config_schema.go b/weed/admin/maintenance/config_schema.go index d6bfe36cb..300a868f2 100644 --- a/weed/admin/maintenance/config_schema.go +++ b/weed/admin/maintenance/config_schema.go @@ -1,488 +1,190 @@ package maintenance import ( - "fmt" - "reflect" - "strings" - "time" + "github.com/seaweedfs/seaweedfs/weed/admin/config" ) -// ConfigFieldType defines the type of a configuration field -type ConfigFieldType string +// Type aliases for backward compatibility +type ConfigFieldType = config.FieldType +type ConfigFieldUnit = config.FieldUnit +type ConfigField = config.Field +// Constant aliases for backward compatibility const ( - FieldTypeBool ConfigFieldType = "bool" - FieldTypeInt ConfigFieldType = "int" - FieldTypeDuration ConfigFieldType = "duration" - FieldTypeInterval ConfigFieldType = "interval" - FieldTypeString ConfigFieldType = "string" - FieldTypeFloat ConfigFieldType = "float" + FieldTypeBool = config.FieldTypeBool + FieldTypeInt = config.FieldTypeInt + FieldTypeDuration = config.FieldTypeDuration + FieldTypeInterval = config.FieldTypeInterval + FieldTypeString = config.FieldTypeString + FieldTypeFloat = config.FieldTypeFloat ) -// ConfigFieldUnit defines the unit for display purposes -type ConfigFieldUnit string - const ( - UnitSeconds ConfigFieldUnit = "seconds" - UnitMinutes ConfigFieldUnit = "minutes" - UnitHours ConfigFieldUnit = "hours" - UnitDays ConfigFieldUnit = "days" - UnitCount ConfigFieldUnit = "count" - UnitNone ConfigFieldUnit = "" + UnitSeconds = config.UnitSeconds + UnitMinutes = config.UnitMinutes + UnitHours = config.UnitHours + UnitDays = config.UnitDays + UnitCount = config.UnitCount + UnitNone = config.UnitNone ) -// ConfigField defines a configuration field with all its metadata -type ConfigField struct { - // Field identification - Name string `json:"name"` - JSONName string `json:"json_name"` - Type ConfigFieldType `json:"type"` - - // Default value and validation - DefaultValue interface{} `json:"default_value"` - MinValue interface{} `json:"min_value,omitempty"` - MaxValue interface{} `json:"max_value,omitempty"` - Required bool `json:"required"` - - // UI display - DisplayName string `json:"display_name"` - Description string `json:"description"` - HelpText string `json:"help_text"` - Placeholder string `json:"placeholder"` - Unit ConfigFieldUnit `json:"unit"` - - // Form rendering - InputType string `json:"input_type"` // "checkbox", "number", "text", etc. - CSSClasses string `json:"css_classes,omitempty"` -} - -// GetDisplayValue returns the value formatted for display in the specified unit -func (cf *ConfigField) GetDisplayValue(value interface{}) interface{} { - if (cf.Type == FieldTypeDuration || cf.Type == FieldTypeInterval) && cf.Unit != UnitSeconds { - if duration, ok := value.(time.Duration); ok { - switch cf.Unit { - case UnitMinutes: - return int(duration.Minutes()) - case UnitHours: - return int(duration.Hours()) - case UnitDays: - return int(duration.Hours() / 24) - } - } - if seconds, ok := value.(int); ok { - switch cf.Unit { - case UnitMinutes: - return seconds / 60 - case UnitHours: - return seconds / 3600 - case UnitDays: - return seconds / (24 * 3600) - } - } - } - return value -} - -// GetIntervalDisplayValue returns the value and unit for interval fields -func (cf *ConfigField) GetIntervalDisplayValue(value interface{}) (int, string) { - if cf.Type != FieldTypeInterval { - return 0, "minutes" - } - - seconds := 0 - if duration, ok := value.(time.Duration); ok { - seconds = int(duration.Seconds()) - } else if s, ok := value.(int); ok { - seconds = s - } - - return SecondsToIntervalValueUnit(seconds) -} - -// SecondsToIntervalValueUnit converts seconds to the most appropriate interval unit -func SecondsToIntervalValueUnit(totalSeconds int) (int, string) { - if totalSeconds == 0 { - return 0, "minutes" - } - - // Check if it's evenly divisible by days - if totalSeconds%(24*3600) == 0 { - return totalSeconds / (24 * 3600), "days" - } - - // Check if it's evenly divisible by hours - if totalSeconds%3600 == 0 { - return totalSeconds / 3600, "hours" - } - - // Default to minutes - return totalSeconds / 60, "minutes" -} - -// IntervalValueUnitToSeconds converts interval value and unit to seconds -func IntervalValueUnitToSeconds(value int, unit string) int { - switch unit { - case "days": - return value * 24 * 3600 - case "hours": - return value * 3600 - case "minutes": - return value * 60 - default: - return value * 60 // Default to minutes - } -} - -// ParseDisplayValue converts a display value back to the storage format -func (cf *ConfigField) ParseDisplayValue(displayValue interface{}) interface{} { - if (cf.Type == FieldTypeDuration || cf.Type == FieldTypeInterval) && cf.Unit != UnitSeconds { - if val, ok := displayValue.(int); ok { - switch cf.Unit { - case UnitMinutes: - return val * 60 - case UnitHours: - return val * 3600 - case UnitDays: - return val * 24 * 3600 - } - } - } - return displayValue -} - -// ParseIntervalFormData parses form data for interval fields (value + unit) -func (cf *ConfigField) ParseIntervalFormData(valueStr, unitStr string) (int, error) { - if cf.Type != FieldTypeInterval { - return 0, fmt.Errorf("field %s is not an interval field", cf.Name) - } - - value := 0 - if valueStr != "" { - var err error - value, err = fmt.Sscanf(valueStr, "%d", &value) - if err != nil { - return 0, fmt.Errorf("invalid interval value: %s", valueStr) - } - } - - return IntervalValueUnitToSeconds(value, unitStr), nil -} - -// ValidateValue validates a value against the field constraints -func (cf *ConfigField) ValidateValue(value interface{}) error { - if cf.Required && (value == nil || value == "" || value == 0) { - return fmt.Errorf("%s is required", cf.DisplayName) - } - - if cf.MinValue != nil { - if !cf.compareValues(value, cf.MinValue, ">=") { - return fmt.Errorf("%s must be >= %v", cf.DisplayName, cf.MinValue) - } - } - - if cf.MaxValue != nil { - if !cf.compareValues(value, cf.MaxValue, "<=") { - return fmt.Errorf("%s must be <= %v", cf.DisplayName, cf.MaxValue) - } - } - - return nil -} - -// compareValues compares two values based on the operator -func (cf *ConfigField) compareValues(a, b interface{}, op string) bool { - switch cf.Type { - case FieldTypeInt: - aVal, aOk := a.(int) - bVal, bOk := b.(int) - if !aOk || !bOk { - return false - } - switch op { - case ">=": - return aVal >= bVal - case "<=": - return aVal <= bVal - } - case FieldTypeFloat: - aVal, aOk := a.(float64) - bVal, bOk := b.(float64) - if !aOk || !bOk { - return false - } - switch op { - case ">=": - return aVal >= bVal - case "<=": - return aVal <= bVal - } - } - return true -} +// Function aliases for backward compatibility +var ( + SecondsToIntervalValueUnit = config.SecondsToIntervalValueUnit + IntervalValueUnitToSeconds = config.IntervalValueUnitToSeconds +) // MaintenanceConfigSchema defines the schema for maintenance configuration type MaintenanceConfigSchema struct { - Fields map[string]*ConfigField `json:"fields"` + config.Schema // Embed common schema functionality } // GetMaintenanceConfigSchema returns the schema for maintenance configuration func GetMaintenanceConfigSchema() *MaintenanceConfigSchema { return &MaintenanceConfigSchema{ - Fields: map[string]*ConfigField{ - "enabled": { - Name: "enabled", - JSONName: "enabled", - Type: FieldTypeBool, - DefaultValue: false, - Required: false, - DisplayName: "Enable Maintenance System", - Description: "When enabled, the system will automatically scan for and execute maintenance tasks", - HelpText: "Toggle this to enable or disable the entire maintenance system", - InputType: "checkbox", - CSSClasses: "form-check-input", - }, - "scan_interval_seconds": { - Name: "scan_interval_seconds", - JSONName: "scan_interval_seconds", - Type: FieldTypeInterval, - DefaultValue: 30 * 60, // 30 minutes in seconds - MinValue: 1 * 60, // 1 minute - MaxValue: 24 * 60 * 60, // 24 hours - Required: true, - DisplayName: "Scan Interval", - Description: "How often to scan for maintenance tasks", - HelpText: "The system will check for new maintenance tasks at this interval", - Placeholder: "30", - Unit: UnitMinutes, - InputType: "interval", - CSSClasses: "form-control", - }, - "worker_timeout_seconds": { - Name: "worker_timeout_seconds", - JSONName: "worker_timeout_seconds", - Type: FieldTypeInterval, - DefaultValue: 5 * 60, // 5 minutes - MinValue: 1 * 60, // 1 minute - MaxValue: 60 * 60, // 1 hour - Required: true, - DisplayName: "Worker Timeout", - Description: "How long to wait for worker heartbeat before considering it inactive", - HelpText: "Workers that don't send heartbeats within this time are considered offline", - Placeholder: "5", - Unit: UnitMinutes, - InputType: "interval", - CSSClasses: "form-control", - }, - "task_timeout_seconds": { - Name: "task_timeout_seconds", - JSONName: "task_timeout_seconds", - Type: FieldTypeInterval, - DefaultValue: 2 * 60 * 60, // 2 hours - MinValue: 1 * 60 * 60, // 1 hour - MaxValue: 24 * 60 * 60, // 24 hours - Required: true, - DisplayName: "Task Timeout", - Description: "Maximum time allowed for a single task to complete", - HelpText: "Tasks that run longer than this will be considered failed and may be retried", - Placeholder: "2", - Unit: UnitHours, - InputType: "interval", - CSSClasses: "form-control", - }, - "retry_delay_seconds": { - Name: "retry_delay_seconds", - JSONName: "retry_delay_seconds", - Type: FieldTypeInterval, - DefaultValue: 15 * 60, // 15 minutes - MinValue: 1 * 60, // 1 minute - MaxValue: 2 * 60 * 60, // 2 hours - Required: true, - DisplayName: "Retry Delay", - Description: "Time to wait before retrying failed tasks", - HelpText: "Failed tasks will wait this long before being retried", - Placeholder: "15", - Unit: UnitMinutes, - InputType: "interval", - CSSClasses: "form-control", - }, - "max_retries": { - Name: "max_retries", - JSONName: "max_retries", - Type: FieldTypeInt, - DefaultValue: 3, - MinValue: 0, - MaxValue: 10, - Required: false, - DisplayName: "Default Max Retries", - Description: "Default number of times to retry failed tasks", - HelpText: "Tasks will be retried this many times before being marked as permanently failed", - Placeholder: "3 (default)", - Unit: UnitCount, - InputType: "number", - CSSClasses: "form-control", - }, - "cleanup_interval_seconds": { - Name: "cleanup_interval_seconds", - JSONName: "cleanup_interval_seconds", - Type: FieldTypeInterval, - DefaultValue: 24 * 60 * 60, // 24 hours - MinValue: 1 * 60 * 60, // 1 hour - MaxValue: 7 * 24 * 60 * 60, // 7 days - Required: true, - DisplayName: "Cleanup Interval", - Description: "How often to clean up old task records", - HelpText: "The system will remove old completed/failed tasks at this interval", - Placeholder: "24", - Unit: UnitHours, - InputType: "interval", - CSSClasses: "form-control", - }, - "task_retention_seconds": { - Name: "task_retention_seconds", - JSONName: "task_retention_seconds", - Type: FieldTypeInterval, - DefaultValue: 7 * 24 * 60 * 60, // 7 days - MinValue: 1 * 24 * 60 * 60, // 1 day - MaxValue: 30 * 24 * 60 * 60, // 30 days - Required: true, - DisplayName: "Task Retention", - Description: "How long to keep completed/failed task records", - HelpText: "Task records older than this will be automatically deleted", - Placeholder: "7", - Unit: UnitDays, - InputType: "interval", - CSSClasses: "form-control", - }, - "global_max_concurrent": { - Name: "global_max_concurrent", - JSONName: "global_max_concurrent", - Type: FieldTypeInt, - DefaultValue: 4, - MinValue: 1, - MaxValue: 20, - Required: true, - DisplayName: "Global Concurrent Limit", - Description: "Maximum number of maintenance tasks that can run simultaneously across all workers", - HelpText: "This limits the total system load from maintenance operations", - Placeholder: "4 (default)", - Unit: UnitCount, - InputType: "number", - CSSClasses: "form-control", + Schema: config.Schema{ + Fields: []*config.Field{ + { + Name: "enabled", + JSONName: "enabled", + Type: config.FieldTypeBool, + DefaultValue: false, + Required: false, + DisplayName: "Enable Maintenance System", + Description: "When enabled, the system will automatically scan for and execute maintenance tasks", + HelpText: "Toggle this to enable or disable the entire maintenance system", + InputType: "checkbox", + CSSClasses: "form-check-input", + }, + { + Name: "scan_interval_seconds", + JSONName: "scan_interval_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 30 * 60, // 30 minutes in seconds + MinValue: 1 * 60, // 1 minute + MaxValue: 24 * 60 * 60, // 24 hours + Required: true, + DisplayName: "Scan Interval", + Description: "How often to scan for maintenance tasks", + HelpText: "The system will check for new maintenance tasks at this interval", + Placeholder: "30", + Unit: config.UnitMinutes, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "worker_timeout_seconds", + JSONName: "worker_timeout_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 5 * 60, // 5 minutes + MinValue: 1 * 60, // 1 minute + MaxValue: 60 * 60, // 1 hour + Required: true, + DisplayName: "Worker Timeout", + Description: "How long to wait for worker heartbeat before considering it inactive", + HelpText: "Workers that don't send heartbeats within this time are considered offline", + Placeholder: "5", + Unit: config.UnitMinutes, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "task_timeout_seconds", + JSONName: "task_timeout_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 2 * 60 * 60, // 2 hours + MinValue: 10 * 60, // 10 minutes + MaxValue: 24 * 60 * 60, // 24 hours + Required: true, + DisplayName: "Task Timeout", + Description: "Maximum time allowed for a task to complete", + HelpText: "Tasks that exceed this duration will be marked as failed", + Placeholder: "2", + Unit: config.UnitHours, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "retry_delay_seconds", + JSONName: "retry_delay_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 15 * 60, // 15 minutes + MinValue: 1 * 60, // 1 minute + MaxValue: 24 * 60 * 60, // 24 hours + Required: true, + DisplayName: "Retry Delay", + Description: "How long to wait before retrying a failed task", + HelpText: "Failed tasks will be retried after this delay", + Placeholder: "15", + Unit: config.UnitMinutes, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "max_retries", + JSONName: "max_retries", + Type: config.FieldTypeInt, + DefaultValue: 3, + MinValue: 0, + MaxValue: 10, + Required: true, + DisplayName: "Max Retries", + Description: "Maximum number of times to retry a failed task", + HelpText: "Tasks that fail more than this many times will be marked as permanently failed", + Placeholder: "3", + Unit: config.UnitCount, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "cleanup_interval_seconds", + JSONName: "cleanup_interval_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 24 * 60 * 60, // 24 hours + MinValue: 1 * 60 * 60, // 1 hour + MaxValue: 7 * 24 * 60 * 60, // 7 days + Required: true, + DisplayName: "Cleanup Interval", + Description: "How often to run maintenance cleanup operations", + HelpText: "Removes old task records and temporary files at this interval", + Placeholder: "24", + Unit: config.UnitHours, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "task_retention_seconds", + JSONName: "task_retention_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 7 * 24 * 60 * 60, // 7 days + MinValue: 1 * 24 * 60 * 60, // 1 day + MaxValue: 30 * 24 * 60 * 60, // 30 days + Required: true, + DisplayName: "Task Retention", + Description: "How long to keep completed task records", + HelpText: "Task history older than this duration will be automatically deleted", + Placeholder: "7", + Unit: config.UnitDays, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "global_max_concurrent", + JSONName: "global_max_concurrent", + Type: config.FieldTypeInt, + DefaultValue: 10, + MinValue: 1, + MaxValue: 100, + Required: true, + DisplayName: "Global Max Concurrent Tasks", + Description: "Maximum number of maintenance tasks that can run simultaneously across all workers", + HelpText: "Limits the total number of maintenance operations to control system load", + Placeholder: "10", + Unit: config.UnitCount, + InputType: "number", + CSSClasses: "form-control", + }, }, }, } } - -// ApplyDefaults applies default values to a configuration struct using reflection -func (schema *MaintenanceConfigSchema) ApplyDefaults(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, exists := schema.Fields[jsonTag] - if !exists { - continue - } - - // Apply default if field is zero value - if field.CanSet() && isZeroValue(field) { - defaultValue := reflect.ValueOf(schemaField.DefaultValue) - if defaultValue.Type().ConvertibleTo(field.Type()) { - field.Set(defaultValue.Convert(field.Type())) - } - } - } - - return nil -} - -// isZeroValue checks if a reflect.Value represents a zero value -func isZeroValue(v reflect.Value) bool { - switch v.Kind() { - case reflect.Bool: - return !v.Bool() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return v.Int() == 0 - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return v.Uint() == 0 - case reflect.Float32, reflect.Float64: - return v.Float() == 0 - case reflect.String: - return v.String() == "" - case reflect.Slice, reflect.Map, reflect.Array: - return v.IsNil() || v.Len() == 0 - case reflect.Interface, reflect.Ptr: - return v.IsNil() - } - return false -} - -// ValidateConfig validates a configuration against the schema -func (schema *MaintenanceConfigSchema) ValidateConfig(config interface{}) []error { - var errors []error - - configValue := reflect.ValueOf(config) - if configValue.Kind() == reflect.Ptr { - configValue = configValue.Elem() - } - - if configValue.Kind() != reflect.Struct { - errors = append(errors, fmt.Errorf("config must be a struct or pointer to struct")) - return errors - } - - 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, exists := schema.Fields[jsonTag] - if !exists { - continue - } - - // Validate field value - fieldValue := field.Interface() - if err := schemaField.ValidateValue(fieldValue); err != nil { - errors = append(errors, err) - } - } - - return errors -} diff --git a/weed/admin/maintenance/maintenance_types.go b/weed/admin/maintenance/maintenance_types.go index aa33033fb..0489553a5 100644 --- a/weed/admin/maintenance/maintenance_types.go +++ b/weed/admin/maintenance/maintenance_types.go @@ -306,7 +306,7 @@ func DefaultMaintenanceConfig() *MaintenanceConfig { // Apply policy defaults from schema if config.Policy != nil { - if globalMaxField, exists := schema.Fields["global_max_concurrent"]; exists { + if globalMaxField := schema.GetFieldByName("global_max_concurrent"); globalMaxField != nil { if defaultVal, ok := globalMaxField.DefaultValue.(int); ok { config.Policy.GlobalMaxConcurrent = defaultVal } diff --git a/weed/admin/view/app/maintenance_config_schema.templ b/weed/admin/view/app/maintenance_config_schema.templ index f58a509b3..74f7c1035 100644 --- a/weed/admin/view/app/maintenance_config_schema.templ +++ b/weed/admin/view/app/maintenance_config_schema.templ @@ -2,7 +2,10 @@ package app import ( "fmt" + "reflect" + "strings" "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" + "github.com/seaweedfs/seaweedfs/weed/admin/config" ) templ MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *maintenance.MaintenanceConfigSchema) { @@ -11,13 +14,13 @@ templ MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *m
Configure specific settings for each maintenance task type.
-{menuItem.Description}
- - } -Reclaims disk space by removing deleted files from volumes.
+ Configure{data.LastScanTime.Format("2006-01-02 15:04:05")}
-{data.NextScanTime.Format("2006-01-02 15:04:05")}
-{fmt.Sprintf("%d", data.SystemStats.TotalTasks)}
-{fmt.Sprintf("%d", data.SystemStats.ActiveWorkers)}
-Redistributes volumes across servers to optimize storage utilization.
+ Configure +Converts volumes to erasure coded format for improved durability.
+ Configure{schema.Description}
+Performance: Vacuum operations are I/O intensive and may impact cluster performance.
+Safety: Only volumes meeting age and garbage thresholds will be processed.
+Recommendation: Monitor cluster load and adjust concurrent limits accordingly.
+ } else if schema.TaskName == "balance" { +Performance: Volume balancing involves data movement and can impact cluster performance.
+Safety: Requires adequate server count to ensure data safety during moves.
+Recommendation: Run during off-peak hours to minimize impact on production workloads.
+ } else if schema.TaskName == "erasure_coding" { +Performance: Erasure coding is CPU and I/O intensive. Consider running during off-peak hours.
+Durability: With 10+4 configuration, can tolerate up to 4 shard failures.
+Configuration: Fullness ratio should be between 0.5 and 1.0 (e.g., 0.90 for 90%).
+ } +") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(schema.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 36, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Performance: Vacuum operations are I/O intensive and may impact cluster performance.
Safety: Only volumes meeting age and garbage thresholds will be processed.
Recommendation: Monitor cluster load and adjust concurrent limits accordingly.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if schema.TaskName == "balance" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Performance: Volume balancing involves data movement and can impact cluster performance.
Safety: Requires adequate server count to ensure data safety during moves.
Recommendation: Run during off-peak hours to minimize impact on production workloads.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if schema.TaskName == "erasure_coding" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Performance: Erasure coding is CPU and I/O intensive. Consider running during off-peak hours.
Durability: With 10+4 configuration, can tolerate up to 4 shard failures.
Configuration: Fullness ratio should be between 0.5 and 1.0 (e.g., 0.90 for 90%).
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "