From 6659c213c30074e4e19870ded5008f71e9e18474 Mon Sep 17 00:00:00 2001 From: chrislu Date: Sat, 26 Jul 2025 22:06:31 -0700 Subject: [PATCH] config --- weed/admin/config/schema.go | 350 +++++++ weed/admin/handlers/maintenance_handlers.go | 39 +- weed/admin/maintenance/config_schema.go | 630 ++++--------- weed/admin/maintenance/maintenance_types.go | 2 +- .../view/app/maintenance_config_schema.templ | 276 +++--- .../app/maintenance_config_schema_templ.go | 554 +++++------ weed/admin/view/app/task_config_schema.templ | 396 ++++++++ .../view/app/task_config_schema_templ.go | 877 ++++++++++++++++++ weed/worker/tasks/task_config_schema.go | 310 +++++++ 9 files changed, 2503 insertions(+), 931 deletions(-) create mode 100644 weed/admin/config/schema.go create mode 100644 weed/admin/view/app/task_config_schema.templ create mode 100644 weed/admin/view/app/task_config_schema_templ.go create mode 100644 weed/worker/tasks/task_config_schema.go 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

- - Maintenance Configuration (SCHEMA_TEMPLATE) + + Maintenance Configuration

@@ -32,17 +35,9 @@ templ MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *m
- - @ConfigField(schema.Fields["enabled"], data.Config.Enabled) - @ConfigField(schema.Fields["scan_interval_seconds"], data.Config.ScanIntervalSeconds) - @ConfigField(schema.Fields["worker_timeout_seconds"], data.Config.WorkerTimeoutSeconds) - @ConfigField(schema.Fields["task_timeout_seconds"], data.Config.TaskTimeoutSeconds) - @ConfigField(schema.Fields["retry_delay_seconds"], data.Config.RetryDelaySeconds) - @ConfigField(schema.Fields["max_retries"], data.Config.MaxRetries) - @ConfigField(schema.Fields["cleanup_interval_seconds"], data.Config.CleanupIntervalSeconds) - @ConfigField(schema.Fields["task_retention_seconds"], data.Config.TaskRetentionSeconds) - if data.Config.Policy != nil { - @ConfigField(schema.Fields["global_max_concurrent"], data.Config.Policy.GlobalMaxConcurrent) + + for _, field := range schema.Fields { + @ConfigField(field, getMaintenanceFieldValue(data.Config, field.JSONName)) }
@@ -61,75 +56,47 @@ templ MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *m
- +
-
+
- - Task Configuration + + Volume Vacuum
-

Configure specific settings for each maintenance task type.

- +

Reclaims disk space by removing deleted files from volumes.

+ Configure
-
- - -
-
+
-
System Statistics
+
+ + Volume Balance +
-
-
-
-
Last Scan
-

{data.LastScanTime.Format("2006-01-02 15:04:05")}

-
-
-
-
-
Next Scan
-

{data.NextScanTime.Format("2006-01-02 15:04:05")}

-
-
-
-
-
Total Tasks
-

{fmt.Sprintf("%d", data.SystemStats.TotalTasks)}

-
-
-
-
-
Active Workers
-

{fmt.Sprintf("%d", data.SystemStats.ActiveWorkers)}

-
-
-
+

Redistributes volumes across servers to optimize storage utilization.

+ Configure +
+
+
+
+
+
+
+ + Erasure Coding +
+
+
+

Converts volumes to erasure coded format for improved durability.

+ Configure
@@ -140,19 +107,50 @@ templ MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *m function saveConfiguration() { const form = document.getElementById('maintenanceConfigForm'); const formData = new FormData(form); + + // Convert form data to JSON, handling interval fields specially const config = {}; - - // Convert FormData to config object with proper type conversion - for (const [key, value] of formData.entries()) { - const field = getSchemaField(key); - if (field) { - config[key] = convertFieldValue(field, value); + + for (let [key, value] of formData.entries()) { + if (key.endsWith('_value')) { + // This is an interval value part + const baseKey = key.replace('_value', ''); + const unitKey = baseKey + '_unit'; + const unitValue = formData.get(unitKey); + + if (unitValue) { + // Convert to seconds based on unit + const numValue = parseInt(value) || 0; + let seconds = numValue; + switch(unitValue) { + case 'minutes': + seconds = numValue * 60; + break; + case 'hours': + seconds = numValue * 3600; + break; + case 'days': + seconds = numValue * 24 * 3600; + break; + } + config[baseKey] = seconds; + } + } else if (key.endsWith('_unit')) { + // Skip unit keys - they're handled with their corresponding value + continue; + } else { + // Regular field + if (form.querySelector(`[name="${key}"]`).type === 'checkbox') { + config[key] = form.querySelector(`[name="${key}"]`).checked; + } else { + const numValue = parseFloat(value); + config[key] = isNaN(numValue) ? value : numValue; + } } } - // Send the configuration - fetch('/api/maintenance/config', { - method: 'PUT', + fetch('/maintenance/config', { + method: 'POST', headers: { 'Content-Type': 'application/json', }, @@ -161,61 +159,46 @@ templ MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *m .then(response => response.json()) .then(data => { if (data.success) { - alert('Configuration saved successfully'); - location.reload(); // Reload to show updated values + alert('Configuration saved successfully!'); + location.reload(); } else { - alert('Failed to save configuration: ' + (data.error || 'Unknown error')); + alert('Error saving configuration: ' + (data.error || 'Unknown error')); } }) .catch(error => { - alert('Error: ' + error.message); + console.error('Error:', error); + alert('Error saving configuration: ' + error.message); }); } function resetToDefaults() { if (confirm('Are you sure you want to reset to default configuration? This will overwrite your current settings.')) { - // Get schema defaults and apply them - const schema = window.maintenanceConfigSchema || {}; - Object.keys(schema.fields || {}).forEach(fieldName => { - const field = schema.fields[fieldName]; - const element = document.getElementById(fieldName); - if (element && field.default_value !== undefined) { - if (field.input_type === 'checkbox') { - element.checked = field.default_value; - } else { - element.value = field.GetDisplayValue ? field.GetDisplayValue(field.default_value) : field.default_value; - } + fetch('/maintenance/config/defaults', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Configuration reset to defaults!'); + location.reload(); + } else { + alert('Error resetting configuration: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error resetting configuration: ' + error.message); }); } } - - function getSchemaField(fieldName) { - const schema = window.maintenanceConfigSchema || {}; - return schema.fields && schema.fields[fieldName]; - } - - function convertFieldValue(field, value) { - switch (field.type) { - case 'bool': - return value === 'on' || value === 'true'; - case 'int': - const intVal = parseInt(value); - return field.ParseDisplayValue ? field.ParseDisplayValue(intVal) : intVal; - case 'float': - return parseFloat(value); - default: - return value; - } - } - - // Store schema data for JavaScript access - window.maintenanceConfigSchema = @schemaToJSON(schema); } // ConfigField renders a single configuration field based on schema -templ ConfigField(field *maintenance.ConfigField, value interface{}) { +templ ConfigField(field *config.Field, value interface{}) { if field.InputType == "interval" {
@@ -320,6 +303,7 @@ templ ConfigField(field *maintenance.ConfigField, value interface{}) { if field.MaxValue != nil { max={ fmt.Sprintf("%v", field.MaxValue) } } + step={ getNumberStep(field) } if field.Required { required } @@ -339,7 +323,7 @@ func getBoolValue(value interface{}) bool { return false } -func convertSecondsToDisplayValue(value interface{}, field *maintenance.ConfigField) float64 { +func convertSecondsToDisplayValue(value interface{}, field *config.Field) float64 { if intVal, ok := value.(int); ok { if intVal == 0 { return 0 @@ -361,7 +345,7 @@ func convertSecondsToDisplayValue(value interface{}, field *maintenance.ConfigFi return 0 } -func getDisplayUnit(value interface{}, field *maintenance.ConfigField) string { +func getDisplayUnit(value interface{}, field *config.Field) string { if intVal, ok := value.(int); ok { if intVal == 0 { return "minutes" @@ -383,6 +367,58 @@ func getDisplayUnit(value interface{}, field *maintenance.ConfigField) string { return "minutes" } +func getNumberStep(field *config.Field) string { + if field.Type == config.FieldTypeFloat { + return "0.01" + } + return "1" +} + +func getMaintenanceFieldValue(config *maintenance.MaintenanceConfig, fieldName string) interface{} { + if config == nil { + return nil + } + + // Use reflection to get the field value from the config struct + configValue := reflect.ValueOf(config) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + if configValue.Kind() != reflect.Struct { + return nil + } + + 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] + } + + if jsonTag == fieldName { + return field.Interface() + } + } + + // Handle nested Policy fields + if config.Policy != nil && fieldName == "global_max_concurrent" { + return config.Policy.GlobalMaxConcurrent + } + + return nil +} + // Helper function to convert schema to JSON for JavaScript templ schemaToJSON(schema *maintenance.MaintenanceConfigSchema) { {`{}`} diff --git a/weed/admin/view/app/maintenance_config_schema_templ.go b/weed/admin/view/app/maintenance_config_schema_templ.go index 2685ef0e5..3586c40f4 100644 --- a/weed/admin/view/app/maintenance_config_schema_templ.go +++ b/weed/admin/view/app/maintenance_config_schema_templ.go @@ -10,7 +10,10 @@ import templruntime "github.com/a-h/templ/runtime" import ( "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/config" "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" + "reflect" + "strings" ) func MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *maintenance.MaintenanceConfigSchema) templ.Component { @@ -34,187 +37,17 @@ func MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *ma templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Maintenance Configuration (SCHEMA_TEMPLATE)

System Settings
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Maintenance Configuration

System Settings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = ConfigField(schema.Fields["enabled"], data.Config.Enabled).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["scan_interval_seconds"], data.Config.ScanIntervalSeconds).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["worker_timeout_seconds"], data.Config.WorkerTimeoutSeconds).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["task_timeout_seconds"], data.Config.TaskTimeoutSeconds).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["retry_delay_seconds"], data.Config.RetryDelaySeconds).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["max_retries"], data.Config.MaxRetries).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["cleanup_interval_seconds"], data.Config.CleanupIntervalSeconds).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = ConfigField(schema.Fields["task_retention_seconds"], data.Config.TaskRetentionSeconds).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if data.Config.Policy != nil { - templ_7745c5c3_Err = ConfigField(schema.Fields["global_max_concurrent"], data.Config.Policy.GlobalMaxConcurrent).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Task Configuration

Configure specific settings for each maintenance task type.

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, menuItem := range data.MenuItems { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 = []any{menuItem.Icon + " me-2"} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.DisplayName) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 82, Col: 65} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if menuItem.IsEnabled { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Enabled") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Disabled") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Description) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 90, Col: 90} - } - _, 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, 11, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
System Statistics
Last Scan

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastScanTime.Format("2006-01-02 15:04:05")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 111, Col: 100} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Next Scan

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.NextScanTime.Format("2006-01-02 15:04:05")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 117, Col: 100} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

Total Tasks

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.SystemStats.TotalTasks)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 123, Col: 99} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

Active Workers

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.SystemStats.ActiveWorkers)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 129, Col: 102} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Volume Vacuum

Reclaims disk space by removing deleted files from volumes.

Configure
Volume Balance

Redistributes volumes across servers to optimize storage utilization.

Configure
Erasure Coding

Converts volumes to erasure coded format for improved durability.

Configure
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -223,7 +56,7 @@ func MaintenanceConfigSchema(data *maintenance.MaintenanceConfigData, schema *ma } // ConfigField renders a single configuration field based on schema -func ConfigField(field *maintenance.ConfigField, value interface{}) templ.Component { +func ConfigField(field *config.Field, value interface{}) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -239,441 +72,458 @@ func ConfigField(field *maintenance.ConfigField, value interface{}) templ.Compon }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if field.InputType == "interval" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if field.InputType == "checkbox" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if field.Description != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(field.Description) + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(field.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 298, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 281, Col: 69} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -690,7 +540,7 @@ func getBoolValue(value interface{}) bool { return false } -func convertSecondsToDisplayValue(value interface{}, field *maintenance.ConfigField) float64 { +func convertSecondsToDisplayValue(value interface{}, field *config.Field) float64 { if intVal, ok := value.(int); ok { if intVal == 0 { return 0 @@ -712,7 +562,7 @@ func convertSecondsToDisplayValue(value interface{}, field *maintenance.ConfigFi return 0 } -func getDisplayUnit(value interface{}, field *maintenance.ConfigField) string { +func getDisplayUnit(value interface{}, field *config.Field) string { if intVal, ok := value.(int); ok { if intVal == 0 { return "minutes" @@ -734,6 +584,58 @@ func getDisplayUnit(value interface{}, field *maintenance.ConfigField) string { return "minutes" } +func getNumberStep(field *config.Field) string { + if field.Type == config.FieldTypeFloat { + return "0.01" + } + return "1" +} + +func getMaintenanceFieldValue(config *maintenance.MaintenanceConfig, fieldName string) interface{} { + if config == nil { + return nil + } + + // Use reflection to get the field value from the config struct + configValue := reflect.ValueOf(config) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + if configValue.Kind() != reflect.Struct { + return nil + } + + 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] + } + + if jsonTag == fieldName { + return field.Interface() + } + } + + // Handle nested Policy fields + if config.Policy != nil && fieldName == "global_max_concurrent" { + return config.Policy.GlobalMaxConcurrent + } + + return nil +} + // Helper function to convert schema to JSON for JavaScript func schemaToJSON(schema *maintenance.MaintenanceConfigSchema) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { @@ -751,17 +653,17 @@ func schemaToJSON(schema *maintenance.MaintenanceConfigSchema) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(`{}`) + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(`{}`) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 388, Col: 9} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config_schema.templ`, Line: 424, Col: 9} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/task_config_schema.templ b/weed/admin/view/app/task_config_schema.templ new file mode 100644 index 000000000..8b9922d67 --- /dev/null +++ b/weed/admin/view/app/task_config_schema.templ @@ -0,0 +1,396 @@ +package app + +import ( + "fmt" + "reflect" + "strings" + "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks" + "github.com/seaweedfs/seaweedfs/weed/admin/config" +) + +templ TaskConfigSchema(data *maintenance.TaskConfigData, schema *tasks.TaskConfigSchema, config interface{}) { +
+
+
+
+

+ + {schema.DisplayName} Configuration +

+ +
+
+
+ +
+
+
+
+
{schema.DisplayName} Settings
+

{schema.Description}

+
+
+
+ + for _, field := range schema.Fields { + @TaskConfigField(field, getTaskFieldValue(config, field.JSONName)) + } + +
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+ + Important Notes +
+
+
+ +
+
+
+
+
+ + +} + +// TaskConfigField renders a single task configuration field based on schema +templ TaskConfigField(field *config.Field, value interface{}) { + if field.InputType == "interval" { + +
+ +
+ + +
+ if field.Description != "" { +
{ field.Description }
+ } +
+ } else if field.InputType == "checkbox" { + +
+
+ + +
+ if field.Description != "" { +
{ field.Description }
+ } +
+ } else if field.InputType == "text" { + +
+ + + if field.Description != "" { +
{ field.Description }
+ } +
+ } else { + +
+ + + if field.Description != "" { +
{ field.Description }
+ } +
+ } +} + +// Helper functions for the template +func getTaskBoolValue(value interface{}) bool { + if boolVal, ok := value.(bool); ok { + return boolVal + } + return false +} + +func convertTaskSecondsToDisplayValue(value interface{}, field *config.Field) float64 { + if intVal, ok := value.(int); ok { + if intVal == 0 { + return 0 + } + + // Check if it's evenly divisible by days + if intVal%(24*3600) == 0 { + return float64(intVal / (24 * 3600)) + } + + // Check if it's evenly divisible by hours + if intVal%3600 == 0 { + return float64(intVal / 3600) + } + + // Default to minutes + return float64(intVal / 60) + } + return 0 +} + +func getTaskDisplayUnit(value interface{}, field *config.Field) string { + if intVal, ok := value.(int); ok { + if intVal == 0 { + return "minutes" + } + + // Check if it's evenly divisible by days + if intVal%(24*3600) == 0 { + return "days" + } + + // Check if it's evenly divisible by hours + if intVal%3600 == 0 { + return "hours" + } + + // Default to minutes + return "minutes" + } + return "minutes" +} + +func getTaskNumberStep(field *config.Field) string { + if field.Type == config.FieldTypeFloat { + return "0.01" + } + return "1" +} + +func getTaskFieldValue(config interface{}, fieldName string) interface{} { + if config == nil { + return nil + } + + // Use reflection to get the field value from the config struct + configValue := reflect.ValueOf(config) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + if configValue.Kind() != reflect.Struct { + return nil + } + + 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] + } + + if jsonTag == fieldName { + return field.Interface() + } + } + + return nil +} + +// Helper function to convert schema to JSON for JavaScript +templ taskSchemaToTaskJSON(schema *tasks.TaskConfigSchema) { + {`{}`} +} \ No newline at end of file diff --git a/weed/admin/view/app/task_config_schema_templ.go b/weed/admin/view/app/task_config_schema_templ.go new file mode 100644 index 000000000..2bcfedf39 --- /dev/null +++ b/weed/admin/view/app/task_config_schema_templ.go @@ -0,0 +1,877 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.906 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/config" + "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks" + "reflect" + "strings" +) + +func TaskConfigSchema(data *maintenance.TaskConfigData, schema *tasks.TaskConfigSchema, config interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 = []any{schema.Icon + " me-2"} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(schema.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 19, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " Configuration

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(schema.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 35, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " Settings

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, field := range schema.Fields { + templ_7745c5c3_Err = TaskConfigField(field, getTaskFieldValue(config, field.JSONName)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Important Notes
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if schema.TaskName == "vacuum" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
Vacuum Operations:

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, "
Balance Operations:

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, "
Erasure Coding Operations:

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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// TaskConfigField renders a single task configuration field based on schema +func TaskConfigField(field *config.Field, value interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if field.InputType == "interval" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if field.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(field.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 212, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if field.InputType == "checkbox" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if field.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(field.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 233, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if field.InputType == "text" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if field.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(field.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 257, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if field.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(field.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 288, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// Helper functions for the template +func getTaskBoolValue(value interface{}) bool { + if boolVal, ok := value.(bool); ok { + return boolVal + } + return false +} + +func convertTaskSecondsToDisplayValue(value interface{}, field *config.Field) float64 { + if intVal, ok := value.(int); ok { + if intVal == 0 { + return 0 + } + + // Check if it's evenly divisible by days + if intVal%(24*3600) == 0 { + return float64(intVal / (24 * 3600)) + } + + // Check if it's evenly divisible by hours + if intVal%3600 == 0 { + return float64(intVal / 3600) + } + + // Default to minutes + return float64(intVal / 60) + } + return 0 +} + +func getTaskDisplayUnit(value interface{}, field *config.Field) string { + if intVal, ok := value.(int); ok { + if intVal == 0 { + return "minutes" + } + + // Check if it's evenly divisible by days + if intVal%(24*3600) == 0 { + return "days" + } + + // Check if it's evenly divisible by hours + if intVal%3600 == 0 { + return "hours" + } + + // Default to minutes + return "minutes" + } + return "minutes" +} + +func getTaskNumberStep(field *config.Field) string { + if field.Type == config.FieldTypeFloat { + return "0.01" + } + return "1" +} + +func getTaskFieldValue(config interface{}, fieldName string) interface{} { + if config == nil { + return nil + } + + // Use reflection to get the field value from the config struct + configValue := reflect.ValueOf(config) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + if configValue.Kind() != reflect.Struct { + return nil + } + + 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] + } + + if jsonTag == fieldName { + return field.Interface() + } + } + + return nil +} + +// Helper function to convert schema to JSON for JavaScript +func taskSchemaToTaskJSON(schema *tasks.TaskConfigSchema) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var38 := templ.GetChildren(ctx) + if templ_7745c5c3_Var38 == nil { + templ_7745c5c3_Var38 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(`{}`) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_schema.templ`, Line: 395, Col: 9} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/worker/tasks/task_config_schema.go b/weed/worker/tasks/task_config_schema.go new file mode 100644 index 000000000..7149ab91d --- /dev/null +++ b/weed/worker/tasks/task_config_schema.go @@ -0,0 +1,310 @@ +package tasks + +import ( + "github.com/seaweedfs/seaweedfs/weed/admin/config" +) + +// TaskConfigSchema defines the schema for task configuration +type TaskConfigSchema struct { + config.Schema // Embed common schema functionality + TaskName string `json:"task_name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Icon string `json:"icon"` +} + +// GetTaskConfigSchema returns the schema for the specified task type +func GetTaskConfigSchema(taskType string) *TaskConfigSchema { + switch taskType { + case "vacuum": + return GetVacuumTaskConfigSchema() + case "balance": + return GetBalanceTaskConfigSchema() + case "erasure_coding": + return GetErasureCodingTaskConfigSchema() + default: + return nil + } +} + +// GetVacuumTaskConfigSchema returns the schema for vacuum task configuration +func GetVacuumTaskConfigSchema() *TaskConfigSchema { + return &TaskConfigSchema{ + TaskName: "vacuum", + DisplayName: "Volume Vacuum", + Description: "Reclaims disk space by removing deleted files from volumes", + Icon: "fas fa-broom text-primary", + Schema: config.Schema{ + Fields: []*config.Field{ + { + Name: "enabled", + JSONName: "enabled", + Type: config.FieldTypeBool, + DefaultValue: true, + Required: false, + DisplayName: "Enable Vacuum Tasks", + Description: "Whether vacuum tasks should be automatically created", + HelpText: "Toggle this to enable or disable automatic vacuum task generation", + InputType: "checkbox", + CSSClasses: "form-check-input", + }, + { + Name: "garbage_threshold", + JSONName: "garbage_threshold", + Type: config.FieldTypeFloat, + DefaultValue: 0.3, // 30% + MinValue: 0.0, + MaxValue: 1.0, + Required: true, + DisplayName: "Garbage Percentage Threshold", + Description: "Trigger vacuum when garbage ratio exceeds this percentage", + HelpText: "Volumes with more deleted content than this threshold will be vacuumed", + Placeholder: "0.30 (30%)", + Unit: config.UnitNone, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "scan_interval_seconds", + JSONName: "scan_interval_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 2 * 60 * 60, // 2 hours + MinValue: 10 * 60, // 10 minutes + MaxValue: 24 * 60 * 60, // 24 hours + Required: true, + DisplayName: "Scan Interval", + Description: "How often to scan for volumes needing vacuum", + HelpText: "The system will check for volumes that need vacuuming at this interval", + Placeholder: "2", + Unit: config.UnitHours, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "max_concurrent", + JSONName: "max_concurrent", + Type: config.FieldTypeInt, + DefaultValue: 2, + MinValue: 1, + MaxValue: 10, + Required: true, + DisplayName: "Max Concurrent Tasks", + Description: "Maximum number of vacuum tasks that can run simultaneously", + HelpText: "Limits the number of vacuum operations running at the same time to control system load", + Placeholder: "2 (default)", + Unit: config.UnitCount, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "min_volume_age_seconds", + JSONName: "min_volume_age_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: "Minimum Volume Age", + Description: "Only vacuum volumes older than this duration", + HelpText: "Prevents vacuuming of recently created volumes that may still be actively written to", + Placeholder: "24", + Unit: config.UnitHours, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "min_interval_seconds", + JSONName: "min_interval_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: "Minimum Interval", + Description: "Minimum time between vacuum operations on the same volume", + HelpText: "Prevents excessive vacuuming of the same volume by enforcing a minimum wait time", + Placeholder: "7", + Unit: config.UnitDays, + InputType: "interval", + CSSClasses: "form-control", + }, + }, + }, + } +} + +// GetBalanceTaskConfigSchema returns the schema for balance task configuration +func GetBalanceTaskConfigSchema() *TaskConfigSchema { + return &TaskConfigSchema{ + TaskName: "balance", + DisplayName: "Volume Balance", + Description: "Redistributes volumes across volume servers to optimize storage utilization", + Icon: "fas fa-balance-scale text-secondary", + Schema: config.Schema{ + Fields: []*config.Field{ + { + Name: "enabled", + JSONName: "enabled", + Type: config.FieldTypeBool, + DefaultValue: true, + Required: false, + DisplayName: "Enable Balance Tasks", + Description: "Whether balance tasks should be automatically created", + InputType: "checkbox", + CSSClasses: "form-check-input", + }, + { + Name: "imbalance_threshold", + JSONName: "imbalance_threshold", + Type: config.FieldTypeFloat, + DefaultValue: 0.1, // 10% + MinValue: 0.01, + MaxValue: 0.5, + Required: true, + DisplayName: "Imbalance Threshold", + Description: "Trigger balance when storage imbalance exceeds this ratio", + Placeholder: "0.10 (10%)", + Unit: config.UnitNone, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "scan_interval_seconds", + JSONName: "scan_interval_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 6 * 60 * 60, // 6 hours + MinValue: 1 * 60 * 60, // 1 hour + MaxValue: 24 * 60 * 60, // 24 hours + Required: true, + DisplayName: "Scan Interval", + Description: "How often to scan for imbalanced volumes", + Unit: config.UnitHours, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "max_concurrent", + JSONName: "max_concurrent", + Type: config.FieldTypeInt, + DefaultValue: 2, + MinValue: 1, + MaxValue: 5, + Required: true, + DisplayName: "Max Concurrent Tasks", + Description: "Maximum number of balance tasks that can run simultaneously", + Unit: config.UnitCount, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "min_server_count", + JSONName: "min_server_count", + Type: config.FieldTypeInt, + DefaultValue: 3, + MinValue: 2, + MaxValue: 20, + Required: true, + DisplayName: "Minimum Server Count", + Description: "Only balance when at least this many servers are available", + Unit: config.UnitCount, + InputType: "number", + CSSClasses: "form-control", + }, + }, + }, + } +} + +// GetErasureCodingTaskConfigSchema returns the schema for erasure coding task configuration +func GetErasureCodingTaskConfigSchema() *TaskConfigSchema { + return &TaskConfigSchema{ + TaskName: "erasure_coding", + DisplayName: "Erasure Coding", + Description: "Converts volumes to erasure coded format for improved data durability", + Icon: "fas fa-shield-alt text-info", + Schema: config.Schema{ + Fields: []*config.Field{ + { + Name: "enabled", + JSONName: "enabled", + Type: config.FieldTypeBool, + DefaultValue: true, + Required: false, + DisplayName: "Enable Erasure Coding Tasks", + Description: "Whether erasure coding tasks should be automatically created", + InputType: "checkbox", + CSSClasses: "form-check-input", + }, + { + Name: "quiet_for_seconds", + JSONName: "quiet_for_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: "Quiet For Duration", + Description: "Only apply erasure coding to volumes that have not been modified for this duration", + Unit: config.UnitDays, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "scan_interval_seconds", + JSONName: "scan_interval_seconds", + Type: config.FieldTypeInterval, + DefaultValue: 12 * 60 * 60, // 12 hours + MinValue: 2 * 60 * 60, // 2 hours + MaxValue: 24 * 60 * 60, // 24 hours + Required: true, + DisplayName: "Scan Interval", + Description: "How often to scan for volumes needing erasure coding", + Unit: config.UnitHours, + InputType: "interval", + CSSClasses: "form-control", + }, + { + Name: "max_concurrent", + JSONName: "max_concurrent", + Type: config.FieldTypeInt, + DefaultValue: 1, + MinValue: 1, + MaxValue: 3, + Required: true, + DisplayName: "Max Concurrent Tasks", + Description: "Maximum number of erasure coding tasks that can run simultaneously", + Unit: config.UnitCount, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "fullness_ratio", + JSONName: "fullness_ratio", + Type: config.FieldTypeFloat, + DefaultValue: 0.9, // 90% + MinValue: 0.5, + MaxValue: 1.0, + Required: true, + DisplayName: "Fullness Ratio", + Description: "Only apply erasure coding to volumes with fullness ratio above this threshold", + Placeholder: "0.90 (90%)", + Unit: config.UnitNone, + InputType: "number", + CSSClasses: "form-control", + }, + { + Name: "collection_filter", + JSONName: "collection_filter", + Type: config.FieldTypeString, + DefaultValue: "", + Required: false, + DisplayName: "Collection Filter", + Description: "Only apply erasure coding to volumes in these collections (comma-separated, leave empty for all)", + Placeholder: "collection1,collection2", + InputType: "text", + CSSClasses: "form-control", + }, + }, + }, + } +}