aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--weed/admin/dash/admin_server.go632
-rw-r--r--weed/admin/dash/config_persistence.go270
-rw-r--r--weed/admin/dash/types.go49
-rw-r--r--weed/admin/dash/worker_grpc_server.go461
-rw-r--r--weed/admin/handlers/admin_handlers.go53
-rw-r--r--weed/admin/handlers/maintenance_handlers.go388
-rw-r--r--weed/admin/maintenance/maintenance_integration.go409
-rw-r--r--weed/admin/maintenance/maintenance_manager.go407
-rw-r--r--weed/admin/maintenance/maintenance_manager_test.go140
-rw-r--r--weed/admin/maintenance/maintenance_queue.go500
-rw-r--r--weed/admin/maintenance/maintenance_scanner.go163
-rw-r--r--weed/admin/maintenance/maintenance_types.go560
-rw-r--r--weed/admin/maintenance/maintenance_worker.go413
-rw-r--r--weed/admin/static/js/admin.js94
-rw-r--r--weed/admin/view/app/cluster_collections.templ67
-rw-r--r--weed/admin/view/app/cluster_collections_templ.go28
-rw-r--r--weed/admin/view/app/maintenance_config.templ244
-rw-r--r--weed/admin/view/app/maintenance_config_templ.go280
-rw-r--r--weed/admin/view/app/maintenance_queue.templ289
-rw-r--r--weed/admin/view/app/maintenance_queue_templ.go585
-rw-r--r--weed/admin/view/app/maintenance_workers.templ340
-rw-r--r--weed/admin/view/app/maintenance_workers_templ.go431
-rw-r--r--weed/admin/view/app/task_config.templ160
-rw-r--r--weed/admin/view/app/task_config_templ.go174
-rw-r--r--weed/admin/view/app/task_config_templ.templ160
-rw-r--r--weed/admin/view/app/task_config_templ_templ.go112
-rw-r--r--weed/admin/view/components/config_sections.templ83
-rw-r--r--weed/admin/view/components/config_sections_templ.go257
-rw-r--r--weed/admin/view/components/form_fields.templ306
-rw-r--r--weed/admin/view/components/form_fields_templ.go1104
-rw-r--r--weed/admin/view/layout/layout.templ75
-rw-r--r--weed/admin/view/layout/layout_templ.go301
-rw-r--r--weed/admin/view/layout/menu_helper.go47
-rw-r--r--weed/command/admin.go190
-rw-r--r--weed/command/command.go1
-rw-r--r--weed/command/scaffold/security.toml20
-rw-r--r--weed/command/worker.go182
-rw-r--r--weed/pb/Makefile1
-rw-r--r--weed/pb/grpc_client_server.go8
-rw-r--r--weed/pb/worker.proto142
-rw-r--r--weed/pb/worker_pb/worker.pb.go1724
-rw-r--r--weed/pb/worker_pb/worker_grpc.pb.go121
-rw-r--r--weed/worker/client.go761
-rw-r--r--weed/worker/client_test.go111
-rw-r--r--weed/worker/client_tls_test.go146
-rw-r--r--weed/worker/registry.go348
-rw-r--r--weed/worker/tasks/balance/balance.go82
-rw-r--r--weed/worker/tasks/balance/balance_detector.go171
-rw-r--r--weed/worker/tasks/balance/balance_register.go81
-rw-r--r--weed/worker/tasks/balance/balance_scheduler.go197
-rw-r--r--weed/worker/tasks/balance/ui.go361
-rw-r--r--weed/worker/tasks/balance/ui_templ.go369
-rw-r--r--weed/worker/tasks/erasure_coding/ec.go79
-rw-r--r--weed/worker/tasks/erasure_coding/ec_detector.go139
-rw-r--r--weed/worker/tasks/erasure_coding/ec_register.go81
-rw-r--r--weed/worker/tasks/erasure_coding/ec_scheduler.go114
-rw-r--r--weed/worker/tasks/erasure_coding/ui.go309
-rw-r--r--weed/worker/tasks/erasure_coding/ui_templ.go319
-rw-r--r--weed/worker/tasks/registry.go110
-rw-r--r--weed/worker/tasks/task.go252
-rw-r--r--weed/worker/tasks/vacuum/ui.go314
-rw-r--r--weed/worker/tasks/vacuum/ui_templ.go330
-rw-r--r--weed/worker/tasks/vacuum/vacuum.go79
-rw-r--r--weed/worker/tasks/vacuum/vacuum_detector.go132
-rw-r--r--weed/worker/tasks/vacuum/vacuum_register.go81
-rw-r--r--weed/worker/tasks/vacuum/vacuum_scheduler.go111
-rw-r--r--weed/worker/types/config_types.go268
-rw-r--r--weed/worker/types/data_types.go40
-rw-r--r--weed/worker/types/task_detector.go28
-rw-r--r--weed/worker/types/task_registry.go54
-rw-r--r--weed/worker/types/task_scheduler.go32
-rw-r--r--weed/worker/types/task_types.go89
-rw-r--r--weed/worker/types/task_ui.go281
-rw-r--r--weed/worker/types/task_ui_templ.go63
-rw-r--r--weed/worker/types/worker_types.go111
-rw-r--r--weed/worker/worker.go410
76 files changed, 18218 insertions, 206 deletions
diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go
index 03a44a6da..c8da2bbb7 100644
--- a/weed/admin/dash/admin_server.go
+++ b/weed/admin/dash/admin_server.go
@@ -7,6 +7,8 @@ import (
"net/http"
"time"
+ "github.com/gin-gonic/gin"
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
@@ -22,6 +24,7 @@ import (
type AdminServer struct {
masterAddress string
templateFS http.FileSystem
+ dataDir string
grpcDialOption grpc.DialOption
cacheExpiration time.Duration
lastCacheUpdate time.Time
@@ -34,17 +37,28 @@ type AdminServer struct {
// Credential management
credentialManager *credential.CredentialManager
+
+ // Configuration persistence
+ configPersistence *ConfigPersistence
+
+ // Maintenance system
+ maintenanceManager *maintenance.MaintenanceManager
+
+ // Worker gRPC server
+ workerGrpcServer *WorkerGrpcServer
}
// Type definitions moved to types.go
-func NewAdminServer(masterAddress string, templateFS http.FileSystem) *AdminServer {
+func NewAdminServer(masterAddress string, templateFS http.FileSystem, dataDir string) *AdminServer {
server := &AdminServer{
masterAddress: masterAddress,
templateFS: templateFS,
+ dataDir: dataDir,
grpcDialOption: security.LoadClientTLS(util.GetViper(), "grpc.client"),
cacheExpiration: 10 * time.Second,
filerCacheExpiration: 30 * time.Second, // Cache filers for 30 seconds
+ configPersistence: NewConfigPersistence(dataDir),
}
// Initialize credential manager with defaults
@@ -82,6 +96,27 @@ func NewAdminServer(masterAddress string, templateFS http.FileSystem) *AdminServ
}
}
+ // Initialize maintenance system with persistent configuration
+ if server.configPersistence.IsConfigured() {
+ maintenanceConfig, err := server.configPersistence.LoadMaintenanceConfig()
+ if err != nil {
+ glog.Errorf("Failed to load maintenance configuration: %v", err)
+ maintenanceConfig = maintenance.DefaultMaintenanceConfig()
+ }
+ server.InitMaintenanceManager(maintenanceConfig)
+
+ // Start maintenance manager if enabled
+ if maintenanceConfig.Enabled {
+ go func() {
+ if err := server.StartMaintenanceManager(); err != nil {
+ glog.Errorf("Failed to start maintenance manager: %v", err)
+ }
+ }()
+ }
+ } else {
+ glog.V(1).Infof("No data directory configured, maintenance system will run in memory-only mode")
+ }
+
return server
}
@@ -568,3 +603,598 @@ func (s *AdminServer) GetClusterFilers() (*ClusterFilersData, error) {
// GetVolumeDetails method moved to volume_management.go
// VacuumVolume method moved to volume_management.go
+
+// ShowMaintenanceQueue displays the maintenance queue page
+func (as *AdminServer) ShowMaintenanceQueue(c *gin.Context) {
+ data, err := as.getMaintenanceQueueData()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // This should not render HTML template, it should use the component approach
+ c.JSON(http.StatusOK, data)
+}
+
+// ShowMaintenanceWorkers displays the maintenance workers page
+func (as *AdminServer) ShowMaintenanceWorkers(c *gin.Context) {
+ workers, err := as.getMaintenanceWorkers()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Create worker details data
+ workersData := make([]*WorkerDetailsData, 0, len(workers))
+ for _, worker := range workers {
+ details, err := as.getMaintenanceWorkerDetails(worker.ID)
+ if err != nil {
+ // Create basic worker details if we can't get full details
+ details = &WorkerDetailsData{
+ Worker: worker,
+ CurrentTasks: []*MaintenanceTask{},
+ RecentTasks: []*MaintenanceTask{},
+ Performance: &WorkerPerformance{
+ TasksCompleted: 0,
+ TasksFailed: 0,
+ AverageTaskTime: 0,
+ Uptime: 0,
+ SuccessRate: 0,
+ },
+ LastUpdated: time.Now(),
+ }
+ }
+ workersData = append(workersData, details)
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "workers": workersData,
+ "title": "Maintenance Workers",
+ })
+}
+
+// ShowMaintenanceConfig displays the maintenance configuration page
+func (as *AdminServer) ShowMaintenanceConfig(c *gin.Context) {
+ config, err := as.getMaintenanceConfig()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // This should not render HTML template, it should use the component approach
+ c.JSON(http.StatusOK, config)
+}
+
+// UpdateMaintenanceConfig updates maintenance configuration from form
+func (as *AdminServer) UpdateMaintenanceConfig(c *gin.Context) {
+ var config MaintenanceConfig
+ if err := c.ShouldBind(&config); err != nil {
+ c.HTML(http.StatusBadRequest, "error.html", gin.H{"error": err.Error()})
+ return
+ }
+
+ err := as.updateMaintenanceConfig(&config)
+ if err != nil {
+ c.HTML(http.StatusInternalServerError, "error.html", gin.H{"error": err.Error()})
+ return
+ }
+
+ c.Redirect(http.StatusSeeOther, "/maintenance/config")
+}
+
+// TriggerMaintenanceScan triggers a maintenance scan
+func (as *AdminServer) TriggerMaintenanceScan(c *gin.Context) {
+ err := as.triggerMaintenanceScan()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "Maintenance scan triggered"})
+}
+
+// GetMaintenanceTasks returns all maintenance tasks
+func (as *AdminServer) GetMaintenanceTasks(c *gin.Context) {
+ tasks, err := as.getMaintenanceTasks()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, tasks)
+}
+
+// GetMaintenanceTask returns a specific maintenance task
+func (as *AdminServer) GetMaintenanceTask(c *gin.Context) {
+ taskID := c.Param("id")
+ task, err := as.getMaintenanceTask(taskID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, task)
+}
+
+// CancelMaintenanceTask cancels a pending maintenance task
+func (as *AdminServer) CancelMaintenanceTask(c *gin.Context) {
+ taskID := c.Param("id")
+ err := as.cancelMaintenanceTask(taskID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "Task cancelled"})
+}
+
+// GetMaintenanceWorkersAPI returns all maintenance workers
+func (as *AdminServer) GetMaintenanceWorkersAPI(c *gin.Context) {
+ workers, err := as.getMaintenanceWorkers()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, workers)
+}
+
+// GetMaintenanceWorker returns a specific maintenance worker
+func (as *AdminServer) GetMaintenanceWorker(c *gin.Context) {
+ workerID := c.Param("id")
+ worker, err := as.getMaintenanceWorkerDetails(workerID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Worker not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, worker)
+}
+
+// GetMaintenanceStats returns maintenance statistics
+func (as *AdminServer) GetMaintenanceStats(c *gin.Context) {
+ stats, err := as.getMaintenanceStats()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, stats)
+}
+
+// GetMaintenanceConfigAPI returns maintenance configuration
+func (as *AdminServer) GetMaintenanceConfigAPI(c *gin.Context) {
+ config, err := as.getMaintenanceConfig()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, config)
+}
+
+// UpdateMaintenanceConfigAPI updates maintenance configuration via API
+func (as *AdminServer) UpdateMaintenanceConfigAPI(c *gin.Context) {
+ var config MaintenanceConfig
+ if err := c.ShouldBindJSON(&config); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ err := as.updateMaintenanceConfig(&config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "Configuration updated"})
+}
+
+// GetMaintenanceConfigData returns maintenance configuration data (public wrapper)
+func (as *AdminServer) GetMaintenanceConfigData() (*maintenance.MaintenanceConfigData, error) {
+ return as.getMaintenanceConfig()
+}
+
+// UpdateMaintenanceConfigData updates maintenance configuration (public wrapper)
+func (as *AdminServer) UpdateMaintenanceConfigData(config *maintenance.MaintenanceConfig) error {
+ return as.updateMaintenanceConfig(config)
+}
+
+// Helper methods for maintenance operations
+
+// getMaintenanceQueueData returns data for the maintenance queue UI
+func (as *AdminServer) getMaintenanceQueueData() (*maintenance.MaintenanceQueueData, error) {
+ tasks, err := as.getMaintenanceTasks()
+ if err != nil {
+ return nil, err
+ }
+
+ workers, err := as.getMaintenanceWorkers()
+ if err != nil {
+ return nil, err
+ }
+
+ stats, err := as.getMaintenanceQueueStats()
+ if err != nil {
+ return nil, err
+ }
+
+ return &maintenance.MaintenanceQueueData{
+ Tasks: tasks,
+ Workers: workers,
+ Stats: stats,
+ LastUpdated: time.Now(),
+ }, nil
+}
+
+// getMaintenanceQueueStats returns statistics for the maintenance queue
+func (as *AdminServer) getMaintenanceQueueStats() (*maintenance.QueueStats, error) {
+ // This would integrate with the maintenance queue to get real statistics
+ // For now, return mock data
+ return &maintenance.QueueStats{
+ PendingTasks: 5,
+ RunningTasks: 2,
+ CompletedToday: 15,
+ FailedToday: 1,
+ TotalTasks: 23,
+ }, nil
+}
+
+// getMaintenanceTasks returns all maintenance tasks
+func (as *AdminServer) getMaintenanceTasks() ([]*maintenance.MaintenanceTask, error) {
+ if as.maintenanceManager == nil {
+ return []*MaintenanceTask{}, nil
+ }
+ return as.maintenanceManager.GetTasks(maintenance.TaskStatusPending, "", 0), nil
+}
+
+// getMaintenanceTask returns a specific maintenance task
+func (as *AdminServer) getMaintenanceTask(taskID string) (*MaintenanceTask, error) {
+ if as.maintenanceManager == nil {
+ return nil, fmt.Errorf("maintenance manager not initialized")
+ }
+
+ // Search for the task across all statuses since we don't know which status it has
+ statuses := []MaintenanceTaskStatus{
+ TaskStatusPending,
+ TaskStatusAssigned,
+ TaskStatusInProgress,
+ TaskStatusCompleted,
+ TaskStatusFailed,
+ TaskStatusCancelled,
+ }
+
+ for _, status := range statuses {
+ tasks := as.maintenanceManager.GetTasks(status, "", 0) // Get all tasks with this status
+ for _, task := range tasks {
+ if task.ID == taskID {
+ return task, nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("task %s not found", taskID)
+}
+
+// cancelMaintenanceTask cancels a pending maintenance task
+func (as *AdminServer) cancelMaintenanceTask(taskID string) error {
+ if as.maintenanceManager == nil {
+ return fmt.Errorf("maintenance manager not initialized")
+ }
+
+ return as.maintenanceManager.CancelTask(taskID)
+}
+
+// getMaintenanceWorkers returns all maintenance workers
+func (as *AdminServer) getMaintenanceWorkers() ([]*maintenance.MaintenanceWorker, error) {
+ if as.maintenanceManager == nil {
+ return []*MaintenanceWorker{}, nil
+ }
+ return as.maintenanceManager.GetWorkers(), nil
+}
+
+// getMaintenanceWorkerDetails returns detailed information about a worker
+func (as *AdminServer) getMaintenanceWorkerDetails(workerID string) (*WorkerDetailsData, error) {
+ if as.maintenanceManager == nil {
+ return nil, fmt.Errorf("maintenance manager not initialized")
+ }
+
+ workers := as.maintenanceManager.GetWorkers()
+ var targetWorker *MaintenanceWorker
+ for _, worker := range workers {
+ if worker.ID == workerID {
+ targetWorker = worker
+ break
+ }
+ }
+
+ if targetWorker == nil {
+ return nil, fmt.Errorf("worker %s not found", workerID)
+ }
+
+ // Get current tasks for this worker
+ currentTasks := as.maintenanceManager.GetTasks(TaskStatusInProgress, "", 0)
+ var workerCurrentTasks []*MaintenanceTask
+ for _, task := range currentTasks {
+ if task.WorkerID == workerID {
+ workerCurrentTasks = append(workerCurrentTasks, task)
+ }
+ }
+
+ // Get recent tasks for this worker
+ recentTasks := as.maintenanceManager.GetTasks(TaskStatusCompleted, "", 10)
+ var workerRecentTasks []*MaintenanceTask
+ for _, task := range recentTasks {
+ if task.WorkerID == workerID {
+ workerRecentTasks = append(workerRecentTasks, task)
+ }
+ }
+
+ // Calculate performance metrics
+ var totalDuration time.Duration
+ var completedTasks, failedTasks int
+ for _, task := range workerRecentTasks {
+ if task.Status == TaskStatusCompleted {
+ completedTasks++
+ if task.StartedAt != nil && task.CompletedAt != nil {
+ totalDuration += task.CompletedAt.Sub(*task.StartedAt)
+ }
+ } else if task.Status == TaskStatusFailed {
+ failedTasks++
+ }
+ }
+
+ var averageTaskTime time.Duration
+ var successRate float64
+ if completedTasks+failedTasks > 0 {
+ if completedTasks > 0 {
+ averageTaskTime = totalDuration / time.Duration(completedTasks)
+ }
+ successRate = float64(completedTasks) / float64(completedTasks+failedTasks) * 100
+ }
+
+ return &WorkerDetailsData{
+ Worker: targetWorker,
+ CurrentTasks: workerCurrentTasks,
+ RecentTasks: workerRecentTasks,
+ Performance: &WorkerPerformance{
+ TasksCompleted: completedTasks,
+ TasksFailed: failedTasks,
+ AverageTaskTime: averageTaskTime,
+ Uptime: time.Since(targetWorker.LastHeartbeat), // This should be tracked properly
+ SuccessRate: successRate,
+ },
+ LastUpdated: time.Now(),
+ }, nil
+}
+
+// getMaintenanceStats returns maintenance statistics
+func (as *AdminServer) getMaintenanceStats() (*MaintenanceStats, error) {
+ if as.maintenanceManager == nil {
+ return &MaintenanceStats{
+ TotalTasks: 0,
+ TasksByStatus: make(map[MaintenanceTaskStatus]int),
+ TasksByType: make(map[MaintenanceTaskType]int),
+ ActiveWorkers: 0,
+ }, nil
+ }
+ return as.maintenanceManager.GetStats(), nil
+}
+
+// getMaintenanceConfig returns maintenance configuration
+func (as *AdminServer) getMaintenanceConfig() (*maintenance.MaintenanceConfigData, error) {
+ // Load configuration from persistent storage
+ config, err := as.configPersistence.LoadMaintenanceConfig()
+ if err != nil {
+ glog.Errorf("Failed to load maintenance configuration: %v", err)
+ // Fallback to default configuration
+ config = DefaultMaintenanceConfig()
+ }
+
+ // Get system stats from maintenance manager if available
+ var systemStats *MaintenanceStats
+ if as.maintenanceManager != nil {
+ systemStats = as.maintenanceManager.GetStats()
+ } else {
+ // Fallback stats
+ systemStats = &MaintenanceStats{
+ TotalTasks: 0,
+ TasksByStatus: map[MaintenanceTaskStatus]int{
+ TaskStatusPending: 0,
+ TaskStatusInProgress: 0,
+ TaskStatusCompleted: 0,
+ TaskStatusFailed: 0,
+ },
+ TasksByType: make(map[MaintenanceTaskType]int),
+ ActiveWorkers: 0,
+ CompletedToday: 0,
+ FailedToday: 0,
+ AverageTaskTime: 0,
+ LastScanTime: time.Now().Add(-time.Hour),
+ NextScanTime: time.Now().Add(time.Duration(config.ScanIntervalSeconds) * time.Second),
+ }
+ }
+
+ return &MaintenanceConfigData{
+ Config: config,
+ IsEnabled: config.Enabled,
+ LastScanTime: systemStats.LastScanTime,
+ NextScanTime: systemStats.NextScanTime,
+ SystemStats: systemStats,
+ MenuItems: maintenance.BuildMaintenanceMenuItems(),
+ }, nil
+}
+
+// updateMaintenanceConfig updates maintenance configuration
+func (as *AdminServer) updateMaintenanceConfig(config *maintenance.MaintenanceConfig) error {
+ // Save configuration to persistent storage
+ if err := as.configPersistence.SaveMaintenanceConfig(config); err != nil {
+ return fmt.Errorf("failed to save maintenance configuration: %v", err)
+ }
+
+ // Update maintenance manager if available
+ if as.maintenanceManager != nil {
+ if err := as.maintenanceManager.UpdateConfig(config); err != nil {
+ glog.Errorf("Failed to update maintenance manager config: %v", err)
+ // Don't return error here, just log it
+ }
+ }
+
+ glog.V(1).Infof("Updated maintenance configuration (enabled: %v, scan interval: %ds)",
+ config.Enabled, config.ScanIntervalSeconds)
+ return nil
+}
+
+// triggerMaintenanceScan triggers a maintenance scan
+func (as *AdminServer) triggerMaintenanceScan() error {
+ if as.maintenanceManager == nil {
+ return fmt.Errorf("maintenance manager not initialized")
+ }
+
+ return as.maintenanceManager.TriggerScan()
+}
+
+// GetConfigInfo returns information about the admin configuration
+func (as *AdminServer) GetConfigInfo(c *gin.Context) {
+ configInfo := as.configPersistence.GetConfigInfo()
+
+ // Add additional admin server info
+ configInfo["master_address"] = as.masterAddress
+ configInfo["cache_expiration"] = as.cacheExpiration.String()
+ configInfo["filer_cache_expiration"] = as.filerCacheExpiration.String()
+
+ // Add maintenance system info
+ if as.maintenanceManager != nil {
+ configInfo["maintenance_enabled"] = true
+ configInfo["maintenance_running"] = as.maintenanceManager.IsRunning()
+ } else {
+ configInfo["maintenance_enabled"] = false
+ configInfo["maintenance_running"] = false
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "config_info": configInfo,
+ "title": "Configuration Information",
+ })
+}
+
+// GetMaintenanceWorkersData returns workers data for the maintenance workers page
+func (as *AdminServer) GetMaintenanceWorkersData() (*MaintenanceWorkersData, error) {
+ workers, err := as.getMaintenanceWorkers()
+ if err != nil {
+ return nil, err
+ }
+
+ // Create worker details data
+ workersData := make([]*WorkerDetailsData, 0, len(workers))
+ activeWorkers := 0
+ busyWorkers := 0
+ totalLoad := 0
+
+ for _, worker := range workers {
+ details, err := as.getMaintenanceWorkerDetails(worker.ID)
+ if err != nil {
+ // Create basic worker details if we can't get full details
+ details = &WorkerDetailsData{
+ Worker: worker,
+ CurrentTasks: []*MaintenanceTask{},
+ RecentTasks: []*MaintenanceTask{},
+ Performance: &WorkerPerformance{
+ TasksCompleted: 0,
+ TasksFailed: 0,
+ AverageTaskTime: 0,
+ Uptime: 0,
+ SuccessRate: 0,
+ },
+ LastUpdated: time.Now(),
+ }
+ }
+ workersData = append(workersData, details)
+
+ if worker.Status == "active" {
+ activeWorkers++
+ } else if worker.Status == "busy" {
+ busyWorkers++
+ }
+ totalLoad += worker.CurrentLoad
+ }
+
+ return &MaintenanceWorkersData{
+ Workers: workersData,
+ ActiveWorkers: activeWorkers,
+ BusyWorkers: busyWorkers,
+ TotalLoad: totalLoad,
+ LastUpdated: time.Now(),
+ }, nil
+}
+
+// StartWorkerGrpcServer starts the worker gRPC server
+func (s *AdminServer) StartWorkerGrpcServer(httpPort int) error {
+ if s.workerGrpcServer != nil {
+ return fmt.Errorf("worker gRPC server is already running")
+ }
+
+ // Calculate gRPC port (HTTP port + 10000)
+ grpcPort := httpPort + 10000
+
+ s.workerGrpcServer = NewWorkerGrpcServer(s)
+ return s.workerGrpcServer.StartWithTLS(grpcPort)
+}
+
+// StopWorkerGrpcServer stops the worker gRPC server
+func (s *AdminServer) StopWorkerGrpcServer() error {
+ if s.workerGrpcServer != nil {
+ err := s.workerGrpcServer.Stop()
+ s.workerGrpcServer = nil
+ return err
+ }
+ return nil
+}
+
+// GetWorkerGrpcServer returns the worker gRPC server
+func (s *AdminServer) GetWorkerGrpcServer() *WorkerGrpcServer {
+ return s.workerGrpcServer
+}
+
+// Maintenance system integration methods
+
+// InitMaintenanceManager initializes the maintenance manager
+func (s *AdminServer) InitMaintenanceManager(config *maintenance.MaintenanceConfig) {
+ s.maintenanceManager = maintenance.NewMaintenanceManager(s, config)
+ glog.V(1).Infof("Maintenance manager initialized (enabled: %v)", config.Enabled)
+}
+
+// GetMaintenanceManager returns the maintenance manager
+func (s *AdminServer) GetMaintenanceManager() *maintenance.MaintenanceManager {
+ return s.maintenanceManager
+}
+
+// StartMaintenanceManager starts the maintenance manager
+func (s *AdminServer) StartMaintenanceManager() error {
+ if s.maintenanceManager == nil {
+ return fmt.Errorf("maintenance manager not initialized")
+ }
+ return s.maintenanceManager.Start()
+}
+
+// StopMaintenanceManager stops the maintenance manager
+func (s *AdminServer) StopMaintenanceManager() {
+ if s.maintenanceManager != nil {
+ s.maintenanceManager.Stop()
+ }
+}
+
+// Shutdown gracefully shuts down the admin server
+func (s *AdminServer) Shutdown() {
+ glog.V(1).Infof("Shutting down admin server...")
+
+ // Stop maintenance manager
+ s.StopMaintenanceManager()
+
+ // Stop worker gRPC server
+ if err := s.StopWorkerGrpcServer(); err != nil {
+ glog.Errorf("Failed to stop worker gRPC server: %v", err)
+ }
+
+ glog.V(1).Infof("Admin server shutdown complete")
+}
diff --git a/weed/admin/dash/config_persistence.go b/weed/admin/dash/config_persistence.go
new file mode 100644
index 000000000..93d9f6a09
--- /dev/null
+++ b/weed/admin/dash/config_persistence.go
@@ -0,0 +1,270 @@
+package dash
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+const (
+ // Configuration file names
+ MaintenanceConfigFile = "maintenance.json"
+ AdminConfigFile = "admin.json"
+ ConfigDirPermissions = 0755
+ ConfigFilePermissions = 0644
+)
+
+// ConfigPersistence handles saving and loading configuration files
+type ConfigPersistence struct {
+ dataDir string
+}
+
+// NewConfigPersistence creates a new configuration persistence manager
+func NewConfigPersistence(dataDir string) *ConfigPersistence {
+ return &ConfigPersistence{
+ dataDir: dataDir,
+ }
+}
+
+// SaveMaintenanceConfig saves maintenance configuration to JSON file
+func (cp *ConfigPersistence) SaveMaintenanceConfig(config *MaintenanceConfig) error {
+ if cp.dataDir == "" {
+ return fmt.Errorf("no data directory specified, cannot save configuration")
+ }
+
+ configPath := filepath.Join(cp.dataDir, MaintenanceConfigFile)
+
+ // Create directory if it doesn't exist
+ if err := os.MkdirAll(cp.dataDir, ConfigDirPermissions); err != nil {
+ return fmt.Errorf("failed to create config directory: %v", err)
+ }
+
+ // Marshal configuration to JSON
+ configData, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal maintenance config: %v", err)
+ }
+
+ // Write to file
+ if err := os.WriteFile(configPath, configData, ConfigFilePermissions); err != nil {
+ return fmt.Errorf("failed to write maintenance config file: %v", err)
+ }
+
+ glog.V(1).Infof("Saved maintenance configuration to %s", configPath)
+ return nil
+}
+
+// LoadMaintenanceConfig loads maintenance configuration from JSON file
+func (cp *ConfigPersistence) LoadMaintenanceConfig() (*MaintenanceConfig, error) {
+ if cp.dataDir == "" {
+ glog.V(1).Infof("No data directory specified, using default maintenance configuration")
+ return DefaultMaintenanceConfig(), nil
+ }
+
+ configPath := filepath.Join(cp.dataDir, MaintenanceConfigFile)
+
+ // Check if file exists
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ glog.V(1).Infof("Maintenance config file does not exist, using defaults: %s", configPath)
+ return DefaultMaintenanceConfig(), nil
+ }
+
+ // Read file
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read maintenance config file: %v", err)
+ }
+
+ // Unmarshal JSON
+ var config MaintenanceConfig
+ if err := json.Unmarshal(configData, &config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal maintenance config: %v", err)
+ }
+
+ glog.V(1).Infof("Loaded maintenance configuration from %s", configPath)
+ return &config, nil
+}
+
+// SaveAdminConfig saves general admin configuration to JSON file
+func (cp *ConfigPersistence) SaveAdminConfig(config map[string]interface{}) error {
+ if cp.dataDir == "" {
+ return fmt.Errorf("no data directory specified, cannot save configuration")
+ }
+
+ configPath := filepath.Join(cp.dataDir, AdminConfigFile)
+
+ // Create directory if it doesn't exist
+ if err := os.MkdirAll(cp.dataDir, ConfigDirPermissions); err != nil {
+ return fmt.Errorf("failed to create config directory: %v", err)
+ }
+
+ // Marshal configuration to JSON
+ configData, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal admin config: %v", err)
+ }
+
+ // Write to file
+ if err := os.WriteFile(configPath, configData, ConfigFilePermissions); err != nil {
+ return fmt.Errorf("failed to write admin config file: %v", err)
+ }
+
+ glog.V(1).Infof("Saved admin configuration to %s", configPath)
+ return nil
+}
+
+// LoadAdminConfig loads general admin configuration from JSON file
+func (cp *ConfigPersistence) LoadAdminConfig() (map[string]interface{}, error) {
+ if cp.dataDir == "" {
+ glog.V(1).Infof("No data directory specified, using default admin configuration")
+ return make(map[string]interface{}), nil
+ }
+
+ configPath := filepath.Join(cp.dataDir, AdminConfigFile)
+
+ // Check if file exists
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ glog.V(1).Infof("Admin config file does not exist, using defaults: %s", configPath)
+ return make(map[string]interface{}), nil
+ }
+
+ // Read file
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read admin config file: %v", err)
+ }
+
+ // Unmarshal JSON
+ var config map[string]interface{}
+ if err := json.Unmarshal(configData, &config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal admin config: %v", err)
+ }
+
+ glog.V(1).Infof("Loaded admin configuration from %s", configPath)
+ return config, nil
+}
+
+// GetConfigPath returns the path to a configuration file
+func (cp *ConfigPersistence) GetConfigPath(filename string) string {
+ if cp.dataDir == "" {
+ return ""
+ }
+ return filepath.Join(cp.dataDir, filename)
+}
+
+// ListConfigFiles returns all configuration files in the data directory
+func (cp *ConfigPersistence) ListConfigFiles() ([]string, error) {
+ if cp.dataDir == "" {
+ return nil, fmt.Errorf("no data directory specified")
+ }
+
+ files, err := os.ReadDir(cp.dataDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read config directory: %v", err)
+ }
+
+ var configFiles []string
+ for _, file := range files {
+ if !file.IsDir() && filepath.Ext(file.Name()) == ".json" {
+ configFiles = append(configFiles, file.Name())
+ }
+ }
+
+ return configFiles, nil
+}
+
+// BackupConfig creates a backup of a configuration file
+func (cp *ConfigPersistence) BackupConfig(filename string) error {
+ if cp.dataDir == "" {
+ return fmt.Errorf("no data directory specified")
+ }
+
+ configPath := filepath.Join(cp.dataDir, filename)
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ return fmt.Errorf("config file does not exist: %s", filename)
+ }
+
+ // Create backup filename with timestamp
+ timestamp := time.Now().Format("2006-01-02_15-04-05")
+ backupName := fmt.Sprintf("%s.backup_%s", filename, timestamp)
+ backupPath := filepath.Join(cp.dataDir, backupName)
+
+ // Copy file
+ configData, err := os.ReadFile(configPath)
+ if err != nil {
+ return fmt.Errorf("failed to read config file: %v", err)
+ }
+
+ if err := os.WriteFile(backupPath, configData, ConfigFilePermissions); err != nil {
+ return fmt.Errorf("failed to create backup: %v", err)
+ }
+
+ glog.V(1).Infof("Created backup of %s as %s", filename, backupName)
+ return nil
+}
+
+// RestoreConfig restores a configuration file from a backup
+func (cp *ConfigPersistence) RestoreConfig(filename, backupName string) error {
+ if cp.dataDir == "" {
+ return fmt.Errorf("no data directory specified")
+ }
+
+ backupPath := filepath.Join(cp.dataDir, backupName)
+ if _, err := os.Stat(backupPath); os.IsNotExist(err) {
+ return fmt.Errorf("backup file does not exist: %s", backupName)
+ }
+
+ // Read backup file
+ backupData, err := os.ReadFile(backupPath)
+ if err != nil {
+ return fmt.Errorf("failed to read backup file: %v", err)
+ }
+
+ // Write to config file
+ configPath := filepath.Join(cp.dataDir, filename)
+ if err := os.WriteFile(configPath, backupData, ConfigFilePermissions); err != nil {
+ return fmt.Errorf("failed to restore config: %v", err)
+ }
+
+ glog.V(1).Infof("Restored %s from backup %s", filename, backupName)
+ return nil
+}
+
+// GetDataDir returns the data directory path
+func (cp *ConfigPersistence) GetDataDir() string {
+ return cp.dataDir
+}
+
+// IsConfigured returns true if a data directory is configured
+func (cp *ConfigPersistence) IsConfigured() bool {
+ return cp.dataDir != ""
+}
+
+// GetConfigInfo returns information about the configuration storage
+func (cp *ConfigPersistence) GetConfigInfo() map[string]interface{} {
+ info := map[string]interface{}{
+ "data_dir_configured": cp.IsConfigured(),
+ "data_dir": cp.dataDir,
+ }
+
+ if cp.IsConfigured() {
+ // Check if data directory exists
+ if _, err := os.Stat(cp.dataDir); err == nil {
+ info["data_dir_exists"] = true
+
+ // List config files
+ configFiles, err := cp.ListConfigFiles()
+ if err == nil {
+ info["config_files"] = configFiles
+ }
+ } else {
+ info["data_dir_exists"] = false
+ }
+ }
+
+ return info
+}
diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go
index 8c0be1aeb..07157d9dc 100644
--- a/weed/admin/dash/types.go
+++ b/weed/admin/dash/types.go
@@ -3,6 +3,7 @@ package dash
import (
"time"
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
@@ -197,3 +198,51 @@ type ClusterVolumeServersData struct {
TotalCapacity int64 `json:"total_capacity"`
LastUpdated time.Time `json:"last_updated"`
}
+
+// Type aliases for maintenance package types to support existing code
+type MaintenanceTask = maintenance.MaintenanceTask
+type MaintenanceTaskType = maintenance.MaintenanceTaskType
+type MaintenanceTaskStatus = maintenance.MaintenanceTaskStatus
+type MaintenanceTaskPriority = maintenance.MaintenanceTaskPriority
+type MaintenanceWorker = maintenance.MaintenanceWorker
+type MaintenanceConfig = maintenance.MaintenanceConfig
+type MaintenanceStats = maintenance.MaintenanceStats
+type MaintenanceConfigData = maintenance.MaintenanceConfigData
+type MaintenanceQueueData = maintenance.MaintenanceQueueData
+type QueueStats = maintenance.QueueStats
+type WorkerDetailsData = maintenance.WorkerDetailsData
+type WorkerPerformance = maintenance.WorkerPerformance
+
+// GetTaskIcon returns the icon CSS class for a task type from its UI provider
+func GetTaskIcon(taskType MaintenanceTaskType) string {
+ return maintenance.GetTaskIcon(taskType)
+}
+
+// Status constants (these are still static)
+const (
+ TaskStatusPending = maintenance.TaskStatusPending
+ TaskStatusAssigned = maintenance.TaskStatusAssigned
+ TaskStatusInProgress = maintenance.TaskStatusInProgress
+ TaskStatusCompleted = maintenance.TaskStatusCompleted
+ TaskStatusFailed = maintenance.TaskStatusFailed
+ TaskStatusCancelled = maintenance.TaskStatusCancelled
+
+ PriorityLow = maintenance.PriorityLow
+ PriorityNormal = maintenance.PriorityNormal
+ PriorityHigh = maintenance.PriorityHigh
+ PriorityCritical = maintenance.PriorityCritical
+)
+
+// Helper functions from maintenance package
+var DefaultMaintenanceConfig = maintenance.DefaultMaintenanceConfig
+
+// MaintenanceWorkersData represents the data for the maintenance workers page
+type MaintenanceWorkersData struct {
+ Workers []*WorkerDetailsData `json:"workers"`
+ ActiveWorkers int `json:"active_workers"`
+ BusyWorkers int `json:"busy_workers"`
+ TotalLoad int `json:"total_load"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// Maintenance system types are now in weed/admin/maintenance package
diff --git a/weed/admin/dash/worker_grpc_server.go b/weed/admin/dash/worker_grpc_server.go
new file mode 100644
index 000000000..c824cc388
--- /dev/null
+++ b/weed/admin/dash/worker_grpc_server.go
@@ -0,0 +1,461 @@
+package dash
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb"
+ "github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
+ "github.com/seaweedfs/seaweedfs/weed/security"
+ "github.com/seaweedfs/seaweedfs/weed/util"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/peer"
+)
+
+// WorkerGrpcServer implements the WorkerService gRPC interface
+type WorkerGrpcServer struct {
+ worker_pb.UnimplementedWorkerServiceServer
+ adminServer *AdminServer
+
+ // Worker connection management
+ connections map[string]*WorkerConnection
+ connMutex sync.RWMutex
+
+ // gRPC server
+ grpcServer *grpc.Server
+ listener net.Listener
+ running bool
+ stopChan chan struct{}
+}
+
+// WorkerConnection represents an active worker connection
+type WorkerConnection struct {
+ workerID string
+ stream worker_pb.WorkerService_WorkerStreamServer
+ lastSeen time.Time
+ capabilities []MaintenanceTaskType
+ address string
+ maxConcurrent int32
+ outgoing chan *worker_pb.AdminMessage
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewWorkerGrpcServer creates a new gRPC server for worker connections
+func NewWorkerGrpcServer(adminServer *AdminServer) *WorkerGrpcServer {
+ return &WorkerGrpcServer{
+ adminServer: adminServer,
+ connections: make(map[string]*WorkerConnection),
+ stopChan: make(chan struct{}),
+ }
+}
+
+// StartWithTLS starts the gRPC server on the specified port with optional TLS
+func (s *WorkerGrpcServer) StartWithTLS(port int) error {
+ if s.running {
+ return fmt.Errorf("worker gRPC server is already running")
+ }
+
+ // Create listener
+ listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err != nil {
+ return fmt.Errorf("failed to listen on port %d: %v", port, err)
+ }
+
+ // Create gRPC server with optional TLS
+ grpcServer := pb.NewGrpcServer(security.LoadServerTLS(util.GetViper(), "grpc.admin"))
+
+ worker_pb.RegisterWorkerServiceServer(grpcServer, s)
+
+ s.grpcServer = grpcServer
+ s.listener = listener
+ s.running = true
+
+ // Start cleanup routine
+ go s.cleanupRoutine()
+
+ // Start serving in a goroutine
+ go func() {
+ if err := s.grpcServer.Serve(listener); err != nil {
+ if s.running {
+ glog.Errorf("Worker gRPC server error: %v", err)
+ }
+ }
+ }()
+
+ return nil
+}
+
+// Stop stops the gRPC server
+func (s *WorkerGrpcServer) Stop() error {
+ if !s.running {
+ return nil
+ }
+
+ s.running = false
+ close(s.stopChan)
+
+ // Close all worker connections
+ s.connMutex.Lock()
+ for _, conn := range s.connections {
+ conn.cancel()
+ close(conn.outgoing)
+ }
+ s.connections = make(map[string]*WorkerConnection)
+ s.connMutex.Unlock()
+
+ // Stop gRPC server
+ if s.grpcServer != nil {
+ s.grpcServer.GracefulStop()
+ }
+
+ // Close listener
+ if s.listener != nil {
+ s.listener.Close()
+ }
+
+ glog.Infof("Worker gRPC server stopped")
+ return nil
+}
+
+// WorkerStream handles bidirectional communication with workers
+func (s *WorkerGrpcServer) WorkerStream(stream worker_pb.WorkerService_WorkerStreamServer) error {
+ ctx := stream.Context()
+
+ // get client address
+ address := findClientAddress(ctx)
+
+ // Wait for initial registration message
+ msg, err := stream.Recv()
+ if err != nil {
+ return fmt.Errorf("failed to receive registration message: %v", err)
+ }
+
+ registration := msg.GetRegistration()
+ if registration == nil {
+ return fmt.Errorf("first message must be registration")
+ }
+ registration.Address = address
+
+ workerID := registration.WorkerId
+ if workerID == "" {
+ return fmt.Errorf("worker ID cannot be empty")
+ }
+
+ glog.Infof("Worker %s connecting from %s", workerID, registration.Address)
+
+ // Create worker connection
+ connCtx, connCancel := context.WithCancel(ctx)
+ conn := &WorkerConnection{
+ workerID: workerID,
+ stream: stream,
+ lastSeen: time.Now(),
+ address: registration.Address,
+ maxConcurrent: registration.MaxConcurrent,
+ outgoing: make(chan *worker_pb.AdminMessage, 100),
+ ctx: connCtx,
+ cancel: connCancel,
+ }
+
+ // Convert capabilities
+ capabilities := make([]MaintenanceTaskType, len(registration.Capabilities))
+ for i, cap := range registration.Capabilities {
+ capabilities[i] = MaintenanceTaskType(cap)
+ }
+ conn.capabilities = capabilities
+
+ // Register connection
+ s.connMutex.Lock()
+ s.connections[workerID] = conn
+ s.connMutex.Unlock()
+
+ // Register worker with maintenance manager
+ s.registerWorkerWithManager(conn)
+
+ // Send registration response
+ regResponse := &worker_pb.AdminMessage{
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.AdminMessage_RegistrationResponse{
+ RegistrationResponse: &worker_pb.RegistrationResponse{
+ Success: true,
+ Message: "Worker registered successfully",
+ },
+ },
+ }
+
+ select {
+ case conn.outgoing <- regResponse:
+ case <-time.After(5 * time.Second):
+ glog.Errorf("Failed to send registration response to worker %s", workerID)
+ }
+
+ // Start outgoing message handler
+ go s.handleOutgoingMessages(conn)
+
+ // Handle incoming messages
+ for {
+ select {
+ case <-ctx.Done():
+ glog.Infof("Worker %s connection closed: %v", workerID, ctx.Err())
+ s.unregisterWorker(workerID)
+ return nil
+ case <-connCtx.Done():
+ glog.Infof("Worker %s connection cancelled", workerID)
+ s.unregisterWorker(workerID)
+ return nil
+ default:
+ }
+
+ msg, err := stream.Recv()
+ if err != nil {
+ if err == io.EOF {
+ glog.Infof("Worker %s disconnected", workerID)
+ } else {
+ glog.Errorf("Error receiving from worker %s: %v", workerID, err)
+ }
+ s.unregisterWorker(workerID)
+ return err
+ }
+
+ conn.lastSeen = time.Now()
+ s.handleWorkerMessage(conn, msg)
+ }
+}
+
+// handleOutgoingMessages sends messages to worker
+func (s *WorkerGrpcServer) handleOutgoingMessages(conn *WorkerConnection) {
+ for {
+ select {
+ case <-conn.ctx.Done():
+ return
+ case msg, ok := <-conn.outgoing:
+ if !ok {
+ return
+ }
+
+ if err := conn.stream.Send(msg); err != nil {
+ glog.Errorf("Failed to send message to worker %s: %v", conn.workerID, err)
+ conn.cancel()
+ return
+ }
+ }
+ }
+}
+
+// handleWorkerMessage processes incoming messages from workers
+func (s *WorkerGrpcServer) handleWorkerMessage(conn *WorkerConnection, msg *worker_pb.WorkerMessage) {
+ workerID := conn.workerID
+
+ switch m := msg.Message.(type) {
+ case *worker_pb.WorkerMessage_Heartbeat:
+ s.handleHeartbeat(conn, m.Heartbeat)
+
+ case *worker_pb.WorkerMessage_TaskRequest:
+ s.handleTaskRequest(conn, m.TaskRequest)
+
+ case *worker_pb.WorkerMessage_TaskUpdate:
+ s.handleTaskUpdate(conn, m.TaskUpdate)
+
+ case *worker_pb.WorkerMessage_TaskComplete:
+ s.handleTaskCompletion(conn, m.TaskComplete)
+
+ case *worker_pb.WorkerMessage_Shutdown:
+ glog.Infof("Worker %s shutting down: %s", workerID, m.Shutdown.Reason)
+ s.unregisterWorker(workerID)
+
+ default:
+ glog.Warningf("Unknown message type from worker %s", workerID)
+ }
+}
+
+// registerWorkerWithManager registers the worker with the maintenance manager
+func (s *WorkerGrpcServer) registerWorkerWithManager(conn *WorkerConnection) {
+ if s.adminServer.maintenanceManager == nil {
+ return
+ }
+
+ worker := &MaintenanceWorker{
+ ID: conn.workerID,
+ Address: conn.address,
+ LastHeartbeat: time.Now(),
+ Status: "active",
+ Capabilities: conn.capabilities,
+ MaxConcurrent: int(conn.maxConcurrent),
+ CurrentLoad: 0,
+ }
+
+ s.adminServer.maintenanceManager.RegisterWorker(worker)
+ glog.V(1).Infof("Registered worker %s with maintenance manager", conn.workerID)
+}
+
+// handleHeartbeat processes heartbeat messages
+func (s *WorkerGrpcServer) handleHeartbeat(conn *WorkerConnection, heartbeat *worker_pb.WorkerHeartbeat) {
+ if s.adminServer.maintenanceManager != nil {
+ s.adminServer.maintenanceManager.UpdateWorkerHeartbeat(conn.workerID)
+ }
+
+ // Send heartbeat response
+ response := &worker_pb.AdminMessage{
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.AdminMessage_HeartbeatResponse{
+ HeartbeatResponse: &worker_pb.HeartbeatResponse{
+ Success: true,
+ Message: "Heartbeat acknowledged",
+ },
+ },
+ }
+
+ select {
+ case conn.outgoing <- response:
+ case <-time.After(time.Second):
+ glog.Warningf("Failed to send heartbeat response to worker %s", conn.workerID)
+ }
+}
+
+// handleTaskRequest processes task requests from workers
+func (s *WorkerGrpcServer) handleTaskRequest(conn *WorkerConnection, request *worker_pb.TaskRequest) {
+ if s.adminServer.maintenanceManager == nil {
+ return
+ }
+
+ // Get next task from maintenance manager
+ task := s.adminServer.maintenanceManager.GetNextTask(conn.workerID, conn.capabilities)
+
+ if task != nil {
+ // Send task assignment
+ assignment := &worker_pb.AdminMessage{
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.AdminMessage_TaskAssignment{
+ TaskAssignment: &worker_pb.TaskAssignment{
+ TaskId: task.ID,
+ TaskType: string(task.Type),
+ Params: &worker_pb.TaskParams{
+ VolumeId: task.VolumeID,
+ Server: task.Server,
+ Collection: task.Collection,
+ Parameters: convertTaskParameters(task.Parameters),
+ },
+ Priority: int32(task.Priority),
+ CreatedTime: time.Now().Unix(),
+ },
+ },
+ }
+
+ select {
+ case conn.outgoing <- assignment:
+ glog.V(2).Infof("Assigned task %s to worker %s", task.ID, conn.workerID)
+ case <-time.After(time.Second):
+ glog.Warningf("Failed to send task assignment to worker %s", conn.workerID)
+ }
+ }
+}
+
+// handleTaskUpdate processes task progress updates
+func (s *WorkerGrpcServer) handleTaskUpdate(conn *WorkerConnection, update *worker_pb.TaskUpdate) {
+ if s.adminServer.maintenanceManager != nil {
+ s.adminServer.maintenanceManager.UpdateTaskProgress(update.TaskId, float64(update.Progress))
+ glog.V(3).Infof("Updated task %s progress: %.1f%%", update.TaskId, update.Progress)
+ }
+}
+
+// handleTaskCompletion processes task completion notifications
+func (s *WorkerGrpcServer) handleTaskCompletion(conn *WorkerConnection, completion *worker_pb.TaskComplete) {
+ if s.adminServer.maintenanceManager != nil {
+ errorMsg := ""
+ if !completion.Success {
+ errorMsg = completion.ErrorMessage
+ }
+ s.adminServer.maintenanceManager.CompleteTask(completion.TaskId, errorMsg)
+
+ if completion.Success {
+ glog.V(1).Infof("Worker %s completed task %s successfully", conn.workerID, completion.TaskId)
+ } else {
+ glog.Errorf("Worker %s failed task %s: %s", conn.workerID, completion.TaskId, completion.ErrorMessage)
+ }
+ }
+}
+
+// unregisterWorker removes a worker connection
+func (s *WorkerGrpcServer) unregisterWorker(workerID string) {
+ s.connMutex.Lock()
+ if conn, exists := s.connections[workerID]; exists {
+ conn.cancel()
+ close(conn.outgoing)
+ delete(s.connections, workerID)
+ }
+ s.connMutex.Unlock()
+
+ glog.V(1).Infof("Unregistered worker %s", workerID)
+}
+
+// cleanupRoutine periodically cleans up stale connections
+func (s *WorkerGrpcServer) cleanupRoutine() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-s.stopChan:
+ return
+ case <-ticker.C:
+ s.cleanupStaleConnections()
+ }
+ }
+}
+
+// cleanupStaleConnections removes connections that haven't been seen recently
+func (s *WorkerGrpcServer) cleanupStaleConnections() {
+ cutoff := time.Now().Add(-2 * time.Minute)
+
+ s.connMutex.Lock()
+ defer s.connMutex.Unlock()
+
+ for workerID, conn := range s.connections {
+ if conn.lastSeen.Before(cutoff) {
+ glog.Warningf("Cleaning up stale worker connection: %s", workerID)
+ conn.cancel()
+ close(conn.outgoing)
+ delete(s.connections, workerID)
+ }
+ }
+}
+
+// GetConnectedWorkers returns a list of currently connected workers
+func (s *WorkerGrpcServer) GetConnectedWorkers() []string {
+ s.connMutex.RLock()
+ defer s.connMutex.RUnlock()
+
+ workers := make([]string, 0, len(s.connections))
+ for workerID := range s.connections {
+ workers = append(workers, workerID)
+ }
+ return workers
+}
+
+// convertTaskParameters converts task parameters to protobuf format
+func convertTaskParameters(params map[string]interface{}) map[string]string {
+ result := make(map[string]string)
+ for key, value := range params {
+ result[key] = fmt.Sprintf("%v", value)
+ }
+ return result
+}
+
+func findClientAddress(ctx context.Context) string {
+ // fmt.Printf("FromContext %+v\n", ctx)
+ pr, ok := peer.FromContext(ctx)
+ if !ok {
+ glog.Error("failed to get peer from ctx")
+ return ""
+ }
+ if pr.Addr == net.Addr(nil) {
+ glog.Error("failed to get peer address")
+ return ""
+ }
+ return pr.Addr.String()
+}
diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go
index 541bb6293..03d156d08 100644
--- a/weed/admin/handlers/admin_handlers.go
+++ b/weed/admin/handlers/admin_handlers.go
@@ -17,6 +17,7 @@ type AdminHandlers struct {
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
userHandlers *UserHandlers
+ maintenanceHandlers *MaintenanceHandlers
}
// NewAdminHandlers creates a new instance of AdminHandlers
@@ -25,12 +26,14 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
clusterHandlers := NewClusterHandlers(adminServer)
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
userHandlers := NewUserHandlers(adminServer)
+ maintenanceHandlers := NewMaintenanceHandlers(adminServer)
return &AdminHandlers{
adminServer: adminServer,
authHandlers: authHandlers,
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
userHandlers: userHandlers,
+ maintenanceHandlers: maintenanceHandlers,
}
}
@@ -69,13 +72,22 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
protected.GET("/cluster/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails)
protected.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections)
+ // Maintenance system routes
+ protected.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue)
+ protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
+ protected.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
+ protected.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig)
+ protected.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig)
+ protected.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig)
+
// API routes for AJAX calls
api := protected.Group("/api")
{
api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology)
api.GET("/cluster/masters", h.clusterHandlers.GetMasters)
api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers)
- api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
+ api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
+ api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information
// S3 API routes
s3Api := api.Group("/s3")
@@ -118,6 +130,20 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
{
volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume)
}
+
+ // Maintenance API routes
+ maintenanceApi := api.Group("/maintenance")
+ {
+ maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan)
+ maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks)
+ maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
+ maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask)
+ maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI)
+ maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
+ maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
+ maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
+ maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI)
+ }
}
} else {
// No authentication required - all routes are public
@@ -140,13 +166,22 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
r.GET("/cluster/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails)
r.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections)
+ // Maintenance system routes
+ r.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue)
+ r.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
+ r.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
+ r.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig)
+ r.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig)
+ r.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig)
+
// API routes for AJAX calls
api := r.Group("/api")
{
api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology)
api.GET("/cluster/masters", h.clusterHandlers.GetMasters)
api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers)
- api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
+ api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
+ api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information
// S3 API routes
s3Api := api.Group("/s3")
@@ -189,6 +224,20 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
{
volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume)
}
+
+ // Maintenance API routes
+ maintenanceApi := api.Group("/maintenance")
+ {
+ maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan)
+ maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks)
+ maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
+ maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask)
+ maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI)
+ maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
+ maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
+ maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
+ maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI)
+ }
}
}
}
diff --git a/weed/admin/handlers/maintenance_handlers.go b/weed/admin/handlers/maintenance_handlers.go
new file mode 100644
index 000000000..954874c14
--- /dev/null
+++ b/weed/admin/handlers/maintenance_handlers.go
@@ -0,0 +1,388 @@
+package handlers
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/app"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// MaintenanceHandlers handles maintenance-related HTTP requests
+type MaintenanceHandlers struct {
+ adminServer *dash.AdminServer
+}
+
+// NewMaintenanceHandlers creates a new instance of MaintenanceHandlers
+func NewMaintenanceHandlers(adminServer *dash.AdminServer) *MaintenanceHandlers {
+ return &MaintenanceHandlers{
+ adminServer: adminServer,
+ }
+}
+
+// ShowMaintenanceQueue displays the maintenance queue page
+func (h *MaintenanceHandlers) ShowMaintenanceQueue(c *gin.Context) {
+ data, err := h.getMaintenanceQueueData()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Render HTML template
+ c.Header("Content-Type", "text/html")
+ maintenanceComponent := app.MaintenanceQueue(data)
+ layoutComponent := layout.Layout(c, maintenanceComponent)
+ 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
+ }
+}
+
+// ShowMaintenanceWorkers displays the maintenance workers page
+func (h *MaintenanceHandlers) ShowMaintenanceWorkers(c *gin.Context) {
+ workersData, err := h.adminServer.GetMaintenanceWorkersData()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Render HTML template
+ c.Header("Content-Type", "text/html")
+ workersComponent := app.MaintenanceWorkers(workersData)
+ layoutComponent := layout.Layout(c, workersComponent)
+ 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
+ }
+}
+
+// ShowMaintenanceConfig displays the maintenance configuration page
+func (h *MaintenanceHandlers) ShowMaintenanceConfig(c *gin.Context) {
+ config, err := h.getMaintenanceConfig()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Render HTML template
+ c.Header("Content-Type", "text/html")
+ configComponent := app.MaintenanceConfig(config)
+ layoutComponent := layout.Layout(c, configComponent)
+ 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
+ }
+}
+
+// ShowTaskConfig displays the configuration page for a specific task type
+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"})
+ return
+ }
+
+ // Get the UI provider for this task type
+ uiRegistry := tasks.GetGlobalUIRegistry()
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+
+ var provider types.TaskUIProvider
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ provider = uiRegistry.GetProvider(workerTaskType)
+ break
+ }
+ }
+
+ if provider == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
+ return
+ }
+
+ // Try to get templ UI provider first
+ templUIProvider := getTemplUIProvider(taskType)
+ var configSections []components.ConfigSectionData
+
+ if templUIProvider != nil {
+ // Use the new templ-based UI provider
+ currentConfig := templUIProvider.GetCurrentConfig()
+ sections, err := templUIProvider.RenderConfigSections(currentConfig)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render configuration sections: " + err.Error()})
+ return
+ }
+ configSections = sections
+ } else {
+ // Fallback to basic configuration for providers that haven't been migrated yet
+ configSections = []components.ConfigSectionData{
+ {
+ Title: "Configuration Settings",
+ Icon: "fas fa-cogs",
+ Description: "Configure task detection and scheduling parameters",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "enabled",
+ Label: "Enable Task",
+ Description: "Whether this task type should be enabled",
+ },
+ Checked: true,
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "max_concurrent",
+ Label: "Max Concurrent Tasks",
+ Description: "Maximum number of concurrent tasks",
+ Required: true,
+ },
+ Value: 2,
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ components.DurationFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "scan_interval",
+ Label: "Scan Interval",
+ Description: "How often to scan for tasks",
+ Required: true,
+ },
+ Value: "30m",
+ },
+ },
+ },
+ }
+ }
+
+ // Create task configuration data using templ components
+ configData := &app.TaskConfigTemplData{
+ TaskType: taskType,
+ TaskName: provider.GetDisplayName(),
+ TaskIcon: provider.GetIcon(),
+ Description: provider.GetDescription(),
+ ConfigSections: configSections,
+ }
+
+ // Render HTML template using templ components
+ c.Header("Content-Type", "text/html")
+ taskConfigComponent := app.TaskConfigTempl(configData)
+ layoutComponent := layout.Layout(c, taskConfigComponent)
+ 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
+ }
+}
+
+// UpdateTaskConfig updates configuration for a specific task type
+func (h *MaintenanceHandlers) UpdateTaskConfig(c *gin.Context) {
+ taskTypeName := c.Param("taskType")
+
+ // Get the task type
+ taskType := maintenance.GetMaintenanceTaskType(taskTypeName)
+ if taskType == "" {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Task type not found"})
+ return
+ }
+
+ // Try to get templ UI provider first
+ templUIProvider := getTemplUIProvider(taskType)
+
+ // Parse form data
+ err := c.Request.ParseForm()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse form data: " + err.Error()})
+ return
+ }
+
+ // Convert form data to map
+ formData := make(map[string][]string)
+ for key, values := range c.Request.PostForm {
+ formData[key] = values
+ }
+
+ var config interface{}
+
+ if templUIProvider != nil {
+ // Use the new templ-based UI provider
+ config, err = templUIProvider.ParseConfigForm(formData)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
+ return
+ }
+
+ // Apply configuration using templ provider
+ err = templUIProvider.ApplyConfig(config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
+ return
+ }
+ } else {
+ // Fallback to old UI provider for tasks that haven't been migrated yet
+ uiRegistry := tasks.GetGlobalUIRegistry()
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+
+ var provider types.TaskUIProvider
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ provider = uiRegistry.GetProvider(workerTaskType)
+ break
+ }
+ }
+
+ if provider == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "UI provider not found for task type"})
+ return
+ }
+
+ // Parse configuration from form using old provider
+ config, err = provider.ParseConfigForm(formData)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse configuration: " + err.Error()})
+ return
+ }
+
+ // Apply configuration using old provider
+ err = provider.ApplyConfig(config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
+ return
+ }
+ }
+
+ // Redirect back to task configuration page
+ c.Redirect(http.StatusSeeOther, "/maintenance/config/"+taskTypeName)
+}
+
+// UpdateMaintenanceConfig updates maintenance configuration from form
+func (h *MaintenanceHandlers) UpdateMaintenanceConfig(c *gin.Context) {
+ var config maintenance.MaintenanceConfig
+ if err := c.ShouldBind(&config); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ err := h.updateMaintenanceConfig(&config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.Redirect(http.StatusSeeOther, "/maintenance/config")
+}
+
+// Helper methods that delegate to AdminServer
+
+func (h *MaintenanceHandlers) getMaintenanceQueueData() (*maintenance.MaintenanceQueueData, error) {
+ tasks, err := h.getMaintenanceTasks()
+ if err != nil {
+ return nil, err
+ }
+
+ workers, err := h.getMaintenanceWorkers()
+ if err != nil {
+ return nil, err
+ }
+
+ stats, err := h.getMaintenanceQueueStats()
+ if err != nil {
+ return nil, err
+ }
+
+ return &maintenance.MaintenanceQueueData{
+ Tasks: tasks,
+ Workers: workers,
+ Stats: stats,
+ LastUpdated: time.Now(),
+ }, nil
+}
+
+func (h *MaintenanceHandlers) getMaintenanceQueueStats() (*maintenance.QueueStats, error) {
+ // This would integrate with the maintenance queue to get real statistics
+ // For now, return mock data
+ return &maintenance.QueueStats{
+ PendingTasks: 5,
+ RunningTasks: 2,
+ CompletedToday: 15,
+ FailedToday: 1,
+ TotalTasks: 23,
+ }, nil
+}
+
+func (h *MaintenanceHandlers) getMaintenanceTasks() ([]*maintenance.MaintenanceTask, error) {
+ // This would integrate with the maintenance queue to get real tasks
+ // For now, return mock data
+ return []*maintenance.MaintenanceTask{}, nil
+}
+
+func (h *MaintenanceHandlers) getMaintenanceWorkers() ([]*maintenance.MaintenanceWorker, error) {
+ // This would integrate with the maintenance system to get real workers
+ // For now, return mock data
+ return []*maintenance.MaintenanceWorker{}, nil
+}
+
+func (h *MaintenanceHandlers) getMaintenanceConfig() (*maintenance.MaintenanceConfigData, error) {
+ // Delegate to AdminServer's real persistence method
+ return h.adminServer.GetMaintenanceConfigData()
+}
+
+func (h *MaintenanceHandlers) updateMaintenanceConfig(config *maintenance.MaintenanceConfig) error {
+ // Delegate to AdminServer's real persistence method
+ return h.adminServer.UpdateMaintenanceConfigData(config)
+}
+
+// floatPtr is a helper function to create float64 pointers
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+// Global templ UI registry
+var globalTemplUIRegistry *types.UITemplRegistry
+
+// initTemplUIRegistry initializes the global templ UI registry
+func initTemplUIRegistry() {
+ if globalTemplUIRegistry == nil {
+ globalTemplUIRegistry = types.NewUITemplRegistry()
+
+ // Register vacuum templ UI provider using shared instances
+ vacuumDetector, vacuumScheduler := vacuum.GetSharedInstances()
+ vacuum.RegisterUITempl(globalTemplUIRegistry, vacuumDetector, vacuumScheduler)
+
+ // Register erasure coding templ UI provider using shared instances
+ erasureCodingDetector, erasureCodingScheduler := erasure_coding.GetSharedInstances()
+ erasure_coding.RegisterUITempl(globalTemplUIRegistry, erasureCodingDetector, erasureCodingScheduler)
+
+ // Register balance templ UI provider using shared instances
+ balanceDetector, balanceScheduler := balance.GetSharedInstances()
+ balance.RegisterUITempl(globalTemplUIRegistry, balanceDetector, balanceScheduler)
+ }
+}
+
+// getTemplUIProvider gets the templ UI provider for a task type
+func getTemplUIProvider(taskType maintenance.MaintenanceTaskType) types.TaskUITemplProvider {
+ initTemplUIRegistry()
+
+ // Convert maintenance task type to worker task type
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ return globalTemplUIRegistry.GetProvider(workerTaskType)
+ }
+ }
+
+ return nil
+}
diff --git a/weed/admin/maintenance/maintenance_integration.go b/weed/admin/maintenance/maintenance_integration.go
new file mode 100644
index 000000000..9a965d38a
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_integration.go
@@ -0,0 +1,409 @@
+package maintenance
+
+import (
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// MaintenanceIntegration bridges the task system with existing maintenance
+type MaintenanceIntegration struct {
+ taskRegistry *types.TaskRegistry
+ uiRegistry *types.UIRegistry
+
+ // Bridge to existing system
+ maintenanceQueue *MaintenanceQueue
+ maintenancePolicy *MaintenancePolicy
+
+ // Type conversion maps
+ taskTypeMap map[types.TaskType]MaintenanceTaskType
+ revTaskTypeMap map[MaintenanceTaskType]types.TaskType
+ priorityMap map[types.TaskPriority]MaintenanceTaskPriority
+ revPriorityMap map[MaintenanceTaskPriority]types.TaskPriority
+}
+
+// NewMaintenanceIntegration creates the integration bridge
+func NewMaintenanceIntegration(queue *MaintenanceQueue, policy *MaintenancePolicy) *MaintenanceIntegration {
+ integration := &MaintenanceIntegration{
+ taskRegistry: tasks.GetGlobalTypesRegistry(), // Use global types registry with auto-registered tasks
+ uiRegistry: tasks.GetGlobalUIRegistry(), // Use global UI registry with auto-registered UI providers
+ maintenanceQueue: queue,
+ maintenancePolicy: policy,
+ }
+
+ // Initialize type conversion maps
+ integration.initializeTypeMaps()
+
+ // Register all tasks
+ integration.registerAllTasks()
+
+ return integration
+}
+
+// initializeTypeMaps creates the type conversion maps for dynamic conversion
+func (s *MaintenanceIntegration) initializeTypeMaps() {
+ // Initialize empty maps
+ s.taskTypeMap = make(map[types.TaskType]MaintenanceTaskType)
+ s.revTaskTypeMap = make(map[MaintenanceTaskType]types.TaskType)
+
+ // Build task type mappings dynamically from registered tasks after registration
+ // This will be called from registerAllTasks() after all tasks are registered
+
+ // Priority mappings (these are static and don't depend on registered tasks)
+ s.priorityMap = map[types.TaskPriority]MaintenanceTaskPriority{
+ types.TaskPriorityLow: PriorityLow,
+ types.TaskPriorityNormal: PriorityNormal,
+ types.TaskPriorityHigh: PriorityHigh,
+ }
+
+ // Reverse priority mappings
+ s.revPriorityMap = map[MaintenanceTaskPriority]types.TaskPriority{
+ PriorityLow: types.TaskPriorityLow,
+ PriorityNormal: types.TaskPriorityNormal,
+ PriorityHigh: types.TaskPriorityHigh,
+ PriorityCritical: types.TaskPriorityHigh, // Map critical to high
+ }
+}
+
+// buildTaskTypeMappings dynamically builds task type mappings from registered tasks
+func (s *MaintenanceIntegration) buildTaskTypeMappings() {
+ // Clear existing mappings
+ s.taskTypeMap = make(map[types.TaskType]MaintenanceTaskType)
+ s.revTaskTypeMap = make(map[MaintenanceTaskType]types.TaskType)
+
+ // Build mappings from registered detectors
+ for workerTaskType := range s.taskRegistry.GetAllDetectors() {
+ // Convert types.TaskType to MaintenanceTaskType by string conversion
+ maintenanceTaskType := MaintenanceTaskType(string(workerTaskType))
+
+ s.taskTypeMap[workerTaskType] = maintenanceTaskType
+ s.revTaskTypeMap[maintenanceTaskType] = workerTaskType
+
+ glog.V(3).Infof("Dynamically mapped task type: %s <-> %s", workerTaskType, maintenanceTaskType)
+ }
+
+ glog.V(2).Infof("Built %d dynamic task type mappings", len(s.taskTypeMap))
+}
+
+// registerAllTasks registers all available tasks
+func (s *MaintenanceIntegration) registerAllTasks() {
+ // Tasks are already auto-registered via import statements
+ // No manual registration needed
+
+ // Build dynamic type mappings from registered tasks
+ s.buildTaskTypeMappings()
+
+ // Configure tasks from policy
+ s.configureTasksFromPolicy()
+
+ registeredTaskTypes := make([]string, 0, len(s.taskTypeMap))
+ for _, maintenanceTaskType := range s.taskTypeMap {
+ registeredTaskTypes = append(registeredTaskTypes, string(maintenanceTaskType))
+ }
+ glog.V(1).Infof("Registered tasks: %v", registeredTaskTypes)
+}
+
+// configureTasksFromPolicy dynamically configures all registered tasks based on the maintenance policy
+func (s *MaintenanceIntegration) configureTasksFromPolicy() {
+ if s.maintenancePolicy == nil {
+ return
+ }
+
+ // Configure all registered detectors and schedulers dynamically using policy configuration
+ configuredCount := 0
+
+ // Get all registered task types from the registry
+ for taskType, detector := range s.taskRegistry.GetAllDetectors() {
+ // Configure detector using policy-based configuration
+ s.configureDetectorFromPolicy(taskType, detector)
+ configuredCount++
+ }
+
+ for taskType, scheduler := range s.taskRegistry.GetAllSchedulers() {
+ // Configure scheduler using policy-based configuration
+ s.configureSchedulerFromPolicy(taskType, scheduler)
+ }
+
+ glog.V(1).Infof("Dynamically configured %d task types from maintenance policy", configuredCount)
+}
+
+// configureDetectorFromPolicy configures a detector using policy-based configuration
+func (s *MaintenanceIntegration) configureDetectorFromPolicy(taskType types.TaskType, detector types.TaskDetector) {
+ // Try to configure using PolicyConfigurableDetector interface if supported
+ if configurableDetector, ok := detector.(types.PolicyConfigurableDetector); ok {
+ configurableDetector.ConfigureFromPolicy(s.maintenancePolicy)
+ glog.V(2).Infof("Configured detector %s using policy interface", taskType)
+ return
+ }
+
+ // Apply basic configuration that all detectors should support
+ if basicDetector, ok := detector.(interface{ SetEnabled(bool) }); ok {
+ // Convert task system type to maintenance task type for policy lookup
+ maintenanceTaskType, exists := s.taskTypeMap[taskType]
+ if exists {
+ enabled := s.maintenancePolicy.IsTaskEnabled(maintenanceTaskType)
+ basicDetector.SetEnabled(enabled)
+ glog.V(3).Infof("Set enabled=%v for detector %s", enabled, taskType)
+ }
+ }
+
+ // For detectors that don't implement PolicyConfigurableDetector interface,
+ // they should be updated to implement it for full policy-based configuration
+ glog.V(2).Infof("Detector %s should implement PolicyConfigurableDetector interface for full policy support", taskType)
+}
+
+// configureSchedulerFromPolicy configures a scheduler using policy-based configuration
+func (s *MaintenanceIntegration) configureSchedulerFromPolicy(taskType types.TaskType, scheduler types.TaskScheduler) {
+ // Try to configure using PolicyConfigurableScheduler interface if supported
+ if configurableScheduler, ok := scheduler.(types.PolicyConfigurableScheduler); ok {
+ configurableScheduler.ConfigureFromPolicy(s.maintenancePolicy)
+ glog.V(2).Infof("Configured scheduler %s using policy interface", taskType)
+ return
+ }
+
+ // Apply basic configuration that all schedulers should support
+ maintenanceTaskType, exists := s.taskTypeMap[taskType]
+ if !exists {
+ glog.V(3).Infof("No maintenance task type mapping for %s, skipping configuration", taskType)
+ return
+ }
+
+ // Set enabled status if scheduler supports it
+ if enableableScheduler, ok := scheduler.(interface{ SetEnabled(bool) }); ok {
+ enabled := s.maintenancePolicy.IsTaskEnabled(maintenanceTaskType)
+ enableableScheduler.SetEnabled(enabled)
+ glog.V(3).Infof("Set enabled=%v for scheduler %s", enabled, taskType)
+ }
+
+ // Set max concurrent if scheduler supports it
+ if concurrentScheduler, ok := scheduler.(interface{ SetMaxConcurrent(int) }); ok {
+ maxConcurrent := s.maintenancePolicy.GetMaxConcurrent(maintenanceTaskType)
+ if maxConcurrent > 0 {
+ concurrentScheduler.SetMaxConcurrent(maxConcurrent)
+ glog.V(3).Infof("Set max concurrent=%d for scheduler %s", maxConcurrent, taskType)
+ }
+ }
+
+ // For schedulers that don't implement PolicyConfigurableScheduler interface,
+ // they should be updated to implement it for full policy-based configuration
+ glog.V(2).Infof("Scheduler %s should implement PolicyConfigurableScheduler interface for full policy support", taskType)
+}
+
+// ScanWithTaskDetectors performs a scan using the task system
+func (s *MaintenanceIntegration) ScanWithTaskDetectors(volumeMetrics []*types.VolumeHealthMetrics) ([]*TaskDetectionResult, error) {
+ var allResults []*TaskDetectionResult
+
+ // Create cluster info
+ clusterInfo := &types.ClusterInfo{
+ TotalVolumes: len(volumeMetrics),
+ LastUpdated: time.Now(),
+ }
+
+ // Run detection for each registered task type
+ for taskType, detector := range s.taskRegistry.GetAllDetectors() {
+ if !detector.IsEnabled() {
+ continue
+ }
+
+ glog.V(2).Infof("Running detection for task type: %s", taskType)
+
+ results, err := detector.ScanForTasks(volumeMetrics, clusterInfo)
+ if err != nil {
+ glog.Errorf("Failed to scan for %s tasks: %v", taskType, err)
+ continue
+ }
+
+ // Convert results to existing system format
+ for _, result := range results {
+ existingResult := s.convertToExistingFormat(result)
+ if existingResult != nil {
+ allResults = append(allResults, existingResult)
+ }
+ }
+
+ glog.V(2).Infof("Found %d %s tasks", len(results), taskType)
+ }
+
+ return allResults, nil
+}
+
+// convertToExistingFormat converts task results to existing system format using dynamic mapping
+func (s *MaintenanceIntegration) convertToExistingFormat(result *types.TaskDetectionResult) *TaskDetectionResult {
+ // Convert types using mapping tables
+ existingType, exists := s.taskTypeMap[result.TaskType]
+ if !exists {
+ glog.Warningf("Unknown task type %s, skipping conversion", result.TaskType)
+ // Return nil to indicate conversion failed - caller should handle this
+ return nil
+ }
+
+ existingPriority, exists := s.priorityMap[result.Priority]
+ if !exists {
+ glog.Warningf("Unknown priority %d, defaulting to normal", result.Priority)
+ existingPriority = PriorityNormal
+ }
+
+ return &TaskDetectionResult{
+ TaskType: existingType,
+ VolumeID: result.VolumeID,
+ Server: result.Server,
+ Collection: result.Collection,
+ Priority: existingPriority,
+ Reason: result.Reason,
+ Parameters: result.Parameters,
+ ScheduleAt: result.ScheduleAt,
+ }
+}
+
+// CanScheduleWithTaskSchedulers determines if a task can be scheduled using task schedulers with dynamic type conversion
+func (s *MaintenanceIntegration) CanScheduleWithTaskSchedulers(task *MaintenanceTask, runningTasks []*MaintenanceTask, availableWorkers []*MaintenanceWorker) bool {
+ // Convert existing types to task types using mapping
+ taskType, exists := s.revTaskTypeMap[task.Type]
+ if !exists {
+ glog.V(2).Infof("Unknown task type %s for scheduling, falling back to existing logic", task.Type)
+ return false // Fallback to existing logic for unknown types
+ }
+
+ // Convert task objects
+ taskObject := s.convertTaskToTaskSystem(task)
+ if taskObject == nil {
+ glog.V(2).Infof("Failed to convert task %s for scheduling", task.ID)
+ return false
+ }
+
+ runningTaskObjects := s.convertTasksToTaskSystem(runningTasks)
+ workerObjects := s.convertWorkersToTaskSystem(availableWorkers)
+
+ // Get the appropriate scheduler
+ scheduler := s.taskRegistry.GetScheduler(taskType)
+ if scheduler == nil {
+ glog.V(2).Infof("No scheduler found for task type %s", taskType)
+ return false
+ }
+
+ return scheduler.CanScheduleNow(taskObject, runningTaskObjects, workerObjects)
+}
+
+// convertTaskToTaskSystem converts existing task to task system format using dynamic mapping
+func (s *MaintenanceIntegration) convertTaskToTaskSystem(task *MaintenanceTask) *types.Task {
+ // Convert task type using mapping
+ taskType, exists := s.revTaskTypeMap[task.Type]
+ if !exists {
+ glog.Errorf("Unknown task type %s in conversion, cannot convert task", task.Type)
+ // Return nil to indicate conversion failed
+ return nil
+ }
+
+ // Convert priority using mapping
+ priority, exists := s.revPriorityMap[task.Priority]
+ if !exists {
+ glog.Warningf("Unknown priority %d in conversion, defaulting to normal", task.Priority)
+ priority = types.TaskPriorityNormal
+ }
+
+ return &types.Task{
+ ID: task.ID,
+ Type: taskType,
+ Priority: priority,
+ VolumeID: task.VolumeID,
+ Server: task.Server,
+ Collection: task.Collection,
+ Parameters: task.Parameters,
+ CreatedAt: task.CreatedAt,
+ }
+}
+
+// convertTasksToTaskSystem converts multiple tasks
+func (s *MaintenanceIntegration) convertTasksToTaskSystem(tasks []*MaintenanceTask) []*types.Task {
+ var result []*types.Task
+ for _, task := range tasks {
+ converted := s.convertTaskToTaskSystem(task)
+ if converted != nil {
+ result = append(result, converted)
+ }
+ }
+ return result
+}
+
+// convertWorkersToTaskSystem converts workers to task system format using dynamic mapping
+func (s *MaintenanceIntegration) convertWorkersToTaskSystem(workers []*MaintenanceWorker) []*types.Worker {
+ var result []*types.Worker
+ for _, worker := range workers {
+ capabilities := make([]types.TaskType, 0, len(worker.Capabilities))
+ for _, cap := range worker.Capabilities {
+ // Convert capability using mapping
+ taskType, exists := s.revTaskTypeMap[cap]
+ if exists {
+ capabilities = append(capabilities, taskType)
+ } else {
+ glog.V(3).Infof("Unknown capability %s for worker %s, skipping", cap, worker.ID)
+ }
+ }
+
+ result = append(result, &types.Worker{
+ ID: worker.ID,
+ Address: worker.Address,
+ Capabilities: capabilities,
+ MaxConcurrent: worker.MaxConcurrent,
+ CurrentLoad: worker.CurrentLoad,
+ })
+ }
+ return result
+}
+
+// GetTaskScheduler returns the scheduler for a task type using dynamic mapping
+func (s *MaintenanceIntegration) GetTaskScheduler(taskType MaintenanceTaskType) types.TaskScheduler {
+ // Convert task type using mapping
+ taskSystemType, exists := s.revTaskTypeMap[taskType]
+ if !exists {
+ glog.V(3).Infof("Unknown task type %s for scheduler", taskType)
+ return nil
+ }
+
+ return s.taskRegistry.GetScheduler(taskSystemType)
+}
+
+// GetUIProvider returns the UI provider for a task type using dynamic mapping
+func (s *MaintenanceIntegration) GetUIProvider(taskType MaintenanceTaskType) types.TaskUIProvider {
+ // Convert task type using mapping
+ taskSystemType, exists := s.revTaskTypeMap[taskType]
+ if !exists {
+ glog.V(3).Infof("Unknown task type %s for UI provider", taskType)
+ return nil
+ }
+
+ return s.uiRegistry.GetProvider(taskSystemType)
+}
+
+// GetAllTaskStats returns stats for all registered tasks
+func (s *MaintenanceIntegration) GetAllTaskStats() []*types.TaskStats {
+ var stats []*types.TaskStats
+
+ for taskType, detector := range s.taskRegistry.GetAllDetectors() {
+ uiProvider := s.uiRegistry.GetProvider(taskType)
+ if uiProvider == nil {
+ continue
+ }
+
+ stat := &types.TaskStats{
+ TaskType: taskType,
+ DisplayName: uiProvider.GetDisplayName(),
+ Enabled: detector.IsEnabled(),
+ LastScan: time.Now().Add(-detector.ScanInterval()),
+ NextScan: time.Now().Add(detector.ScanInterval()),
+ ScanInterval: detector.ScanInterval(),
+ MaxConcurrent: s.taskRegistry.GetScheduler(taskType).GetMaxConcurrent(),
+ // Would need to get these from actual queue/stats
+ PendingTasks: 0,
+ RunningTasks: 0,
+ CompletedToday: 0,
+ FailedToday: 0,
+ }
+
+ stats = append(stats, stat)
+ }
+
+ return stats
+}
diff --git a/weed/admin/maintenance/maintenance_manager.go b/weed/admin/maintenance/maintenance_manager.go
new file mode 100644
index 000000000..17d1eef6d
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_manager.go
@@ -0,0 +1,407 @@
+package maintenance
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+// MaintenanceManager coordinates the maintenance system
+type MaintenanceManager struct {
+ config *MaintenanceConfig
+ scanner *MaintenanceScanner
+ queue *MaintenanceQueue
+ adminClient AdminClient
+ running bool
+ stopChan chan struct{}
+ // Error handling and backoff
+ errorCount int
+ lastError error
+ lastErrorTime time.Time
+ backoffDelay time.Duration
+ mutex sync.RWMutex
+}
+
+// NewMaintenanceManager creates a new maintenance manager
+func NewMaintenanceManager(adminClient AdminClient, config *MaintenanceConfig) *MaintenanceManager {
+ if config == nil {
+ config = DefaultMaintenanceConfig()
+ }
+
+ queue := NewMaintenanceQueue(config.Policy)
+ scanner := NewMaintenanceScanner(adminClient, config.Policy, queue)
+
+ return &MaintenanceManager{
+ config: config,
+ scanner: scanner,
+ queue: queue,
+ adminClient: adminClient,
+ stopChan: make(chan struct{}),
+ backoffDelay: time.Second, // Start with 1 second backoff
+ }
+}
+
+// Start begins the maintenance manager
+func (mm *MaintenanceManager) Start() error {
+ if !mm.config.Enabled {
+ glog.V(1).Infof("Maintenance system is disabled")
+ return nil
+ }
+
+ // Validate configuration durations to prevent ticker panics
+ if err := mm.validateConfig(); err != nil {
+ return fmt.Errorf("invalid maintenance configuration: %v", err)
+ }
+
+ mm.running = true
+
+ // Start background processes
+ go mm.scanLoop()
+ go mm.cleanupLoop()
+
+ glog.Infof("Maintenance manager started with scan interval %ds", mm.config.ScanIntervalSeconds)
+ return nil
+}
+
+// validateConfig validates the maintenance configuration durations
+func (mm *MaintenanceManager) validateConfig() error {
+ if mm.config.ScanIntervalSeconds <= 0 {
+ glog.Warningf("Invalid scan interval %ds, using default 30m", mm.config.ScanIntervalSeconds)
+ mm.config.ScanIntervalSeconds = 30 * 60 // 30 minutes in seconds
+ }
+
+ if mm.config.CleanupIntervalSeconds <= 0 {
+ glog.Warningf("Invalid cleanup interval %ds, using default 24h", mm.config.CleanupIntervalSeconds)
+ mm.config.CleanupIntervalSeconds = 24 * 60 * 60 // 24 hours in seconds
+ }
+
+ if mm.config.WorkerTimeoutSeconds <= 0 {
+ glog.Warningf("Invalid worker timeout %ds, using default 5m", mm.config.WorkerTimeoutSeconds)
+ mm.config.WorkerTimeoutSeconds = 5 * 60 // 5 minutes in seconds
+ }
+
+ if mm.config.TaskTimeoutSeconds <= 0 {
+ glog.Warningf("Invalid task timeout %ds, using default 2h", mm.config.TaskTimeoutSeconds)
+ mm.config.TaskTimeoutSeconds = 2 * 60 * 60 // 2 hours in seconds
+ }
+
+ if mm.config.RetryDelaySeconds <= 0 {
+ glog.Warningf("Invalid retry delay %ds, using default 15m", mm.config.RetryDelaySeconds)
+ mm.config.RetryDelaySeconds = 15 * 60 // 15 minutes in seconds
+ }
+
+ if mm.config.TaskRetentionSeconds <= 0 {
+ glog.Warningf("Invalid task retention %ds, using default 168h", mm.config.TaskRetentionSeconds)
+ mm.config.TaskRetentionSeconds = 7 * 24 * 60 * 60 // 7 days in seconds
+ }
+
+ return nil
+}
+
+// IsRunning returns whether the maintenance manager is currently running
+func (mm *MaintenanceManager) IsRunning() bool {
+ return mm.running
+}
+
+// Stop terminates the maintenance manager
+func (mm *MaintenanceManager) Stop() {
+ mm.running = false
+ close(mm.stopChan)
+ glog.Infof("Maintenance manager stopped")
+}
+
+// scanLoop periodically scans for maintenance tasks with adaptive timing
+func (mm *MaintenanceManager) scanLoop() {
+ scanInterval := time.Duration(mm.config.ScanIntervalSeconds) * time.Second
+ ticker := time.NewTicker(scanInterval)
+ defer ticker.Stop()
+
+ for mm.running {
+ select {
+ case <-mm.stopChan:
+ return
+ case <-ticker.C:
+ glog.V(1).Infof("Performing maintenance scan every %v", scanInterval)
+ mm.performScan()
+
+ // Adjust ticker interval based on error state
+ mm.mutex.RLock()
+ currentInterval := scanInterval
+ if mm.errorCount > 0 {
+ // Use backoff delay when there are errors
+ currentInterval = mm.backoffDelay
+ if currentInterval > scanInterval {
+ // Don't make it longer than the configured interval * 10
+ maxInterval := scanInterval * 10
+ if currentInterval > maxInterval {
+ currentInterval = maxInterval
+ }
+ }
+ }
+ mm.mutex.RUnlock()
+
+ // Reset ticker with new interval if needed
+ if currentInterval != scanInterval {
+ ticker.Stop()
+ ticker = time.NewTicker(currentInterval)
+ }
+ }
+ }
+}
+
+// cleanupLoop periodically cleans up old tasks and stale workers
+func (mm *MaintenanceManager) cleanupLoop() {
+ cleanupInterval := time.Duration(mm.config.CleanupIntervalSeconds) * time.Second
+ ticker := time.NewTicker(cleanupInterval)
+ defer ticker.Stop()
+
+ for mm.running {
+ select {
+ case <-mm.stopChan:
+ return
+ case <-ticker.C:
+ mm.performCleanup()
+ }
+ }
+}
+
+// performScan executes a maintenance scan with error handling and backoff
+func (mm *MaintenanceManager) performScan() {
+ mm.mutex.Lock()
+ defer mm.mutex.Unlock()
+
+ glog.V(2).Infof("Starting maintenance scan")
+
+ results, err := mm.scanner.ScanForMaintenanceTasks()
+ if err != nil {
+ mm.handleScanError(err)
+ return
+ }
+
+ // Scan succeeded, reset error tracking
+ mm.resetErrorTracking()
+
+ if len(results) > 0 {
+ mm.queue.AddTasksFromResults(results)
+ glog.V(1).Infof("Maintenance scan completed: added %d tasks", len(results))
+ } else {
+ glog.V(2).Infof("Maintenance scan completed: no tasks needed")
+ }
+}
+
+// handleScanError handles scan errors with exponential backoff and reduced logging
+func (mm *MaintenanceManager) handleScanError(err error) {
+ now := time.Now()
+ mm.errorCount++
+ mm.lastError = err
+ mm.lastErrorTime = now
+
+ // Use exponential backoff with jitter
+ if mm.errorCount > 1 {
+ mm.backoffDelay = mm.backoffDelay * 2
+ if mm.backoffDelay > 5*time.Minute {
+ mm.backoffDelay = 5 * time.Minute // Cap at 5 minutes
+ }
+ }
+
+ // Reduce log frequency based on error count and time
+ shouldLog := false
+ if mm.errorCount <= 3 {
+ // Log first 3 errors immediately
+ shouldLog = true
+ } else if mm.errorCount <= 10 && mm.errorCount%3 == 0 {
+ // Log every 3rd error for errors 4-10
+ shouldLog = true
+ } else if mm.errorCount%10 == 0 {
+ // Log every 10th error after that
+ shouldLog = true
+ }
+
+ if shouldLog {
+ // Check if it's a connection error to provide better messaging
+ if isConnectionError(err) {
+ if mm.errorCount == 1 {
+ glog.Errorf("Maintenance scan failed: %v (will retry with backoff)", err)
+ } else {
+ glog.Errorf("Maintenance scan still failing after %d attempts: %v (backoff: %v)",
+ mm.errorCount, err, mm.backoffDelay)
+ }
+ } else {
+ glog.Errorf("Maintenance scan failed: %v", err)
+ }
+ } else {
+ // Use debug level for suppressed errors
+ glog.V(3).Infof("Maintenance scan failed (error #%d, suppressed): %v", mm.errorCount, err)
+ }
+}
+
+// resetErrorTracking resets error tracking when scan succeeds
+func (mm *MaintenanceManager) resetErrorTracking() {
+ if mm.errorCount > 0 {
+ glog.V(1).Infof("Maintenance scan recovered after %d failed attempts", mm.errorCount)
+ mm.errorCount = 0
+ mm.lastError = nil
+ mm.backoffDelay = time.Second // Reset to initial delay
+ }
+}
+
+// isConnectionError checks if the error is a connection-related error
+func isConnectionError(err error) bool {
+ if err == nil {
+ return false
+ }
+ errStr := err.Error()
+ return strings.Contains(errStr, "connection refused") ||
+ strings.Contains(errStr, "connection error") ||
+ strings.Contains(errStr, "dial tcp") ||
+ strings.Contains(errStr, "connection timeout") ||
+ strings.Contains(errStr, "no route to host") ||
+ strings.Contains(errStr, "network unreachable")
+}
+
+// performCleanup cleans up old tasks and stale workers
+func (mm *MaintenanceManager) performCleanup() {
+ glog.V(2).Infof("Starting maintenance cleanup")
+
+ taskRetention := time.Duration(mm.config.TaskRetentionSeconds) * time.Second
+ workerTimeout := time.Duration(mm.config.WorkerTimeoutSeconds) * time.Second
+
+ removedTasks := mm.queue.CleanupOldTasks(taskRetention)
+ removedWorkers := mm.queue.RemoveStaleWorkers(workerTimeout)
+
+ if removedTasks > 0 || removedWorkers > 0 {
+ glog.V(1).Infof("Cleanup completed: removed %d old tasks and %d stale workers", removedTasks, removedWorkers)
+ }
+}
+
+// GetQueue returns the maintenance queue
+func (mm *MaintenanceManager) GetQueue() *MaintenanceQueue {
+ return mm.queue
+}
+
+// GetConfig returns the maintenance configuration
+func (mm *MaintenanceManager) GetConfig() *MaintenanceConfig {
+ return mm.config
+}
+
+// GetStats returns maintenance statistics
+func (mm *MaintenanceManager) GetStats() *MaintenanceStats {
+ stats := mm.queue.GetStats()
+
+ mm.mutex.RLock()
+ defer mm.mutex.RUnlock()
+
+ stats.LastScanTime = time.Now() // Would need to track this properly
+
+ // Calculate next scan time based on current error state
+ scanInterval := time.Duration(mm.config.ScanIntervalSeconds) * time.Second
+ nextScanInterval := scanInterval
+ if mm.errorCount > 0 {
+ nextScanInterval = mm.backoffDelay
+ maxInterval := scanInterval * 10
+ if nextScanInterval > maxInterval {
+ nextScanInterval = maxInterval
+ }
+ }
+ stats.NextScanTime = time.Now().Add(nextScanInterval)
+
+ return stats
+}
+
+// GetErrorState returns the current error state for monitoring
+func (mm *MaintenanceManager) GetErrorState() (errorCount int, lastError error, backoffDelay time.Duration) {
+ mm.mutex.RLock()
+ defer mm.mutex.RUnlock()
+ return mm.errorCount, mm.lastError, mm.backoffDelay
+}
+
+// GetTasks returns tasks with filtering
+func (mm *MaintenanceManager) GetTasks(status MaintenanceTaskStatus, taskType MaintenanceTaskType, limit int) []*MaintenanceTask {
+ return mm.queue.GetTasks(status, taskType, limit)
+}
+
+// GetWorkers returns all registered workers
+func (mm *MaintenanceManager) GetWorkers() []*MaintenanceWorker {
+ return mm.queue.GetWorkers()
+}
+
+// TriggerScan manually triggers a maintenance scan
+func (mm *MaintenanceManager) TriggerScan() error {
+ if !mm.running {
+ return fmt.Errorf("maintenance manager is not running")
+ }
+
+ go mm.performScan()
+ return nil
+}
+
+// UpdateConfig updates the maintenance configuration
+func (mm *MaintenanceManager) UpdateConfig(config *MaintenanceConfig) error {
+ if config == nil {
+ return fmt.Errorf("config cannot be nil")
+ }
+
+ mm.config = config
+ mm.queue.policy = config.Policy
+ mm.scanner.policy = config.Policy
+
+ glog.V(1).Infof("Maintenance configuration updated")
+ return nil
+}
+
+// CancelTask cancels a pending task
+func (mm *MaintenanceManager) CancelTask(taskID string) error {
+ mm.queue.mutex.Lock()
+ defer mm.queue.mutex.Unlock()
+
+ task, exists := mm.queue.tasks[taskID]
+ if !exists {
+ return fmt.Errorf("task %s not found", taskID)
+ }
+
+ if task.Status == TaskStatusPending {
+ task.Status = TaskStatusCancelled
+ task.CompletedAt = &[]time.Time{time.Now()}[0]
+
+ // Remove from pending tasks
+ for i, pendingTask := range mm.queue.pendingTasks {
+ if pendingTask.ID == taskID {
+ mm.queue.pendingTasks = append(mm.queue.pendingTasks[:i], mm.queue.pendingTasks[i+1:]...)
+ break
+ }
+ }
+
+ glog.V(2).Infof("Cancelled task %s", taskID)
+ return nil
+ }
+
+ return fmt.Errorf("task %s cannot be cancelled (status: %s)", taskID, task.Status)
+}
+
+// RegisterWorker registers a new worker
+func (mm *MaintenanceManager) RegisterWorker(worker *MaintenanceWorker) {
+ mm.queue.RegisterWorker(worker)
+}
+
+// GetNextTask returns the next task for a worker
+func (mm *MaintenanceManager) GetNextTask(workerID string, capabilities []MaintenanceTaskType) *MaintenanceTask {
+ return mm.queue.GetNextTask(workerID, capabilities)
+}
+
+// CompleteTask marks a task as completed
+func (mm *MaintenanceManager) CompleteTask(taskID string, error string) {
+ mm.queue.CompleteTask(taskID, error)
+}
+
+// UpdateTaskProgress updates task progress
+func (mm *MaintenanceManager) UpdateTaskProgress(taskID string, progress float64) {
+ mm.queue.UpdateTaskProgress(taskID, progress)
+}
+
+// UpdateWorkerHeartbeat updates worker heartbeat
+func (mm *MaintenanceManager) UpdateWorkerHeartbeat(workerID string) {
+ mm.queue.UpdateWorkerHeartbeat(workerID)
+}
diff --git a/weed/admin/maintenance/maintenance_manager_test.go b/weed/admin/maintenance/maintenance_manager_test.go
new file mode 100644
index 000000000..243a88f5e
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_manager_test.go
@@ -0,0 +1,140 @@
+package maintenance
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestMaintenanceManager_ErrorHandling(t *testing.T) {
+ config := DefaultMaintenanceConfig()
+ config.ScanIntervalSeconds = 1 // Short interval for testing (1 second)
+
+ manager := NewMaintenanceManager(nil, config)
+
+ // Test initial state
+ if manager.errorCount != 0 {
+ t.Errorf("Expected initial error count to be 0, got %d", manager.errorCount)
+ }
+
+ if manager.backoffDelay != time.Second {
+ t.Errorf("Expected initial backoff delay to be 1s, got %v", manager.backoffDelay)
+ }
+
+ // Test error handling
+ err := errors.New("dial tcp [::1]:19333: connect: connection refused")
+ manager.handleScanError(err)
+
+ if manager.errorCount != 1 {
+ t.Errorf("Expected error count to be 1, got %d", manager.errorCount)
+ }
+
+ if manager.lastError != err {
+ t.Errorf("Expected last error to be set")
+ }
+
+ // Test exponential backoff
+ initialDelay := manager.backoffDelay
+ manager.handleScanError(err)
+
+ if manager.backoffDelay != initialDelay*2 {
+ t.Errorf("Expected backoff delay to double, got %v", manager.backoffDelay)
+ }
+
+ if manager.errorCount != 2 {
+ t.Errorf("Expected error count to be 2, got %d", manager.errorCount)
+ }
+
+ // Test backoff cap
+ for i := 0; i < 10; i++ {
+ manager.handleScanError(err)
+ }
+
+ if manager.backoffDelay > 5*time.Minute {
+ t.Errorf("Expected backoff delay to be capped at 5 minutes, got %v", manager.backoffDelay)
+ }
+
+ // Test error reset
+ manager.resetErrorTracking()
+
+ if manager.errorCount != 0 {
+ t.Errorf("Expected error count to be reset to 0, got %d", manager.errorCount)
+ }
+
+ if manager.backoffDelay != time.Second {
+ t.Errorf("Expected backoff delay to be reset to 1s, got %v", manager.backoffDelay)
+ }
+
+ if manager.lastError != nil {
+ t.Errorf("Expected last error to be reset to nil")
+ }
+}
+
+func TestIsConnectionError(t *testing.T) {
+ tests := []struct {
+ err error
+ expected bool
+ }{
+ {nil, false},
+ {errors.New("connection refused"), true},
+ {errors.New("dial tcp [::1]:19333: connect: connection refused"), true},
+ {errors.New("connection error: desc = \"transport: Error while dialing\""), true},
+ {errors.New("connection timeout"), true},
+ {errors.New("no route to host"), true},
+ {errors.New("network unreachable"), true},
+ {errors.New("some other error"), false},
+ {errors.New("invalid argument"), false},
+ }
+
+ for _, test := range tests {
+ result := isConnectionError(test.err)
+ if result != test.expected {
+ t.Errorf("For error %v, expected %v, got %v", test.err, test.expected, result)
+ }
+ }
+}
+
+func TestMaintenanceManager_GetErrorState(t *testing.T) {
+ config := DefaultMaintenanceConfig()
+ manager := NewMaintenanceManager(nil, config)
+
+ // Test initial state
+ errorCount, lastError, backoffDelay := manager.GetErrorState()
+ if errorCount != 0 || lastError != nil || backoffDelay != time.Second {
+ t.Errorf("Expected initial state to be clean")
+ }
+
+ // Add some errors
+ err := errors.New("test error")
+ manager.handleScanError(err)
+ manager.handleScanError(err)
+
+ errorCount, lastError, backoffDelay = manager.GetErrorState()
+ if errorCount != 2 || lastError != err || backoffDelay != 2*time.Second {
+ t.Errorf("Expected error state to be tracked correctly: count=%d, err=%v, delay=%v",
+ errorCount, lastError, backoffDelay)
+ }
+}
+
+func TestMaintenanceManager_LogThrottling(t *testing.T) {
+ config := DefaultMaintenanceConfig()
+ manager := NewMaintenanceManager(nil, config)
+
+ // This is a basic test to ensure the error handling doesn't panic
+ // In practice, you'd want to capture log output to verify throttling
+ err := errors.New("test error")
+
+ // Generate many errors to test throttling
+ for i := 0; i < 25; i++ {
+ manager.handleScanError(err)
+ }
+
+ // Should not panic and should have capped backoff
+ if manager.backoffDelay > 5*time.Minute {
+ t.Errorf("Expected backoff to be capped at 5 minutes")
+ }
+
+ if manager.errorCount != 25 {
+ t.Errorf("Expected error count to be 25, got %d", manager.errorCount)
+ }
+}
diff --git a/weed/admin/maintenance/maintenance_queue.go b/weed/admin/maintenance/maintenance_queue.go
new file mode 100644
index 000000000..580a98718
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_queue.go
@@ -0,0 +1,500 @@
+package maintenance
+
+import (
+ "sort"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+// NewMaintenanceQueue creates a new maintenance queue
+func NewMaintenanceQueue(policy *MaintenancePolicy) *MaintenanceQueue {
+ queue := &MaintenanceQueue{
+ tasks: make(map[string]*MaintenanceTask),
+ workers: make(map[string]*MaintenanceWorker),
+ pendingTasks: make([]*MaintenanceTask, 0),
+ policy: policy,
+ }
+ return queue
+}
+
+// SetIntegration sets the integration reference
+func (mq *MaintenanceQueue) SetIntegration(integration *MaintenanceIntegration) {
+ mq.integration = integration
+ glog.V(1).Infof("Maintenance queue configured with integration")
+}
+
+// AddTask adds a new maintenance task to the queue
+func (mq *MaintenanceQueue) AddTask(task *MaintenanceTask) {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ task.ID = generateTaskID()
+ task.Status = TaskStatusPending
+ task.CreatedAt = time.Now()
+ task.MaxRetries = 3 // Default retry count
+
+ mq.tasks[task.ID] = task
+ mq.pendingTasks = append(mq.pendingTasks, task)
+
+ // Sort pending tasks by priority and schedule time
+ sort.Slice(mq.pendingTasks, func(i, j int) bool {
+ if mq.pendingTasks[i].Priority != mq.pendingTasks[j].Priority {
+ return mq.pendingTasks[i].Priority > mq.pendingTasks[j].Priority
+ }
+ return mq.pendingTasks[i].ScheduledAt.Before(mq.pendingTasks[j].ScheduledAt)
+ })
+
+ glog.V(2).Infof("Added maintenance task %s: %s for volume %d", task.ID, task.Type, task.VolumeID)
+}
+
+// AddTasksFromResults converts detection results to tasks and adds them to the queue
+func (mq *MaintenanceQueue) AddTasksFromResults(results []*TaskDetectionResult) {
+ for _, result := range results {
+ task := &MaintenanceTask{
+ Type: result.TaskType,
+ Priority: result.Priority,
+ VolumeID: result.VolumeID,
+ Server: result.Server,
+ Collection: result.Collection,
+ Parameters: result.Parameters,
+ Reason: result.Reason,
+ ScheduledAt: result.ScheduleAt,
+ }
+ mq.AddTask(task)
+ }
+}
+
+// GetNextTask returns the next available task for a worker
+func (mq *MaintenanceQueue) GetNextTask(workerID string, capabilities []MaintenanceTaskType) *MaintenanceTask {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ worker, exists := mq.workers[workerID]
+ if !exists {
+ return nil
+ }
+
+ // Check if worker has capacity
+ if worker.CurrentLoad >= worker.MaxConcurrent {
+ return nil
+ }
+
+ now := time.Now()
+
+ // Find the next suitable task
+ for i, task := range mq.pendingTasks {
+ // Check if it's time to execute the task
+ if task.ScheduledAt.After(now) {
+ continue
+ }
+
+ // Check if worker can handle this task type
+ if !mq.workerCanHandle(task.Type, capabilities) {
+ continue
+ }
+
+ // Check scheduling logic - use simplified system if available, otherwise fallback
+ if !mq.canScheduleTaskNow(task) {
+ continue
+ }
+
+ // Assign task to worker
+ task.Status = TaskStatusAssigned
+ task.WorkerID = workerID
+ startTime := now
+ task.StartedAt = &startTime
+
+ // Remove from pending tasks
+ mq.pendingTasks = append(mq.pendingTasks[:i], mq.pendingTasks[i+1:]...)
+
+ // Update worker
+ worker.CurrentTask = task
+ worker.CurrentLoad++
+ worker.Status = "busy"
+
+ glog.V(2).Infof("Assigned task %s to worker %s", task.ID, workerID)
+ return task
+ }
+
+ return nil
+}
+
+// CompleteTask marks a task as completed
+func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ task, exists := mq.tasks[taskID]
+ if !exists {
+ return
+ }
+
+ completedTime := time.Now()
+ task.CompletedAt = &completedTime
+
+ if error != "" {
+ task.Status = TaskStatusFailed
+ task.Error = error
+
+ // Check if task should be retried
+ if task.RetryCount < task.MaxRetries {
+ task.RetryCount++
+ task.Status = TaskStatusPending
+ task.WorkerID = ""
+ task.StartedAt = nil
+ task.CompletedAt = nil
+ task.Error = ""
+ task.ScheduledAt = time.Now().Add(15 * time.Minute) // Retry delay
+
+ mq.pendingTasks = append(mq.pendingTasks, task)
+ glog.V(2).Infof("Retrying task %s (attempt %d/%d)", taskID, task.RetryCount, task.MaxRetries)
+ } else {
+ glog.Errorf("Task %s failed permanently after %d retries: %s", taskID, task.MaxRetries, error)
+ }
+ } else {
+ task.Status = TaskStatusCompleted
+ task.Progress = 100
+ glog.V(2).Infof("Task %s completed successfully", taskID)
+ }
+
+ // Update worker
+ if task.WorkerID != "" {
+ if worker, exists := mq.workers[task.WorkerID]; exists {
+ worker.CurrentTask = nil
+ worker.CurrentLoad--
+ if worker.CurrentLoad == 0 {
+ worker.Status = "active"
+ }
+ }
+ }
+}
+
+// UpdateTaskProgress updates the progress of a running task
+func (mq *MaintenanceQueue) UpdateTaskProgress(taskID string, progress float64) {
+ mq.mutex.RLock()
+ defer mq.mutex.RUnlock()
+
+ if task, exists := mq.tasks[taskID]; exists {
+ task.Progress = progress
+ task.Status = TaskStatusInProgress
+ }
+}
+
+// RegisterWorker registers a new worker
+func (mq *MaintenanceQueue) RegisterWorker(worker *MaintenanceWorker) {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ worker.LastHeartbeat = time.Now()
+ worker.Status = "active"
+ worker.CurrentLoad = 0
+ mq.workers[worker.ID] = worker
+
+ glog.V(1).Infof("Registered maintenance worker %s at %s", worker.ID, worker.Address)
+}
+
+// UpdateWorkerHeartbeat updates worker heartbeat
+func (mq *MaintenanceQueue) UpdateWorkerHeartbeat(workerID string) {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ if worker, exists := mq.workers[workerID]; exists {
+ worker.LastHeartbeat = time.Now()
+ }
+}
+
+// GetRunningTaskCount returns the number of running tasks of a specific type
+func (mq *MaintenanceQueue) GetRunningTaskCount(taskType MaintenanceTaskType) int {
+ mq.mutex.RLock()
+ defer mq.mutex.RUnlock()
+
+ count := 0
+ for _, task := range mq.tasks {
+ if task.Type == taskType && (task.Status == TaskStatusAssigned || task.Status == TaskStatusInProgress) {
+ count++
+ }
+ }
+ return count
+}
+
+// WasTaskRecentlyCompleted checks if a similar task was recently completed
+func (mq *MaintenanceQueue) WasTaskRecentlyCompleted(taskType MaintenanceTaskType, volumeID uint32, server string, now time.Time) bool {
+ mq.mutex.RLock()
+ defer mq.mutex.RUnlock()
+
+ // Get the repeat prevention interval for this task type
+ interval := mq.getRepeatPreventionInterval(taskType)
+ cutoff := now.Add(-interval)
+
+ for _, task := range mq.tasks {
+ if task.Type == taskType &&
+ task.VolumeID == volumeID &&
+ task.Server == server &&
+ task.Status == TaskStatusCompleted &&
+ task.CompletedAt != nil &&
+ task.CompletedAt.After(cutoff) {
+ return true
+ }
+ }
+ return false
+}
+
+// getRepeatPreventionInterval returns the interval for preventing task repetition
+func (mq *MaintenanceQueue) getRepeatPreventionInterval(taskType MaintenanceTaskType) time.Duration {
+ // First try to get default from task scheduler
+ if mq.integration != nil {
+ if scheduler := mq.integration.GetTaskScheduler(taskType); scheduler != nil {
+ defaultInterval := scheduler.GetDefaultRepeatInterval()
+ if defaultInterval > 0 {
+ glog.V(3).Infof("Using task scheduler default repeat interval for %s: %v", taskType, defaultInterval)
+ return defaultInterval
+ }
+ }
+ }
+
+ // Fallback to policy configuration if no scheduler available or scheduler doesn't provide default
+ if mq.policy != nil {
+ repeatIntervalHours := mq.policy.GetRepeatInterval(taskType)
+ if repeatIntervalHours > 0 {
+ interval := time.Duration(repeatIntervalHours) * time.Hour
+ glog.V(3).Infof("Using policy configuration repeat interval for %s: %v", taskType, interval)
+ return interval
+ }
+ }
+
+ // Ultimate fallback - but avoid hardcoded values where possible
+ glog.V(2).Infof("No scheduler or policy configuration found for task type %s, using minimal default: 1h", taskType)
+ return time.Hour // Minimal safe default
+}
+
+// GetTasks returns tasks with optional filtering
+func (mq *MaintenanceQueue) GetTasks(status MaintenanceTaskStatus, taskType MaintenanceTaskType, limit int) []*MaintenanceTask {
+ mq.mutex.RLock()
+ defer mq.mutex.RUnlock()
+
+ var tasks []*MaintenanceTask
+ for _, task := range mq.tasks {
+ if status != "" && task.Status != status {
+ continue
+ }
+ if taskType != "" && task.Type != taskType {
+ continue
+ }
+ tasks = append(tasks, task)
+ if limit > 0 && len(tasks) >= limit {
+ break
+ }
+ }
+
+ // Sort by creation time (newest first)
+ sort.Slice(tasks, func(i, j int) bool {
+ return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
+ })
+
+ return tasks
+}
+
+// GetWorkers returns all registered workers
+func (mq *MaintenanceQueue) GetWorkers() []*MaintenanceWorker {
+ mq.mutex.RLock()
+ defer mq.mutex.RUnlock()
+
+ var workers []*MaintenanceWorker
+ for _, worker := range mq.workers {
+ workers = append(workers, worker)
+ }
+ return workers
+}
+
+// generateTaskID generates a unique ID for tasks
+func generateTaskID() string {
+ const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
+ b := make([]byte, 8)
+ for i := range b {
+ b[i] = charset[i%len(charset)]
+ }
+ return string(b)
+}
+
+// CleanupOldTasks removes old completed and failed tasks
+func (mq *MaintenanceQueue) CleanupOldTasks(retention time.Duration) int {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ cutoff := time.Now().Add(-retention)
+ removed := 0
+
+ for id, task := range mq.tasks {
+ if (task.Status == TaskStatusCompleted || task.Status == TaskStatusFailed) &&
+ task.CompletedAt != nil &&
+ task.CompletedAt.Before(cutoff) {
+ delete(mq.tasks, id)
+ removed++
+ }
+ }
+
+ glog.V(2).Infof("Cleaned up %d old maintenance tasks", removed)
+ return removed
+}
+
+// RemoveStaleWorkers removes workers that haven't sent heartbeat recently
+func (mq *MaintenanceQueue) RemoveStaleWorkers(timeout time.Duration) int {
+ mq.mutex.Lock()
+ defer mq.mutex.Unlock()
+
+ cutoff := time.Now().Add(-timeout)
+ removed := 0
+
+ for id, worker := range mq.workers {
+ if worker.LastHeartbeat.Before(cutoff) {
+ // Mark any assigned tasks as failed
+ for _, task := range mq.tasks {
+ if task.WorkerID == id && (task.Status == TaskStatusAssigned || task.Status == TaskStatusInProgress) {
+ task.Status = TaskStatusFailed
+ task.Error = "Worker became unavailable"
+ completedTime := time.Now()
+ task.CompletedAt = &completedTime
+ }
+ }
+
+ delete(mq.workers, id)
+ removed++
+ glog.Warningf("Removed stale maintenance worker %s", id)
+ }
+ }
+
+ return removed
+}
+
+// GetStats returns maintenance statistics
+func (mq *MaintenanceQueue) GetStats() *MaintenanceStats {
+ mq.mutex.RLock()
+ defer mq.mutex.RUnlock()
+
+ stats := &MaintenanceStats{
+ TotalTasks: len(mq.tasks),
+ TasksByStatus: make(map[MaintenanceTaskStatus]int),
+ TasksByType: make(map[MaintenanceTaskType]int),
+ ActiveWorkers: 0,
+ }
+
+ today := time.Now().Truncate(24 * time.Hour)
+ var totalDuration time.Duration
+ var completedTasks int
+
+ for _, task := range mq.tasks {
+ stats.TasksByStatus[task.Status]++
+ stats.TasksByType[task.Type]++
+
+ if task.CompletedAt != nil && task.CompletedAt.After(today) {
+ if task.Status == TaskStatusCompleted {
+ stats.CompletedToday++
+ } else if task.Status == TaskStatusFailed {
+ stats.FailedToday++
+ }
+
+ if task.StartedAt != nil {
+ duration := task.CompletedAt.Sub(*task.StartedAt)
+ totalDuration += duration
+ completedTasks++
+ }
+ }
+ }
+
+ for _, worker := range mq.workers {
+ if worker.Status == "active" || worker.Status == "busy" {
+ stats.ActiveWorkers++
+ }
+ }
+
+ if completedTasks > 0 {
+ stats.AverageTaskTime = totalDuration / time.Duration(completedTasks)
+ }
+
+ return stats
+}
+
+// workerCanHandle checks if a worker can handle a specific task type
+func (mq *MaintenanceQueue) workerCanHandle(taskType MaintenanceTaskType, capabilities []MaintenanceTaskType) bool {
+ for _, capability := range capabilities {
+ if capability == taskType {
+ return true
+ }
+ }
+ return false
+}
+
+// canScheduleTaskNow determines if a task can be scheduled using task schedulers or fallback logic
+func (mq *MaintenanceQueue) canScheduleTaskNow(task *MaintenanceTask) bool {
+ // Try task scheduling logic first
+ if mq.integration != nil {
+ // Get all running tasks and available workers
+ runningTasks := mq.getRunningTasks()
+ availableWorkers := mq.getAvailableWorkers()
+
+ canSchedule := mq.integration.CanScheduleWithTaskSchedulers(task, runningTasks, availableWorkers)
+ glog.V(3).Infof("Task scheduler decision for task %s (%s): %v", task.ID, task.Type, canSchedule)
+ return canSchedule
+ }
+
+ // Fallback to hardcoded logic
+ return mq.canExecuteTaskType(task.Type)
+}
+
+// canExecuteTaskType checks if we can execute more tasks of this type (concurrency limits) - fallback logic
+func (mq *MaintenanceQueue) canExecuteTaskType(taskType MaintenanceTaskType) bool {
+ runningCount := mq.GetRunningTaskCount(taskType)
+ maxConcurrent := mq.getMaxConcurrentForTaskType(taskType)
+
+ return runningCount < maxConcurrent
+}
+
+// getMaxConcurrentForTaskType returns the maximum concurrent tasks allowed for a task type
+func (mq *MaintenanceQueue) getMaxConcurrentForTaskType(taskType MaintenanceTaskType) int {
+ // First try to get default from task scheduler
+ if mq.integration != nil {
+ if scheduler := mq.integration.GetTaskScheduler(taskType); scheduler != nil {
+ maxConcurrent := scheduler.GetMaxConcurrent()
+ if maxConcurrent > 0 {
+ glog.V(3).Infof("Using task scheduler max concurrent for %s: %d", taskType, maxConcurrent)
+ return maxConcurrent
+ }
+ }
+ }
+
+ // Fallback to policy configuration if no scheduler available or scheduler doesn't provide default
+ if mq.policy != nil {
+ maxConcurrent := mq.policy.GetMaxConcurrent(taskType)
+ if maxConcurrent > 0 {
+ glog.V(3).Infof("Using policy configuration max concurrent for %s: %d", taskType, maxConcurrent)
+ return maxConcurrent
+ }
+ }
+
+ // Ultimate fallback - minimal safe default
+ glog.V(2).Infof("No scheduler or policy configuration found for task type %s, using minimal default: 1", taskType)
+ return 1
+}
+
+// getRunningTasks returns all currently running tasks
+func (mq *MaintenanceQueue) getRunningTasks() []*MaintenanceTask {
+ var runningTasks []*MaintenanceTask
+ for _, task := range mq.tasks {
+ if task.Status == TaskStatusAssigned || task.Status == TaskStatusInProgress {
+ runningTasks = append(runningTasks, task)
+ }
+ }
+ return runningTasks
+}
+
+// getAvailableWorkers returns all workers that can take more work
+func (mq *MaintenanceQueue) getAvailableWorkers() []*MaintenanceWorker {
+ var availableWorkers []*MaintenanceWorker
+ for _, worker := range mq.workers {
+ if worker.Status == "active" && worker.CurrentLoad < worker.MaxConcurrent {
+ availableWorkers = append(availableWorkers, worker)
+ }
+ }
+ return availableWorkers
+}
diff --git a/weed/admin/maintenance/maintenance_scanner.go b/weed/admin/maintenance/maintenance_scanner.go
new file mode 100644
index 000000000..4d7cda125
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_scanner.go
@@ -0,0 +1,163 @@
+package maintenance
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// NewMaintenanceScanner creates a new maintenance scanner
+func NewMaintenanceScanner(adminClient AdminClient, policy *MaintenancePolicy, queue *MaintenanceQueue) *MaintenanceScanner {
+ scanner := &MaintenanceScanner{
+ adminClient: adminClient,
+ policy: policy,
+ queue: queue,
+ lastScan: make(map[MaintenanceTaskType]time.Time),
+ }
+
+ // Initialize integration
+ scanner.integration = NewMaintenanceIntegration(queue, policy)
+
+ // Set up bidirectional relationship
+ queue.SetIntegration(scanner.integration)
+
+ glog.V(1).Infof("Initialized maintenance scanner with task system")
+
+ return scanner
+}
+
+// ScanForMaintenanceTasks analyzes the cluster and generates maintenance tasks
+func (ms *MaintenanceScanner) ScanForMaintenanceTasks() ([]*TaskDetectionResult, error) {
+ // Get volume health metrics
+ volumeMetrics, err := ms.getVolumeHealthMetrics()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get volume health metrics: %v", err)
+ }
+
+ // Use task system for all task types
+ if ms.integration != nil {
+ // Convert metrics to task system format
+ taskMetrics := ms.convertToTaskMetrics(volumeMetrics)
+
+ // Use task detection system
+ results, err := ms.integration.ScanWithTaskDetectors(taskMetrics)
+ if err != nil {
+ glog.Errorf("Task scanning failed: %v", err)
+ return nil, err
+ }
+
+ glog.V(1).Infof("Maintenance scan completed: found %d tasks", len(results))
+ return results, nil
+ }
+
+ // No integration available
+ glog.Warningf("No integration available, no tasks will be scheduled")
+ return []*TaskDetectionResult{}, nil
+}
+
+// getVolumeHealthMetrics collects health information for all volumes
+func (ms *MaintenanceScanner) getVolumeHealthMetrics() ([]*VolumeHealthMetrics, error) {
+ var metrics []*VolumeHealthMetrics
+
+ err := ms.adminClient.WithMasterClient(func(client master_pb.SeaweedClient) error {
+ resp, err := client.VolumeList(context.Background(), &master_pb.VolumeListRequest{})
+ if err != nil {
+ return err
+ }
+
+ if resp.TopologyInfo == nil {
+ return nil
+ }
+
+ for _, dc := range resp.TopologyInfo.DataCenterInfos {
+ for _, rack := range dc.RackInfos {
+ for _, node := range rack.DataNodeInfos {
+ for _, diskInfo := range node.DiskInfos {
+ for _, volInfo := range diskInfo.VolumeInfos {
+ metric := &VolumeHealthMetrics{
+ VolumeID: volInfo.Id,
+ Server: node.Id,
+ Collection: volInfo.Collection,
+ Size: volInfo.Size,
+ DeletedBytes: volInfo.DeletedByteCount,
+ LastModified: time.Unix(int64(volInfo.ModifiedAtSecond), 0),
+ IsReadOnly: volInfo.ReadOnly,
+ IsECVolume: false, // Will be determined from volume structure
+ ReplicaCount: 1, // Will be counted
+ ExpectedReplicas: int(volInfo.ReplicaPlacement),
+ }
+
+ // Calculate derived metrics
+ if metric.Size > 0 {
+ metric.GarbageRatio = float64(metric.DeletedBytes) / float64(metric.Size)
+ // Calculate fullness ratio (would need volume size limit)
+ // metric.FullnessRatio = float64(metric.Size) / float64(volumeSizeLimit)
+ }
+ metric.Age = time.Since(metric.LastModified)
+
+ metrics = append(metrics, metric)
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Count actual replicas and identify EC volumes
+ ms.enrichVolumeMetrics(metrics)
+
+ return metrics, nil
+}
+
+// enrichVolumeMetrics adds additional information like replica counts
+func (ms *MaintenanceScanner) enrichVolumeMetrics(metrics []*VolumeHealthMetrics) {
+ // Group volumes by ID to count replicas
+ volumeGroups := make(map[uint32][]*VolumeHealthMetrics)
+ for _, metric := range metrics {
+ volumeGroups[metric.VolumeID] = append(volumeGroups[metric.VolumeID], metric)
+ }
+
+ // Update replica counts
+ for _, group := range volumeGroups {
+ actualReplicas := len(group)
+ for _, metric := range group {
+ metric.ReplicaCount = actualReplicas
+ }
+ }
+}
+
+// convertToTaskMetrics converts existing volume metrics to task system format
+func (ms *MaintenanceScanner) convertToTaskMetrics(metrics []*VolumeHealthMetrics) []*types.VolumeHealthMetrics {
+ var simplified []*types.VolumeHealthMetrics
+
+ for _, metric := range metrics {
+ simplified = append(simplified, &types.VolumeHealthMetrics{
+ VolumeID: metric.VolumeID,
+ Server: metric.Server,
+ Collection: metric.Collection,
+ Size: metric.Size,
+ DeletedBytes: metric.DeletedBytes,
+ GarbageRatio: metric.GarbageRatio,
+ LastModified: metric.LastModified,
+ Age: metric.Age,
+ ReplicaCount: metric.ReplicaCount,
+ ExpectedReplicas: metric.ExpectedReplicas,
+ IsReadOnly: metric.IsReadOnly,
+ HasRemoteCopy: metric.HasRemoteCopy,
+ IsECVolume: metric.IsECVolume,
+ FullnessRatio: metric.FullnessRatio,
+ })
+ }
+
+ return simplified
+}
diff --git a/weed/admin/maintenance/maintenance_types.go b/weed/admin/maintenance/maintenance_types.go
new file mode 100644
index 000000000..6b8c2e9a0
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_types.go
@@ -0,0 +1,560 @@
+package maintenance
+
+import (
+ "html/template"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// AdminClient interface defines what the maintenance system needs from the admin server
+type AdminClient interface {
+ WithMasterClient(fn func(client master_pb.SeaweedClient) error) error
+}
+
+// MaintenanceTaskType represents different types of maintenance operations
+type MaintenanceTaskType string
+
+// GetRegisteredMaintenanceTaskTypes returns all registered task types as MaintenanceTaskType values
+// sorted alphabetically for consistent menu ordering
+func GetRegisteredMaintenanceTaskTypes() []MaintenanceTaskType {
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+ var taskTypes []MaintenanceTaskType
+
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ maintenanceTaskType := MaintenanceTaskType(string(workerTaskType))
+ taskTypes = append(taskTypes, maintenanceTaskType)
+ }
+
+ // Sort task types alphabetically to ensure consistent menu ordering
+ sort.Slice(taskTypes, func(i, j int) bool {
+ return string(taskTypes[i]) < string(taskTypes[j])
+ })
+
+ return taskTypes
+}
+
+// GetMaintenanceTaskType returns a specific task type if it's registered, or empty string if not found
+func GetMaintenanceTaskType(taskTypeName string) MaintenanceTaskType {
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == taskTypeName {
+ return MaintenanceTaskType(taskTypeName)
+ }
+ }
+
+ return MaintenanceTaskType("")
+}
+
+// IsMaintenanceTaskTypeRegistered checks if a task type is registered
+func IsMaintenanceTaskTypeRegistered(taskType MaintenanceTaskType) bool {
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// MaintenanceTaskPriority represents task execution priority
+type MaintenanceTaskPriority int
+
+const (
+ PriorityLow MaintenanceTaskPriority = iota
+ PriorityNormal
+ PriorityHigh
+ PriorityCritical
+)
+
+// MaintenanceTaskStatus represents the current status of a task
+type MaintenanceTaskStatus string
+
+const (
+ TaskStatusPending MaintenanceTaskStatus = "pending"
+ TaskStatusAssigned MaintenanceTaskStatus = "assigned"
+ TaskStatusInProgress MaintenanceTaskStatus = "in_progress"
+ TaskStatusCompleted MaintenanceTaskStatus = "completed"
+ TaskStatusFailed MaintenanceTaskStatus = "failed"
+ TaskStatusCancelled MaintenanceTaskStatus = "cancelled"
+)
+
+// MaintenanceTask represents a single maintenance operation
+type MaintenanceTask struct {
+ ID string `json:"id"`
+ Type MaintenanceTaskType `json:"type"`
+ Priority MaintenanceTaskPriority `json:"priority"`
+ Status MaintenanceTaskStatus `json:"status"`
+ VolumeID uint32 `json:"volume_id,omitempty"`
+ Server string `json:"server,omitempty"`
+ Collection string `json:"collection,omitempty"`
+ Parameters map[string]interface{} `json:"parameters,omitempty"`
+ Reason string `json:"reason"`
+ CreatedAt time.Time `json:"created_at"`
+ ScheduledAt time.Time `json:"scheduled_at"`
+ StartedAt *time.Time `json:"started_at,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ WorkerID string `json:"worker_id,omitempty"`
+ Error string `json:"error,omitempty"`
+ Progress float64 `json:"progress"` // 0-100
+ RetryCount int `json:"retry_count"`
+ MaxRetries int `json:"max_retries"`
+}
+
+// TaskPolicy represents configuration for a specific task type
+type TaskPolicy struct {
+ Enabled bool `json:"enabled"`
+ MaxConcurrent int `json:"max_concurrent"`
+ RepeatInterval int `json:"repeat_interval"` // Hours to wait before repeating
+ CheckInterval int `json:"check_interval"` // Hours between checks
+ Configuration map[string]interface{} `json:"configuration"` // Task-specific config
+}
+
+// MaintenancePolicy defines policies for maintenance operations using a dynamic structure
+type MaintenancePolicy struct {
+ // Task-specific policies mapped by task type
+ TaskPolicies map[MaintenanceTaskType]*TaskPolicy `json:"task_policies"`
+
+ // Global policy settings
+ GlobalMaxConcurrent int `json:"global_max_concurrent"` // Overall limit across all task types
+ DefaultRepeatInterval int `json:"default_repeat_interval"` // Default hours if task doesn't specify
+ DefaultCheckInterval int `json:"default_check_interval"` // Default hours for periodic checks
+}
+
+// GetTaskPolicy returns the policy for a specific task type, creating generic defaults if needed
+func (mp *MaintenancePolicy) GetTaskPolicy(taskType MaintenanceTaskType) *TaskPolicy {
+ if mp.TaskPolicies == nil {
+ mp.TaskPolicies = make(map[MaintenanceTaskType]*TaskPolicy)
+ }
+
+ policy, exists := mp.TaskPolicies[taskType]
+ if !exists {
+ // Create generic default policy using global settings - no hardcoded fallbacks
+ policy = &TaskPolicy{
+ Enabled: false, // Conservative default - require explicit enabling
+ MaxConcurrent: 1, // Conservative default concurrency
+ RepeatInterval: mp.DefaultRepeatInterval, // Use configured default, 0 if not set
+ CheckInterval: mp.DefaultCheckInterval, // Use configured default, 0 if not set
+ Configuration: make(map[string]interface{}),
+ }
+ mp.TaskPolicies[taskType] = policy
+ }
+
+ return policy
+}
+
+// SetTaskPolicy sets the policy for a specific task type
+func (mp *MaintenancePolicy) SetTaskPolicy(taskType MaintenanceTaskType, policy *TaskPolicy) {
+ if mp.TaskPolicies == nil {
+ mp.TaskPolicies = make(map[MaintenanceTaskType]*TaskPolicy)
+ }
+ mp.TaskPolicies[taskType] = policy
+}
+
+// IsTaskEnabled returns whether a task type is enabled
+func (mp *MaintenancePolicy) IsTaskEnabled(taskType MaintenanceTaskType) bool {
+ policy := mp.GetTaskPolicy(taskType)
+ return policy.Enabled
+}
+
+// GetMaxConcurrent returns the max concurrent limit for a task type
+func (mp *MaintenancePolicy) GetMaxConcurrent(taskType MaintenanceTaskType) int {
+ policy := mp.GetTaskPolicy(taskType)
+ return policy.MaxConcurrent
+}
+
+// GetRepeatInterval returns the repeat interval for a task type
+func (mp *MaintenancePolicy) GetRepeatInterval(taskType MaintenanceTaskType) int {
+ policy := mp.GetTaskPolicy(taskType)
+ return policy.RepeatInterval
+}
+
+// GetTaskConfig returns a configuration value for a task type
+func (mp *MaintenancePolicy) GetTaskConfig(taskType MaintenanceTaskType, key string) (interface{}, bool) {
+ policy := mp.GetTaskPolicy(taskType)
+ value, exists := policy.Configuration[key]
+ return value, exists
+}
+
+// SetTaskConfig sets a configuration value for a task type
+func (mp *MaintenancePolicy) SetTaskConfig(taskType MaintenanceTaskType, key string, value interface{}) {
+ policy := mp.GetTaskPolicy(taskType)
+ if policy.Configuration == nil {
+ policy.Configuration = make(map[string]interface{})
+ }
+ policy.Configuration[key] = value
+}
+
+// MaintenanceWorker represents a worker instance
+type MaintenanceWorker struct {
+ ID string `json:"id"`
+ Address string `json:"address"`
+ LastHeartbeat time.Time `json:"last_heartbeat"`
+ Status string `json:"status"` // active, inactive, busy
+ CurrentTask *MaintenanceTask `json:"current_task,omitempty"`
+ Capabilities []MaintenanceTaskType `json:"capabilities"`
+ MaxConcurrent int `json:"max_concurrent"`
+ CurrentLoad int `json:"current_load"`
+}
+
+// MaintenanceQueue manages the task queue and worker coordination
+type MaintenanceQueue struct {
+ tasks map[string]*MaintenanceTask
+ workers map[string]*MaintenanceWorker
+ pendingTasks []*MaintenanceTask
+ mutex sync.RWMutex
+ policy *MaintenancePolicy
+ integration *MaintenanceIntegration
+}
+
+// MaintenanceScanner analyzes the cluster and generates maintenance tasks
+type MaintenanceScanner struct {
+ adminClient AdminClient
+ policy *MaintenancePolicy
+ queue *MaintenanceQueue
+ lastScan map[MaintenanceTaskType]time.Time
+ integration *MaintenanceIntegration
+}
+
+// TaskDetectionResult represents the result of scanning for maintenance needs
+type TaskDetectionResult struct {
+ TaskType MaintenanceTaskType `json:"task_type"`
+ VolumeID uint32 `json:"volume_id,omitempty"`
+ Server string `json:"server,omitempty"`
+ Collection string `json:"collection,omitempty"`
+ Priority MaintenanceTaskPriority `json:"priority"`
+ Reason string `json:"reason"`
+ Parameters map[string]interface{} `json:"parameters,omitempty"`
+ ScheduleAt time.Time `json:"schedule_at"`
+}
+
+// VolumeHealthMetrics contains health information about a volume
+type VolumeHealthMetrics struct {
+ VolumeID uint32 `json:"volume_id"`
+ Server string `json:"server"`
+ Collection string `json:"collection"`
+ Size uint64 `json:"size"`
+ DeletedBytes uint64 `json:"deleted_bytes"`
+ GarbageRatio float64 `json:"garbage_ratio"`
+ LastModified time.Time `json:"last_modified"`
+ Age time.Duration `json:"age"`
+ ReplicaCount int `json:"replica_count"`
+ ExpectedReplicas int `json:"expected_replicas"`
+ IsReadOnly bool `json:"is_read_only"`
+ HasRemoteCopy bool `json:"has_remote_copy"`
+ IsECVolume bool `json:"is_ec_volume"`
+ FullnessRatio float64 `json:"fullness_ratio"`
+}
+
+// MaintenanceStats provides statistics about maintenance operations
+type MaintenanceStats struct {
+ TotalTasks int `json:"total_tasks"`
+ TasksByStatus map[MaintenanceTaskStatus]int `json:"tasks_by_status"`
+ TasksByType map[MaintenanceTaskType]int `json:"tasks_by_type"`
+ ActiveWorkers int `json:"active_workers"`
+ CompletedToday int `json:"completed_today"`
+ FailedToday int `json:"failed_today"`
+ AverageTaskTime time.Duration `json:"average_task_time"`
+ LastScanTime time.Time `json:"last_scan_time"`
+ NextScanTime time.Time `json:"next_scan_time"`
+}
+
+// MaintenanceConfig holds configuration for the maintenance system
+type MaintenanceConfig struct {
+ Enabled bool `json:"enabled"`
+ ScanIntervalSeconds int `json:"scan_interval_seconds"` // How often to scan for maintenance needs (in seconds)
+ WorkerTimeoutSeconds int `json:"worker_timeout_seconds"` // Worker heartbeat timeout (in seconds)
+ TaskTimeoutSeconds int `json:"task_timeout_seconds"` // Individual task timeout (in seconds)
+ RetryDelaySeconds int `json:"retry_delay_seconds"` // Delay between retries (in seconds)
+ MaxRetries int `json:"max_retries"` // Default max retries for tasks
+ CleanupIntervalSeconds int `json:"cleanup_interval_seconds"` // How often to clean up old tasks (in seconds)
+ TaskRetentionSeconds int `json:"task_retention_seconds"` // How long to keep completed/failed tasks (in seconds)
+ Policy *MaintenancePolicy `json:"policy"`
+}
+
+// Default configuration values
+func DefaultMaintenanceConfig() *MaintenanceConfig {
+ return &MaintenanceConfig{
+ Enabled: false, // Disabled by default for safety
+ ScanIntervalSeconds: 30 * 60, // 30 minutes
+ WorkerTimeoutSeconds: 5 * 60, // 5 minutes
+ TaskTimeoutSeconds: 2 * 60 * 60, // 2 hours
+ RetryDelaySeconds: 15 * 60, // 15 minutes
+ MaxRetries: 3,
+ CleanupIntervalSeconds: 24 * 60 * 60, // 24 hours
+ TaskRetentionSeconds: 7 * 24 * 60 * 60, // 7 days
+ Policy: &MaintenancePolicy{
+ GlobalMaxConcurrent: 4,
+ DefaultRepeatInterval: 6,
+ DefaultCheckInterval: 12,
+ },
+ }
+}
+
+// MaintenanceQueueData represents data for the queue visualization UI
+type MaintenanceQueueData struct {
+ Tasks []*MaintenanceTask `json:"tasks"`
+ Workers []*MaintenanceWorker `json:"workers"`
+ Stats *QueueStats `json:"stats"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// QueueStats provides statistics for the queue UI
+type QueueStats struct {
+ PendingTasks int `json:"pending_tasks"`
+ RunningTasks int `json:"running_tasks"`
+ CompletedToday int `json:"completed_today"`
+ FailedToday int `json:"failed_today"`
+ TotalTasks int `json:"total_tasks"`
+}
+
+// MaintenanceConfigData represents configuration data for the UI
+type MaintenanceConfigData struct {
+ Config *MaintenanceConfig `json:"config"`
+ IsEnabled bool `json:"is_enabled"`
+ LastScanTime time.Time `json:"last_scan_time"`
+ NextScanTime time.Time `json:"next_scan_time"`
+ SystemStats *MaintenanceStats `json:"system_stats"`
+ MenuItems []*MaintenanceMenuItem `json:"menu_items"`
+}
+
+// MaintenanceMenuItem represents a menu item for task configuration
+type MaintenanceMenuItem struct {
+ TaskType MaintenanceTaskType `json:"task_type"`
+ DisplayName string `json:"display_name"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ IsEnabled bool `json:"is_enabled"`
+ Path string `json:"path"`
+}
+
+// WorkerDetailsData represents detailed worker information
+type WorkerDetailsData struct {
+ Worker *MaintenanceWorker `json:"worker"`
+ CurrentTasks []*MaintenanceTask `json:"current_tasks"`
+ RecentTasks []*MaintenanceTask `json:"recent_tasks"`
+ Performance *WorkerPerformance `json:"performance"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// WorkerPerformance tracks worker performance metrics
+type WorkerPerformance struct {
+ TasksCompleted int `json:"tasks_completed"`
+ TasksFailed int `json:"tasks_failed"`
+ AverageTaskTime time.Duration `json:"average_task_time"`
+ Uptime time.Duration `json:"uptime"`
+ SuccessRate float64 `json:"success_rate"`
+}
+
+// TaskConfigData represents data for individual task configuration page
+type TaskConfigData struct {
+ TaskType MaintenanceTaskType `json:"task_type"`
+ TaskName string `json:"task_name"`
+ TaskIcon string `json:"task_icon"`
+ Description string `json:"description"`
+ ConfigFormHTML template.HTML `json:"config_form_html"`
+}
+
+// ClusterReplicationTask represents a cluster replication task parameters
+type ClusterReplicationTask struct {
+ SourcePath string `json:"source_path"`
+ TargetCluster string `json:"target_cluster"`
+ TargetPath string `json:"target_path"`
+ ReplicationMode string `json:"replication_mode"` // "sync", "async", "backup"
+ Priority int `json:"priority"`
+ Checksum string `json:"checksum,omitempty"`
+ FileSize int64 `json:"file_size"`
+ CreatedAt time.Time `json:"created_at"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// BuildMaintenancePolicyFromTasks creates a maintenance policy with configurations
+// from all registered tasks using their UI providers
+func BuildMaintenancePolicyFromTasks() *MaintenancePolicy {
+ policy := &MaintenancePolicy{
+ TaskPolicies: make(map[MaintenanceTaskType]*TaskPolicy),
+ GlobalMaxConcurrent: 4,
+ DefaultRepeatInterval: 6,
+ DefaultCheckInterval: 12,
+ }
+
+ // Get all registered task types from the UI registry
+ uiRegistry := tasks.GetGlobalUIRegistry()
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+
+ for taskType, provider := range uiRegistry.GetAllProviders() {
+ // Convert task type to maintenance task type
+ maintenanceTaskType := MaintenanceTaskType(string(taskType))
+
+ // Get the default configuration from the UI provider
+ defaultConfig := provider.GetCurrentConfig()
+
+ // Create task policy from UI configuration
+ taskPolicy := &TaskPolicy{
+ Enabled: true, // Default enabled
+ MaxConcurrent: 2, // Default concurrency
+ RepeatInterval: policy.DefaultRepeatInterval,
+ CheckInterval: policy.DefaultCheckInterval,
+ Configuration: make(map[string]interface{}),
+ }
+
+ // Extract configuration from UI provider's config
+ if configMap, ok := defaultConfig.(map[string]interface{}); ok {
+ // Copy all configuration values
+ for key, value := range configMap {
+ taskPolicy.Configuration[key] = value
+ }
+
+ // Extract common fields
+ if enabled, exists := configMap["enabled"]; exists {
+ if enabledBool, ok := enabled.(bool); ok {
+ taskPolicy.Enabled = enabledBool
+ }
+ }
+ if maxConcurrent, exists := configMap["max_concurrent"]; exists {
+ if maxConcurrentInt, ok := maxConcurrent.(int); ok {
+ taskPolicy.MaxConcurrent = maxConcurrentInt
+ } else if maxConcurrentFloat, ok := maxConcurrent.(float64); ok {
+ taskPolicy.MaxConcurrent = int(maxConcurrentFloat)
+ }
+ }
+ }
+
+ // Also get defaults from scheduler if available (using types.TaskScheduler explicitly)
+ var scheduler types.TaskScheduler = typesRegistry.GetScheduler(taskType)
+ if scheduler != nil {
+ if taskPolicy.MaxConcurrent <= 0 {
+ taskPolicy.MaxConcurrent = scheduler.GetMaxConcurrent()
+ }
+ // Convert default repeat interval to hours
+ if repeatInterval := scheduler.GetDefaultRepeatInterval(); repeatInterval > 0 {
+ taskPolicy.RepeatInterval = int(repeatInterval.Hours())
+ }
+ }
+
+ // Also get defaults from detector if available (using types.TaskDetector explicitly)
+ var detector types.TaskDetector = typesRegistry.GetDetector(taskType)
+ if detector != nil {
+ // Convert scan interval to check interval (hours)
+ if scanInterval := detector.ScanInterval(); scanInterval > 0 {
+ taskPolicy.CheckInterval = int(scanInterval.Hours())
+ }
+ }
+
+ policy.TaskPolicies[maintenanceTaskType] = taskPolicy
+ glog.V(3).Infof("Built policy for task type %s: enabled=%v, max_concurrent=%d",
+ maintenanceTaskType, taskPolicy.Enabled, taskPolicy.MaxConcurrent)
+ }
+
+ glog.V(2).Infof("Built maintenance policy with %d task configurations", len(policy.TaskPolicies))
+ return policy
+}
+
+// SetPolicyFromTasks sets the maintenance policy from registered tasks
+func SetPolicyFromTasks(policy *MaintenancePolicy) {
+ if policy == nil {
+ return
+ }
+
+ // Build new policy from tasks
+ newPolicy := BuildMaintenancePolicyFromTasks()
+
+ // Copy task policies
+ policy.TaskPolicies = newPolicy.TaskPolicies
+
+ glog.V(1).Infof("Updated maintenance policy with %d task configurations from registered tasks", len(policy.TaskPolicies))
+}
+
+// GetTaskIcon returns the icon CSS class for a task type from its UI provider
+func GetTaskIcon(taskType MaintenanceTaskType) string {
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+ uiRegistry := tasks.GetGlobalUIRegistry()
+
+ // Convert MaintenanceTaskType to TaskType
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ // Get the UI provider for this task type
+ provider := uiRegistry.GetProvider(workerTaskType)
+ if provider != nil {
+ return provider.GetIcon()
+ }
+ break
+ }
+ }
+
+ // Default icon if no UI provider found
+ return "fas fa-cog text-muted"
+}
+
+// GetTaskDisplayName returns the display name for a task type from its UI provider
+func GetTaskDisplayName(taskType MaintenanceTaskType) string {
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+ uiRegistry := tasks.GetGlobalUIRegistry()
+
+ // Convert MaintenanceTaskType to TaskType
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ // Get the UI provider for this task type
+ provider := uiRegistry.GetProvider(workerTaskType)
+ if provider != nil {
+ return provider.GetDisplayName()
+ }
+ break
+ }
+ }
+
+ // Fallback to the task type string
+ return string(taskType)
+}
+
+// GetTaskDescription returns the description for a task type from its UI provider
+func GetTaskDescription(taskType MaintenanceTaskType) string {
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+ uiRegistry := tasks.GetGlobalUIRegistry()
+
+ // Convert MaintenanceTaskType to TaskType
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ if string(workerTaskType) == string(taskType) {
+ // Get the UI provider for this task type
+ provider := uiRegistry.GetProvider(workerTaskType)
+ if provider != nil {
+ return provider.GetDescription()
+ }
+ break
+ }
+ }
+
+ // Fallback to a generic description
+ return "Configure detailed settings for " + string(taskType) + " tasks."
+}
+
+// BuildMaintenanceMenuItems creates menu items for all registered task types
+func BuildMaintenanceMenuItems() []*MaintenanceMenuItem {
+ var menuItems []*MaintenanceMenuItem
+
+ // Get all registered task types
+ registeredTypes := GetRegisteredMaintenanceTaskTypes()
+
+ for _, taskType := range registeredTypes {
+ menuItem := &MaintenanceMenuItem{
+ TaskType: taskType,
+ DisplayName: GetTaskDisplayName(taskType),
+ Description: GetTaskDescription(taskType),
+ Icon: GetTaskIcon(taskType),
+ IsEnabled: IsMaintenanceTaskTypeRegistered(taskType),
+ Path: "/maintenance/config/" + string(taskType),
+ }
+
+ menuItems = append(menuItems, menuItem)
+ }
+
+ return menuItems
+}
diff --git a/weed/admin/maintenance/maintenance_worker.go b/weed/admin/maintenance/maintenance_worker.go
new file mode 100644
index 000000000..8a87a8403
--- /dev/null
+++ b/weed/admin/maintenance/maintenance_worker.go
@@ -0,0 +1,413 @@
+package maintenance
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+
+ // Import task packages to trigger their auto-registration
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
+)
+
+// MaintenanceWorkerService manages maintenance task execution
+// TaskExecutor defines the function signature for task execution
+type TaskExecutor func(*MaintenanceWorkerService, *MaintenanceTask) error
+
+// TaskExecutorFactory creates a task executor for a given worker service
+type TaskExecutorFactory func() TaskExecutor
+
+// Global registry for task executor factories
+var taskExecutorFactories = make(map[MaintenanceTaskType]TaskExecutorFactory)
+var executorRegistryMutex sync.RWMutex
+var executorRegistryInitOnce sync.Once
+
+// initializeExecutorFactories dynamically registers executor factories for all auto-registered task types
+func initializeExecutorFactories() {
+ executorRegistryInitOnce.Do(func() {
+ // Get all registered task types from the global registry
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+
+ var taskTypes []MaintenanceTaskType
+ for workerTaskType := range typesRegistry.GetAllDetectors() {
+ // Convert types.TaskType to MaintenanceTaskType by string conversion
+ maintenanceTaskType := MaintenanceTaskType(string(workerTaskType))
+ taskTypes = append(taskTypes, maintenanceTaskType)
+ }
+
+ // Register generic executor for all task types
+ for _, taskType := range taskTypes {
+ RegisterTaskExecutorFactory(taskType, createGenericTaskExecutor)
+ }
+
+ glog.V(1).Infof("Dynamically registered generic task executor for %d task types: %v", len(taskTypes), taskTypes)
+ })
+}
+
+// RegisterTaskExecutorFactory registers a factory function for creating task executors
+func RegisterTaskExecutorFactory(taskType MaintenanceTaskType, factory TaskExecutorFactory) {
+ executorRegistryMutex.Lock()
+ defer executorRegistryMutex.Unlock()
+ taskExecutorFactories[taskType] = factory
+ glog.V(2).Infof("Registered executor factory for task type: %s", taskType)
+}
+
+// GetTaskExecutorFactory returns the factory for a task type
+func GetTaskExecutorFactory(taskType MaintenanceTaskType) (TaskExecutorFactory, bool) {
+ // Ensure executor factories are initialized
+ initializeExecutorFactories()
+
+ executorRegistryMutex.RLock()
+ defer executorRegistryMutex.RUnlock()
+ factory, exists := taskExecutorFactories[taskType]
+ return factory, exists
+}
+
+// GetSupportedExecutorTaskTypes returns all task types with registered executor factories
+func GetSupportedExecutorTaskTypes() []MaintenanceTaskType {
+ // Ensure executor factories are initialized
+ initializeExecutorFactories()
+
+ executorRegistryMutex.RLock()
+ defer executorRegistryMutex.RUnlock()
+
+ taskTypes := make([]MaintenanceTaskType, 0, len(taskExecutorFactories))
+ for taskType := range taskExecutorFactories {
+ taskTypes = append(taskTypes, taskType)
+ }
+ return taskTypes
+}
+
+// createGenericTaskExecutor creates a generic task executor that uses the task registry
+func createGenericTaskExecutor() TaskExecutor {
+ return func(mws *MaintenanceWorkerService, task *MaintenanceTask) error {
+ return mws.executeGenericTask(task)
+ }
+}
+
+// init does minimal initialization - actual registration happens lazily
+func init() {
+ // Executor factory registration will happen lazily when first accessed
+ glog.V(1).Infof("Maintenance worker initialized - executor factories will be registered on first access")
+}
+
+type MaintenanceWorkerService struct {
+ workerID string
+ address string
+ adminServer string
+ capabilities []MaintenanceTaskType
+ maxConcurrent int
+ currentTasks map[string]*MaintenanceTask
+ queue *MaintenanceQueue
+ adminClient AdminClient
+ running bool
+ stopChan chan struct{}
+
+ // Task execution registry
+ taskExecutors map[MaintenanceTaskType]TaskExecutor
+
+ // Task registry for creating task instances
+ taskRegistry *tasks.TaskRegistry
+}
+
+// NewMaintenanceWorkerService creates a new maintenance worker service
+func NewMaintenanceWorkerService(workerID, address, adminServer string) *MaintenanceWorkerService {
+ // Get all registered maintenance task types dynamically
+ capabilities := GetRegisteredMaintenanceTaskTypes()
+
+ worker := &MaintenanceWorkerService{
+ workerID: workerID,
+ address: address,
+ adminServer: adminServer,
+ capabilities: capabilities,
+ maxConcurrent: 2, // Default concurrent task limit
+ currentTasks: make(map[string]*MaintenanceTask),
+ stopChan: make(chan struct{}),
+ taskExecutors: make(map[MaintenanceTaskType]TaskExecutor),
+ taskRegistry: tasks.GetGlobalRegistry(), // Use global registry with auto-registered tasks
+ }
+
+ // Initialize task executor registry
+ worker.initializeTaskExecutors()
+
+ glog.V(1).Infof("Created maintenance worker with %d registered task types", len(worker.taskRegistry.GetSupportedTypes()))
+
+ return worker
+}
+
+// executeGenericTask executes a task using the task registry instead of hardcoded methods
+func (mws *MaintenanceWorkerService) executeGenericTask(task *MaintenanceTask) error {
+ glog.V(2).Infof("Executing generic task %s: %s for volume %d", task.ID, task.Type, task.VolumeID)
+
+ // Convert MaintenanceTask to types.TaskType
+ taskType := types.TaskType(string(task.Type))
+
+ // Create task parameters
+ taskParams := types.TaskParams{
+ VolumeID: task.VolumeID,
+ Server: task.Server,
+ Collection: task.Collection,
+ Parameters: task.Parameters,
+ }
+
+ // Create task instance using the registry
+ taskInstance, err := mws.taskRegistry.CreateTask(taskType, taskParams)
+ if err != nil {
+ return fmt.Errorf("failed to create task instance: %v", err)
+ }
+
+ // Update progress to show task has started
+ mws.updateTaskProgress(task.ID, 5)
+
+ // Execute the task
+ err = taskInstance.Execute(taskParams)
+ if err != nil {
+ return fmt.Errorf("task execution failed: %v", err)
+ }
+
+ // Update progress to show completion
+ mws.updateTaskProgress(task.ID, 100)
+
+ glog.V(2).Infof("Generic task %s completed successfully", task.ID)
+ return nil
+}
+
+// initializeTaskExecutors sets up the task execution registry dynamically
+func (mws *MaintenanceWorkerService) initializeTaskExecutors() {
+ mws.taskExecutors = make(map[MaintenanceTaskType]TaskExecutor)
+
+ // Get all registered executor factories and create executors
+ executorRegistryMutex.RLock()
+ defer executorRegistryMutex.RUnlock()
+
+ for taskType, factory := range taskExecutorFactories {
+ executor := factory()
+ mws.taskExecutors[taskType] = executor
+ glog.V(3).Infof("Initialized executor for task type: %s", taskType)
+ }
+
+ glog.V(2).Infof("Initialized %d task executors", len(mws.taskExecutors))
+}
+
+// RegisterTaskExecutor allows dynamic registration of new task executors
+func (mws *MaintenanceWorkerService) RegisterTaskExecutor(taskType MaintenanceTaskType, executor TaskExecutor) {
+ if mws.taskExecutors == nil {
+ mws.taskExecutors = make(map[MaintenanceTaskType]TaskExecutor)
+ }
+ mws.taskExecutors[taskType] = executor
+ glog.V(1).Infof("Registered executor for task type: %s", taskType)
+}
+
+// GetSupportedTaskTypes returns all task types that this worker can execute
+func (mws *MaintenanceWorkerService) GetSupportedTaskTypes() []MaintenanceTaskType {
+ return GetSupportedExecutorTaskTypes()
+}
+
+// Start begins the worker service
+func (mws *MaintenanceWorkerService) Start() error {
+ mws.running = true
+
+ // Register with admin server
+ worker := &MaintenanceWorker{
+ ID: mws.workerID,
+ Address: mws.address,
+ Capabilities: mws.capabilities,
+ MaxConcurrent: mws.maxConcurrent,
+ }
+
+ if mws.queue != nil {
+ mws.queue.RegisterWorker(worker)
+ }
+
+ // Start worker loop
+ go mws.workerLoop()
+
+ glog.Infof("Maintenance worker %s started at %s", mws.workerID, mws.address)
+ return nil
+}
+
+// Stop terminates the worker service
+func (mws *MaintenanceWorkerService) Stop() {
+ mws.running = false
+ close(mws.stopChan)
+
+ // Wait for current tasks to complete or timeout
+ timeout := time.NewTimer(30 * time.Second)
+ defer timeout.Stop()
+
+ for len(mws.currentTasks) > 0 {
+ select {
+ case <-timeout.C:
+ glog.Warningf("Worker %s stopping with %d tasks still running", mws.workerID, len(mws.currentTasks))
+ return
+ case <-time.After(time.Second):
+ // Check again
+ }
+ }
+
+ glog.Infof("Maintenance worker %s stopped", mws.workerID)
+}
+
+// workerLoop is the main worker event loop
+func (mws *MaintenanceWorkerService) workerLoop() {
+ heartbeatTicker := time.NewTicker(30 * time.Second)
+ defer heartbeatTicker.Stop()
+
+ taskRequestTicker := time.NewTicker(5 * time.Second)
+ defer taskRequestTicker.Stop()
+
+ for mws.running {
+ select {
+ case <-mws.stopChan:
+ return
+ case <-heartbeatTicker.C:
+ mws.sendHeartbeat()
+ case <-taskRequestTicker.C:
+ mws.requestTasks()
+ }
+ }
+}
+
+// sendHeartbeat sends heartbeat to admin server
+func (mws *MaintenanceWorkerService) sendHeartbeat() {
+ if mws.queue != nil {
+ mws.queue.UpdateWorkerHeartbeat(mws.workerID)
+ }
+}
+
+// requestTasks requests new tasks from the admin server
+func (mws *MaintenanceWorkerService) requestTasks() {
+ if len(mws.currentTasks) >= mws.maxConcurrent {
+ return // Already at capacity
+ }
+
+ if mws.queue != nil {
+ task := mws.queue.GetNextTask(mws.workerID, mws.capabilities)
+ if task != nil {
+ mws.executeTask(task)
+ }
+ }
+}
+
+// executeTask executes a maintenance task
+func (mws *MaintenanceWorkerService) executeTask(task *MaintenanceTask) {
+ mws.currentTasks[task.ID] = task
+
+ go func() {
+ defer func() {
+ delete(mws.currentTasks, task.ID)
+ }()
+
+ glog.Infof("Worker %s executing task %s: %s", mws.workerID, task.ID, task.Type)
+
+ // Execute task using dynamic executor registry
+ var err error
+ if executor, exists := mws.taskExecutors[task.Type]; exists {
+ err = executor(mws, task)
+ } else {
+ err = fmt.Errorf("unsupported task type: %s", task.Type)
+ glog.Errorf("No executor registered for task type: %s", task.Type)
+ }
+
+ // Report task completion
+ if mws.queue != nil {
+ errorMsg := ""
+ if err != nil {
+ errorMsg = err.Error()
+ }
+ mws.queue.CompleteTask(task.ID, errorMsg)
+ }
+
+ if err != nil {
+ glog.Errorf("Worker %s failed to execute task %s: %v", mws.workerID, task.ID, err)
+ } else {
+ glog.Infof("Worker %s completed task %s successfully", mws.workerID, task.ID)
+ }
+ }()
+}
+
+// updateTaskProgress updates the progress of a task
+func (mws *MaintenanceWorkerService) updateTaskProgress(taskID string, progress float64) {
+ if mws.queue != nil {
+ mws.queue.UpdateTaskProgress(taskID, progress)
+ }
+}
+
+// GetStatus returns the current status of the worker
+func (mws *MaintenanceWorkerService) GetStatus() map[string]interface{} {
+ return map[string]interface{}{
+ "worker_id": mws.workerID,
+ "address": mws.address,
+ "running": mws.running,
+ "capabilities": mws.capabilities,
+ "max_concurrent": mws.maxConcurrent,
+ "current_tasks": len(mws.currentTasks),
+ "task_details": mws.currentTasks,
+ }
+}
+
+// SetQueue sets the maintenance queue for the worker
+func (mws *MaintenanceWorkerService) SetQueue(queue *MaintenanceQueue) {
+ mws.queue = queue
+}
+
+// SetAdminClient sets the admin client for the worker
+func (mws *MaintenanceWorkerService) SetAdminClient(client AdminClient) {
+ mws.adminClient = client
+}
+
+// SetCapabilities sets the worker capabilities
+func (mws *MaintenanceWorkerService) SetCapabilities(capabilities []MaintenanceTaskType) {
+ mws.capabilities = capabilities
+}
+
+// SetMaxConcurrent sets the maximum concurrent tasks
+func (mws *MaintenanceWorkerService) SetMaxConcurrent(max int) {
+ mws.maxConcurrent = max
+}
+
+// SetHeartbeatInterval sets the heartbeat interval (placeholder for future use)
+func (mws *MaintenanceWorkerService) SetHeartbeatInterval(interval time.Duration) {
+ // Future implementation for configurable heartbeat
+}
+
+// SetTaskRequestInterval sets the task request interval (placeholder for future use)
+func (mws *MaintenanceWorkerService) SetTaskRequestInterval(interval time.Duration) {
+ // Future implementation for configurable task requests
+}
+
+// MaintenanceWorkerCommand represents a standalone maintenance worker command
+type MaintenanceWorkerCommand struct {
+ workerService *MaintenanceWorkerService
+}
+
+// NewMaintenanceWorkerCommand creates a new worker command
+func NewMaintenanceWorkerCommand(workerID, address, adminServer string) *MaintenanceWorkerCommand {
+ return &MaintenanceWorkerCommand{
+ workerService: NewMaintenanceWorkerService(workerID, address, adminServer),
+ }
+}
+
+// Run starts the maintenance worker as a standalone service
+func (mwc *MaintenanceWorkerCommand) Run() error {
+ // Generate worker ID if not provided
+ if mwc.workerService.workerID == "" {
+ hostname, _ := os.Hostname()
+ mwc.workerService.workerID = fmt.Sprintf("worker-%s-%d", hostname, time.Now().Unix())
+ }
+
+ // Start the worker service
+ err := mwc.workerService.Start()
+ if err != nil {
+ return fmt.Errorf("failed to start maintenance worker: %v", err)
+ }
+
+ // Wait for interrupt signal
+ select {}
+}
diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js
index 4a051be60..2049652cd 100644
--- a/weed/admin/static/js/admin.js
+++ b/weed/admin/static/js/admin.js
@@ -129,6 +129,21 @@ function setupSubmenuBehavior() {
}
}
+ // If we're on a maintenance page, expand the maintenance submenu
+ if (currentPath.startsWith('/maintenance')) {
+ const maintenanceSubmenu = document.getElementById('maintenanceSubmenu');
+ if (maintenanceSubmenu) {
+ maintenanceSubmenu.classList.add('show');
+
+ // Update the parent toggle button state
+ const toggleButton = document.querySelector('[data-bs-target="#maintenanceSubmenu"]');
+ if (toggleButton) {
+ toggleButton.classList.remove('collapsed');
+ toggleButton.setAttribute('aria-expanded', 'true');
+ }
+ }
+ }
+
// Prevent submenu from collapsing when clicking on submenu items
const clusterSubmenuLinks = document.querySelectorAll('#clusterSubmenu .nav-link');
clusterSubmenuLinks.forEach(function(link) {
@@ -146,6 +161,14 @@ function setupSubmenuBehavior() {
});
});
+ const maintenanceSubmenuLinks = document.querySelectorAll('#maintenanceSubmenu .nav-link');
+ maintenanceSubmenuLinks.forEach(function(link) {
+ link.addEventListener('click', function(e) {
+ // Don't prevent the navigation, just stop the collapse behavior
+ e.stopPropagation();
+ });
+ });
+
// Handle the main cluster toggle
const clusterToggle = document.querySelector('[data-bs-target="#clusterSubmenu"]');
if (clusterToggle) {
@@ -191,6 +214,29 @@ function setupSubmenuBehavior() {
}
});
}
+
+ // Handle the main maintenance toggle
+ const maintenanceToggle = document.querySelector('[data-bs-target="#maintenanceSubmenu"]');
+ if (maintenanceToggle) {
+ maintenanceToggle.addEventListener('click', function(e) {
+ e.preventDefault();
+
+ const submenu = document.getElementById('maintenanceSubmenu');
+ const isExpanded = submenu.classList.contains('show');
+
+ if (isExpanded) {
+ // Collapse
+ submenu.classList.remove('show');
+ this.classList.add('collapsed');
+ this.setAttribute('aria-expanded', 'false');
+ } else {
+ // Expand
+ submenu.classList.add('show');
+ this.classList.remove('collapsed');
+ this.setAttribute('aria-expanded', 'true');
+ }
+ });
+ }
}
// Loading indicator functions
@@ -689,7 +735,7 @@ function exportVolumes() {
for (let i = 0; i < cells.length - 1; i++) {
rowData.push(`"${cells[i].textContent.trim().replace(/"/g, '""')}"`);
}
- csv += rowData.join(',') + '\n';
+ csv += rowData.join(',') + '\n';
});
downloadCSV(csv, 'seaweedfs-volumes.csv');
@@ -877,53 +923,7 @@ async function deleteCollection(collectionName) {
}
}
-// Handle create collection form submission
-document.addEventListener('DOMContentLoaded', function() {
- const createCollectionForm = document.getElementById('createCollectionForm');
- if (createCollectionForm) {
- createCollectionForm.addEventListener('submit', handleCreateCollection);
- }
-});
-async function handleCreateCollection(event) {
- event.preventDefault();
-
- const formData = new FormData(event.target);
- const collectionData = {
- name: formData.get('name'),
- replication: formData.get('replication'),
- diskType: formData.get('diskType')
- };
-
- try {
- const response = await fetch('/api/collections', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(collectionData)
- });
-
- if (response.ok) {
- showSuccessMessage(`Collection "${collectionData.name}" created successfully`);
- // Hide modal
- const modal = bootstrap.Modal.getInstance(document.getElementById('createCollectionModal'));
- modal.hide();
- // Reset form
- event.target.reset();
- // Refresh page
- setTimeout(() => {
- window.location.reload();
- }, 1000);
- } else {
- const error = await response.json();
- showErrorMessage(`Failed to create collection: ${error.error || 'Unknown error'}`);
- }
- } catch (error) {
- console.error('Error creating collection:', error);
- showErrorMessage('Failed to create collection. Please try again.');
- }
-}
// Download CSV utility function
function downloadCSV(csvContent, filename) {
diff --git a/weed/admin/view/app/cluster_collections.templ b/weed/admin/view/app/cluster_collections.templ
index 972998d18..2bd21a3ca 100644
--- a/weed/admin/view/app/cluster_collections.templ
+++ b/weed/admin/view/app/cluster_collections.templ
@@ -15,9 +15,6 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportCollections()">
<i class="fas fa-download me-1"></i>Export
</button>
- <button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#createCollectionModal">
- <i class="fas fa-plus me-1"></i>Create Collection
- </button>
</div>
</div>
</div>
@@ -79,11 +76,11 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
</div>
<div class="col-auto">
<i class="fas fa-file fa-2x text-gray-300"></i>
- </div>
</div>
</div>
</div>
</div>
+ </div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-secondary shadow h-100 py-2">
@@ -132,15 +129,15 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
<tr>
<td>
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))} class="text-decoration-none">
- <strong>{collection.Name}</strong>
+ <strong>{collection.Name}</strong>
</a>
</td>
<td>
<a href={templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))} class="text-decoration-none">
- <div class="d-flex align-items-center">
- <i class="fas fa-database me-2 text-muted"></i>
- {fmt.Sprintf("%d", collection.VolumeCount)}
- </div>
+ <div class="d-flex align-items-center">
+ <i class="fas fa-database me-2 text-muted"></i>
+ {fmt.Sprintf("%d", collection.VolumeCount)}
+ </div>
</a>
</td>
<td>
@@ -194,9 +191,6 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
<i class="fas fa-layer-group fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Collections Found</h5>
<p class="text-muted">No collections are currently configured in the cluster.</p>
- <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCollectionModal">
- <i class="fas fa-plus me-2"></i>Create First Collection
- </button>
</div>
}
</div>
@@ -213,54 +207,7 @@ templ ClusterCollections(data dash.ClusterCollectionsData) {
</div>
</div>
- <!-- Create Collection Modal -->
- <div class="modal fade" id="createCollectionModal" tabindex="-1">
- <div class="modal-dialog">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">
- <i class="fas fa-plus me-2"></i>Create New Collection
- </h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
- </div>
- <form id="createCollectionForm">
- <div class="modal-body">
- <div class="mb-3">
- <label for="collectionName" class="form-label">Collection Name</label>
- <input type="text" class="form-control" id="collectionName" name="name" required>
- <div class="form-text">Enter a unique name for the collection</div>
- </div>
- <div class="mb-3">
- <label for="replication" class="form-label">Replication</label>
- <select class="form-select" id="replication" name="replication" required>
- <option value="000">000 - No replication</option>
- <option value="001" selected>001 - Replicate once on same rack</option>
- <option value="010">010 - Replicate once on different rack</option>
- <option value="100">100 - Replicate once on different data center</option>
- <option value="200">200 - Replicate twice on different data centers</option>
- </select>
- </div>
- <div class="mb-3">
- <label for="ttl" class="form-label">TTL (Time To Live)</label>
- <input type="text" class="form-control" id="ttl" name="ttl" placeholder="e.g., 1d, 7d, 30d">
- <div class="form-text">Optional: Specify how long files should be kept</div>
- </div>
- <div class="mb-3">
- <label for="diskType" class="form-label">Disk Type</label>
- <select class="form-select" id="diskType" name="diskType">
- <option value="hdd" selected>HDD</option>
- <option value="ssd">SSD</option>
- </select>
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
- <button type="submit" class="btn btn-primary">Create Collection</button>
- </div>
- </form>
- </div>
- </div>
- </div>
+
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteCollectionModal" tabindex="-1">
diff --git a/weed/admin/view/app/cluster_collections_templ.go b/weed/admin/view/app/cluster_collections_templ.go
index bb7187ece..94282f11f 100644
--- a/weed/admin/view/app/cluster_collections_templ.go
+++ b/weed/admin/view/app/cluster_collections_templ.go
@@ -34,14 +34,14 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-layer-group me-2\"></i>Cluster Collections</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportCollections()\"><i class=\"fas fa-download me-1\"></i>Export</button> <button type=\"button\" class=\"btn btn-sm btn-success\" data-bs-toggle=\"modal\" data-bs-target=\"#createCollectionModal\"><i class=\"fas fa-plus me-1\"></i>Create Collection</button></div></div></div><div id=\"collections-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Collections</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-layer-group me-2\"></i>Cluster Collections</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportCollections()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><div id=\"collections-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Collections</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalCollections))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 37, Col: 77}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 34, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -54,7 +54,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalVolumes))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 57, Col: 73}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 54, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -67,7 +67,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalFiles))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 77, Col: 71}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 74, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalSize))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 97, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 94, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -112,7 +112,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 135, Col: 72}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 132, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -134,7 +134,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.VolumeCount))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 142, Col: 94}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 139, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -147,7 +147,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", collection.FileCount))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 149, Col: 88}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 146, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -160,7 +160,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(collection.TotalSize))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 155, Col: 82}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 152, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(diskType)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 163, Col: 131}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 160, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -230,7 +230,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 181, Col: 93}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 178, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -246,7 +246,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createCollectionModal\"><i class=\"fas fa-plus me-2\"></i>Create First Collection</button></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"text-center py-5\"><i class=\"fas fa-layer-group fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Collections Found</h5><p class=\"text-muted\">No collections are currently configured in the cluster.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -258,13 +258,13 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 210, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 204, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></div></div></div><!-- Create Collection Modal --><div class=\"modal fade\" id=\"createCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"fas fa-plus me-2\"></i>Create New Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><form id=\"createCollectionForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"collectionName\" class=\"form-label\">Collection Name</label> <input type=\"text\" class=\"form-control\" id=\"collectionName\" name=\"name\" required><div class=\"form-text\">Enter a unique name for the collection</div></div><div class=\"mb-3\"><label for=\"replication\" class=\"form-label\">Replication</label> <select class=\"form-select\" id=\"replication\" name=\"replication\" required><option value=\"000\">000 - No replication</option> <option value=\"001\" selected>001 - Replicate once on same rack</option> <option value=\"010\">010 - Replicate once on different rack</option> <option value=\"100\">100 - Replicate once on different data center</option> <option value=\"200\">200 - Replicate twice on different data centers</option></select></div><div class=\"mb-3\"><label for=\"ttl\" class=\"form-label\">TTL (Time To Live)</label> <input type=\"text\" class=\"form-control\" id=\"ttl\" name=\"ttl\" placeholder=\"e.g., 1d, 7d, 30d\"><div class=\"form-text\">Optional: Specify how long files should be kept</div></div><div class=\"mb-3\"><label for=\"diskType\" class=\"form-label\">Disk Type</label> <select class=\"form-select\" id=\"diskType\" name=\"diskType\"><option value=\"hdd\" selected>HDD</option> <option value=\"ssd\">SSD</option></select></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title text-danger\"><i class=\"fas fa-exclamation-triangle me-2\"></i>Delete Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the collection <strong id=\"deleteCollectionName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-warning me-2\"></i> This action cannot be undone. All volumes in this collection will be affected.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" id=\"confirmDeleteCollection\">Delete Collection</button></div></div></div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</small></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteCollectionModal\" tabindex=\"-1\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title text-danger\"><i class=\"fas fa-exclamation-triangle me-2\"></i>Delete Collection</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the collection <strong id=\"deleteCollectionName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-warning me-2\"></i> This action cannot be undone. All volumes in this collection will be affected.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" id=\"confirmDeleteCollection\">Delete Collection</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/app/maintenance_config.templ b/weed/admin/view/app/maintenance_config.templ
new file mode 100644
index 000000000..d560cd22c
--- /dev/null
+++ b/weed/admin/view/app/maintenance_config.templ
@@ -0,0 +1,244 @@
+package app
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+)
+
+templ MaintenanceConfig(data *maintenance.MaintenanceConfigData) {
+ <div class="container-fluid">
+ <div class="row mb-4">
+ <div class="col-12">
+ <div class="d-flex justify-content-between align-items-center">
+ <h2 class="mb-0">
+ <i class="fas fa-cog me-2"></i>
+ Maintenance Configuration
+ </h2>
+ <div class="btn-group">
+ <a href="/maintenance" class="btn btn-outline-secondary">
+ <i class="fas fa-arrow-left me-1"></i>
+ Back to Queue
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">System Settings</h5>
+ </div>
+ <div class="card-body">
+ <form>
+ <div class="mb-3">
+ <div class="form-check form-switch">
+ <input class="form-check-input" type="checkbox" id="enabled" checked?={data.IsEnabled}>
+ <label class="form-check-label" for="enabled">
+ <strong>Enable Maintenance System</strong>
+ </label>
+ </div>
+ <small class="form-text text-muted">
+ When enabled, the system will automatically scan for and execute maintenance tasks.
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="scanInterval" class="form-label">Scan Interval (minutes)</label>
+ <input type="number" class="form-control" id="scanInterval"
+ value={fmt.Sprintf("%.0f", float64(data.Config.ScanIntervalSeconds)/60)} min="1" max="1440">
+ <small class="form-text text-muted">
+ How often to scan for maintenance tasks (1-1440 minutes).
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="workerTimeout" class="form-label">Worker Timeout (minutes)</label>
+ <input type="number" class="form-control" id="workerTimeout"
+ value={fmt.Sprintf("%.0f", float64(data.Config.WorkerTimeoutSeconds)/60)} min="1" max="60">
+ <small class="form-text text-muted">
+ How long to wait for worker heartbeat before considering it inactive (1-60 minutes).
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="taskTimeout" class="form-label">Task Timeout (hours)</label>
+ <input type="number" class="form-control" id="taskTimeout"
+ value={fmt.Sprintf("%.0f", float64(data.Config.TaskTimeoutSeconds)/3600)} min="1" max="24">
+ <small class="form-text text-muted">
+ Maximum time allowed for a single task to complete (1-24 hours).
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="globalMaxConcurrent" class="form-label">Global Concurrent Limit</label>
+ <input type="number" class="form-control" id="globalMaxConcurrent"
+ value={fmt.Sprintf("%d", data.Config.Policy.GlobalMaxConcurrent)} min="1" max="20">
+ <small class="form-text text-muted">
+ Maximum number of maintenance tasks that can run simultaneously across all workers (1-20).
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="maxRetries" class="form-label">Default Max Retries</label>
+ <input type="number" class="form-control" id="maxRetries"
+ value={fmt.Sprintf("%d", data.Config.MaxRetries)} min="0" max="10">
+ <small class="form-text text-muted">
+ Default number of times to retry failed tasks (0-10).
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="retryDelay" class="form-label">Retry Delay (minutes)</label>
+ <input type="number" class="form-control" id="retryDelay"
+ value={fmt.Sprintf("%.0f", float64(data.Config.RetryDelaySeconds)/60)} min="1" max="120">
+ <small class="form-text text-muted">
+ Time to wait before retrying failed tasks (1-120 minutes).
+ </small>
+ </div>
+
+ <div class="mb-3">
+ <label for="taskRetention" class="form-label">Task Retention (days)</label>
+ <input type="number" class="form-control" id="taskRetention"
+ value={fmt.Sprintf("%.0f", float64(data.Config.TaskRetentionSeconds)/(24*3600))} min="1" max="30">
+ <small class="form-text text-muted">
+ How long to keep completed/failed task records (1-30 days).
+ </small>
+ </div>
+
+ <div class="d-flex gap-2">
+ <button type="button" class="btn btn-primary" onclick="saveConfiguration()">
+ <i class="fas fa-save me-1"></i>
+ Save Configuration
+ </button>
+ <button type="button" class="btn btn-secondary" onclick="resetToDefaults()">
+ <i class="fas fa-undo me-1"></i>
+ Reset to Defaults
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Individual Task Configuration Menu -->
+ <div class="row mt-4">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-cogs me-2"></i>
+ Task Configuration
+ </h5>
+ </div>
+ <div class="card-body">
+ <p class="text-muted mb-3">Configure specific settings for each maintenance task type.</p>
+ <div class="list-group">
+ for _, menuItem := range data.MenuItems {
+ <a href={templ.SafeURL(menuItem.Path)} class="list-group-item list-group-item-action">
+ <div class="d-flex w-100 justify-content-between">
+ <h6 class="mb-1">
+ <i class={menuItem.Icon + " me-2"}></i>
+ {menuItem.DisplayName}
+ </h6>
+ if data.Config.Policy.IsTaskEnabled(menuItem.TaskType) {
+ <span class="badge bg-success">Enabled</span>
+ } else {
+ <span class="badge bg-secondary">Disabled</span>
+ }
+ </div>
+ <p class="mb-1 small text-muted">{menuItem.Description}</p>
+ </a>
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Statistics Overview -->
+ <div class="row mt-4">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">System Statistics</h5>
+ </div>
+ <div class="card-body">
+ <div class="row">
+ <div class="col-md-3">
+ <div class="text-center">
+ <h6 class="text-muted">Last Scan</h6>
+ <p class="mb-0">{data.LastScanTime.Format("2006-01-02 15:04:05")}</p>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="text-center">
+ <h6 class="text-muted">Next Scan</h6>
+ <p class="mb-0">{data.NextScanTime.Format("2006-01-02 15:04:05")}</p>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="text-center">
+ <h6 class="text-muted">Total Tasks</h6>
+ <p class="mb-0">{fmt.Sprintf("%d", data.SystemStats.TotalTasks)}</p>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="text-center">
+ <h6 class="text-muted">Active Workers</h6>
+ <p class="mb-0">{fmt.Sprintf("%d", data.SystemStats.ActiveWorkers)}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ function saveConfiguration() {
+ const config = {
+ enabled: document.getElementById('enabled').checked,
+ scan_interval_seconds: parseInt(document.getElementById('scanInterval').value) * 60, // Convert to seconds
+ policy: {
+ vacuum_enabled: document.getElementById('vacuumEnabled').checked,
+ vacuum_garbage_ratio: parseFloat(document.getElementById('vacuumGarbageRatio').value) / 100,
+ replication_fix_enabled: document.getElementById('replicationFixEnabled').checked,
+ }
+ };
+
+ fetch('/api/maintenance/config', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(config)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Configuration saved successfully');
+ } else {
+ alert('Failed to save configuration: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ alert('Error: ' + error.message);
+ });
+ }
+
+ function resetToDefaults() {
+ if (confirm('Are you sure you want to reset to default configuration? This will overwrite your current settings.')) {
+ // Reset form to defaults
+ document.getElementById('enabled').checked = false;
+ document.getElementById('scanInterval').value = '30';
+ document.getElementById('vacuumEnabled').checked = false;
+ document.getElementById('vacuumGarbageRatio').value = '30';
+ document.getElementById('replicationFixEnabled').checked = false;
+ }
+ }
+ </script>
+} \ No newline at end of file
diff --git a/weed/admin/view/app/maintenance_config_templ.go b/weed/admin/view/app/maintenance_config_templ.go
new file mode 100644
index 000000000..deb67b010
--- /dev/null
+++ b/weed/admin/view/app/maintenance_config_templ.go
@@ -0,0 +1,280 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+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/maintenance"
+)
+
+func MaintenanceConfig(data *maintenance.MaintenanceConfigData) 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, "<div class=\"container-fluid\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\"><i class=\"fas fa-cog me-2\"></i> Maintenance Configuration</h2><div class=\"btn-group\"><a href=\"/maintenance\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i> Back to Queue</a></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">System Settings</h5></div><div class=\"card-body\"><form><div class=\"mb-3\"><div class=\"form-check form-switch\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enabled\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.IsEnabled {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " checked")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "> <label class=\"form-check-label\" for=\"enabled\"><strong>Enable Maintenance System</strong></label></div><small class=\"form-text text-muted\">When enabled, the system will automatically scan for and execute maintenance tasks.</small></div><div class=\"mb-3\"><label for=\"scanInterval\" class=\"form-label\">Scan Interval (minutes)</label> <input type=\"number\" class=\"form-control\" id=\"scanInterval\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.ScanIntervalSeconds)/60))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 50, Col: 110}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" min=\"1\" max=\"1440\"> <small class=\"form-text text-muted\">How often to scan for maintenance tasks (1-1440 minutes).</small></div><div class=\"mb-3\"><label for=\"workerTimeout\" class=\"form-label\">Worker Timeout (minutes)</label> <input type=\"number\" class=\"form-control\" id=\"workerTimeout\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.WorkerTimeoutSeconds)/60))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 59, Col: 111}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" min=\"1\" max=\"60\"> <small class=\"form-text text-muted\">How long to wait for worker heartbeat before considering it inactive (1-60 minutes).</small></div><div class=\"mb-3\"><label for=\"taskTimeout\" class=\"form-label\">Task Timeout (hours)</label> <input type=\"number\" class=\"form-control\" id=\"taskTimeout\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.TaskTimeoutSeconds)/3600))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 68, Col: 111}
+ }
+ _, 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, 6, "\" min=\"1\" max=\"24\"> <small class=\"form-text text-muted\">Maximum time allowed for a single task to complete (1-24 hours).</small></div><div class=\"mb-3\"><label for=\"globalMaxConcurrent\" class=\"form-label\">Global Concurrent Limit</label> <input type=\"number\" class=\"form-control\" id=\"globalMaxConcurrent\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Config.Policy.GlobalMaxConcurrent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 77, Col: 103}
+ }
+ _, 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, "\" min=\"1\" max=\"20\"> <small class=\"form-text text-muted\">Maximum number of maintenance tasks that can run simultaneously across all workers (1-20).</small></div><div class=\"mb-3\"><label for=\"maxRetries\" class=\"form-label\">Default Max Retries</label> <input type=\"number\" class=\"form-control\" id=\"maxRetries\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Config.MaxRetries))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 86, Col: 87}
+ }
+ _, 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, 8, "\" min=\"0\" max=\"10\"> <small class=\"form-text text-muted\">Default number of times to retry failed tasks (0-10).</small></div><div class=\"mb-3\"><label for=\"retryDelay\" class=\"form-label\">Retry Delay (minutes)</label> <input type=\"number\" class=\"form-control\" id=\"retryDelay\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.RetryDelaySeconds)/60))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 95, Col: 108}
+ }
+ _, 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, 9, "\" min=\"1\" max=\"120\"> <small class=\"form-text text-muted\">Time to wait before retrying failed tasks (1-120 minutes).</small></div><div class=\"mb-3\"><label for=\"taskRetention\" class=\"form-label\">Task Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"taskRetention\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", float64(data.Config.TaskRetentionSeconds)/(24*3600)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 104, Col: 118}
+ }
+ _, 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, 10, "\" min=\"1\" max=\"30\"> <small class=\"form-text text-muted\">How long to keep completed/failed task records (1-30 days).</small></div><div class=\"d-flex gap-2\"><button type=\"button\" class=\"btn btn-primary\" onclick=\"saveConfiguration()\"><i class=\"fas fa-save me-1\"></i> Save Configuration</button> <button type=\"button\" class=\"btn btn-secondary\" onclick=\"resetToDefaults()\"><i class=\"fas fa-undo me-1\"></i> Reset to Defaults</button></div></form></div></div></div></div><!-- Individual Task Configuration Menu --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"fas fa-cogs me-2\"></i> Task Configuration</h5></div><div class=\"card-body\"><p class=\"text-muted mb-3\">Configure specific settings for each maintenance task type.</p><div class=\"list-group\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, menuItem := range data.MenuItems {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 templ.SafeURL = templ.SafeURL(menuItem.Path)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var9)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"list-group-item list-group-item-action\"><div class=\"d-flex w-100 justify-content-between\"><h6 class=\"mb-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 = []any{menuItem.Icon + " me-2"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.DisplayName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 144, Col: 65}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h6>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Config.Policy.IsTaskEnabled(menuItem.TaskType) {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"badge bg-success\">Enabled</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"badge bg-secondary\">Disabled</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><p class=\"mb-1 small text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_config.templ`, Line: 152, Col: 90}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</p></a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div></div></div></div><!-- Statistics Overview --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">System Statistics</h5></div><div class=\"card-body\"><div class=\"row\"><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Last Scan</h6><p class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, 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.templ`, Line: 173, Col: 100}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></div></div><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Next Scan</h6><p class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, 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.templ`, Line: 179, Col: 100}
+ }
+ _, 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, 22, "</p></div></div><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Total Tasks</h6><p class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, 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.templ`, Line: 185, Col: 99}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p></div></div><div class=\"col-md-3\"><div class=\"text-center\"><h6 class=\"text-muted\">Active Workers</h6><p class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, 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.templ`, Line: 191, Col: 102}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p></div></div></div></div></div></div></div></div><script>\n function saveConfiguration() {\n const config = {\n enabled: document.getElementById('enabled').checked,\n scan_interval_seconds: parseInt(document.getElementById('scanInterval').value) * 60, // Convert to seconds\n policy: {\n vacuum_enabled: document.getElementById('vacuumEnabled').checked,\n vacuum_garbage_ratio: parseFloat(document.getElementById('vacuumGarbageRatio').value) / 100,\n replication_fix_enabled: document.getElementById('replicationFixEnabled').checked,\n }\n };\n\n fetch('/api/maintenance/config', {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(config)\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Configuration saved successfully');\n } else {\n alert('Failed to save configuration: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n alert('Error: ' + error.message);\n });\n }\n\n function resetToDefaults() {\n if (confirm('Are you sure you want to reset to default configuration? This will overwrite your current settings.')) {\n // Reset form to defaults\n document.getElementById('enabled').checked = false;\n document.getElementById('scanInterval').value = '30';\n document.getElementById('vacuumEnabled').checked = false;\n document.getElementById('vacuumGarbageRatio').value = '30';\n document.getElementById('replicationFixEnabled').checked = false;\n }\n }\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/maintenance_queue.templ b/weed/admin/view/app/maintenance_queue.templ
new file mode 100644
index 000000000..2c72c17ff
--- /dev/null
+++ b/weed/admin/view/app/maintenance_queue.templ
@@ -0,0 +1,289 @@
+package app
+
+import (
+ "fmt"
+ "time"
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+)
+
+templ MaintenanceQueue(data *maintenance.MaintenanceQueueData) {
+ <div class="container-fluid">
+ <!-- Header -->
+ <div class="row mb-4">
+ <div class="col-12">
+ <div class="d-flex justify-content-between align-items-center">
+ <h2 class="mb-0">
+ <i class="fas fa-tasks me-2"></i>
+ Maintenance Queue
+ </h2>
+ <div class="btn-group">
+ <button type="button" class="btn btn-primary" onclick="triggerScan()">
+ <i class="fas fa-search me-1"></i>
+ Trigger Scan
+ </button>
+ <button type="button" class="btn btn-secondary" onclick="refreshPage()">
+ <i class="fas fa-sync-alt me-1"></i>
+ Refresh
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Statistics Cards -->
+ <div class="row mb-4">
+ <div class="col-md-3">
+ <div class="card border-primary">
+ <div class="card-body text-center">
+ <i class="fas fa-clock fa-2x text-primary mb-2"></i>
+ <h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.PendingTasks)}</h4>
+ <p class="text-muted mb-0">Pending Tasks</p>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="card border-warning">
+ <div class="card-body text-center">
+ <i class="fas fa-running fa-2x text-warning mb-2"></i>
+ <h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.RunningTasks)}</h4>
+ <p class="text-muted mb-0">Running Tasks</p>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="card border-success">
+ <div class="card-body text-center">
+ <i class="fas fa-check-circle fa-2x text-success mb-2"></i>
+ <h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.CompletedToday)}</h4>
+ <p class="text-muted mb-0">Completed Today</p>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="card border-danger">
+ <div class="card-body text-center">
+ <i class="fas fa-exclamation-triangle fa-2x text-danger mb-2"></i>
+ <h4 class="mb-1">{fmt.Sprintf("%d", data.Stats.FailedToday)}</h4>
+ <p class="text-muted mb-0">Failed Today</p>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Simple task queue display -->
+ <div class="row">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">Task Queue</h5>
+ </div>
+ <div class="card-body">
+ if len(data.Tasks) == 0 {
+ <div class="text-center text-muted py-4">
+ <i class="fas fa-clipboard-list fa-3x mb-3"></i>
+ <p>No maintenance tasks in queue</p>
+ <small>Tasks will appear here when the system detects maintenance needs</small>
+ </div>
+ } else {
+ <div class="table-responsive">
+ <table class="table table-hover">
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>Type</th>
+ <th>Status</th>
+ <th>Volume</th>
+ <th>Server</th>
+ <th>Created</th>
+ </tr>
+ </thead>
+ <tbody>
+ for _, task := range data.Tasks {
+ <tr>
+ <td><code>{task.ID[:8]}...</code></td>
+ <td>{string(task.Type)}</td>
+ <td>{string(task.Status)}</td>
+ <td>{fmt.Sprintf("%d", task.VolumeID)}</td>
+ <td>{task.Server}</td>
+ <td>{task.CreatedAt.Format("2006-01-02 15:04")}</td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Workers Summary -->
+ <div class="row mt-4">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">Active Workers</h5>
+ </div>
+ <div class="card-body">
+ if len(data.Workers) == 0 {
+ <div class="text-center text-muted py-4">
+ <i class="fas fa-robot fa-3x mb-3"></i>
+ <p>No workers are currently active</p>
+ <small>Start workers using: <code>weed worker -admin=localhost:9333</code></small>
+ </div>
+ } else {
+ <div class="row">
+ for _, worker := range data.Workers {
+ <div class="col-md-4 mb-3">
+ <div class="card">
+ <div class="card-body">
+ <h6 class="card-title">{worker.ID}</h6>
+ <p class="card-text">
+ <small class="text-muted">{worker.Address}</small><br/>
+ Status: {worker.Status}<br/>
+ Load: {fmt.Sprintf("%d/%d", worker.CurrentLoad, worker.MaxConcurrent)}
+ </p>
+ </div>
+ </div>
+ </div>
+ }
+ </div>
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ // Auto-refresh every 10 seconds
+ setInterval(function() {
+ if (!document.hidden) {
+ window.location.reload();
+ }
+ }, 10000);
+
+ function triggerScan() {
+ fetch('/api/maintenance/scan', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Maintenance scan triggered successfully');
+ setTimeout(() => window.location.reload(), 2000);
+ } else {
+ alert('Failed to trigger scan: ' + (data.error || 'Unknown error'));
+ }
+ })
+ .catch(error => {
+ alert('Error: ' + error.message);
+ });
+ }
+ </script>
+}
+
+// Helper components
+templ TaskTypeIcon(taskType maintenance.MaintenanceTaskType) {
+ <i class={maintenance.GetTaskIcon(taskType) + " me-1"}></i>
+}
+
+templ PriorityBadge(priority maintenance.MaintenanceTaskPriority) {
+ switch priority {
+ case maintenance.PriorityCritical:
+ <span class="badge bg-danger">Critical</span>
+ case maintenance.PriorityHigh:
+ <span class="badge bg-warning">High</span>
+ case maintenance.PriorityNormal:
+ <span class="badge bg-primary">Normal</span>
+ case maintenance.PriorityLow:
+ <span class="badge bg-secondary">Low</span>
+ default:
+ <span class="badge bg-light text-dark">Unknown</span>
+ }
+}
+
+templ StatusBadge(status maintenance.MaintenanceTaskStatus) {
+ switch status {
+ case maintenance.TaskStatusPending:
+ <span class="badge bg-secondary">Pending</span>
+ case maintenance.TaskStatusAssigned:
+ <span class="badge bg-info">Assigned</span>
+ case maintenance.TaskStatusInProgress:
+ <span class="badge bg-warning">Running</span>
+ case maintenance.TaskStatusCompleted:
+ <span class="badge bg-success">Completed</span>
+ case maintenance.TaskStatusFailed:
+ <span class="badge bg-danger">Failed</span>
+ case maintenance.TaskStatusCancelled:
+ <span class="badge bg-light text-dark">Cancelled</span>
+ default:
+ <span class="badge bg-light text-dark">Unknown</span>
+ }
+}
+
+templ ProgressBar(progress float64, status maintenance.MaintenanceTaskStatus) {
+ if status == maintenance.TaskStatusInProgress || status == maintenance.TaskStatusAssigned {
+ <div class="progress" style="height: 8px; min-width: 100px;">
+ <div class="progress-bar" role="progressbar" style={fmt.Sprintf("width: %.1f%%", progress)}>
+ </div>
+ </div>
+ <small class="text-muted">{fmt.Sprintf("%.1f%%", progress)}</small>
+ } else if status == maintenance.TaskStatusCompleted {
+ <div class="progress" style="height: 8px; min-width: 100px;">
+ <div class="progress-bar bg-success" role="progressbar" style="width: 100%">
+ </div>
+ </div>
+ <small class="text-success">100%</small>
+ } else {
+ <span class="text-muted">-</span>
+ }
+}
+
+templ WorkerStatusBadge(status string) {
+ switch status {
+ case "active":
+ <span class="badge bg-success">Active</span>
+ case "busy":
+ <span class="badge bg-warning">Busy</span>
+ case "inactive":
+ <span class="badge bg-secondary">Inactive</span>
+ default:
+ <span class="badge bg-light text-dark">Unknown</span>
+ }
+}
+
+// Helper functions (would be defined in Go)
+
+
+func getWorkerStatusColor(status string) string {
+ switch status {
+ case "active":
+ return "success"
+ case "busy":
+ return "warning"
+ case "inactive":
+ return "secondary"
+ default:
+ return "light"
+ }
+}
+
+func formatTimeAgo(t time.Time) string {
+ duration := time.Since(t)
+ if duration < time.Minute {
+ return "just now"
+ } else if duration < time.Hour {
+ minutes := int(duration.Minutes())
+ return fmt.Sprintf("%dm ago", minutes)
+ } else if duration < 24*time.Hour {
+ hours := int(duration.Hours())
+ return fmt.Sprintf("%dh ago", hours)
+ } else {
+ days := int(duration.Hours() / 24)
+ return fmt.Sprintf("%dd ago", days)
+ }
+} \ No newline at end of file
diff --git a/weed/admin/view/app/maintenance_queue_templ.go b/weed/admin/view/app/maintenance_queue_templ.go
new file mode 100644
index 000000000..703370b9c
--- /dev/null
+++ b/weed/admin/view/app/maintenance_queue_templ.go
@@ -0,0 +1,585 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+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/maintenance"
+ "time"
+)
+
+func MaintenanceQueue(data *maintenance.MaintenanceQueueData) 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, "<div class=\"container-fluid\"><!-- Header --><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\"><i class=\"fas fa-tasks me-2\"></i> Maintenance Queue</h2><div class=\"btn-group\"><button type=\"button\" class=\"btn btn-primary\" onclick=\"triggerScan()\"><i class=\"fas fa-search me-1\"></i> Trigger Scan</button> <button type=\"button\" class=\"btn btn-secondary\" onclick=\"refreshPage()\"><i class=\"fas fa-sync-alt me-1\"></i> Refresh</button></div></div></div></div><!-- Statistics Cards --><div class=\"row mb-4\"><div class=\"col-md-3\"><div class=\"card border-primary\"><div class=\"card-body text-center\"><i class=\"fas fa-clock fa-2x text-primary mb-2\"></i><h4 class=\"mb-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.PendingTasks))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 39, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h4><p class=\"text-muted mb-0\">Pending Tasks</p></div></div></div><div class=\"col-md-3\"><div class=\"card border-warning\"><div class=\"card-body text-center\"><i class=\"fas fa-running fa-2x text-warning mb-2\"></i><h4 class=\"mb-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.RunningTasks))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 48, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h4><p class=\"text-muted mb-0\">Running Tasks</p></div></div></div><div class=\"col-md-3\"><div class=\"card border-success\"><div class=\"card-body text-center\"><i class=\"fas fa-check-circle fa-2x text-success mb-2\"></i><h4 class=\"mb-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.CompletedToday))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 57, Col: 86}
+ }
+ _, 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, "</h4><p class=\"text-muted mb-0\">Completed Today</p></div></div></div><div class=\"col-md-3\"><div class=\"card border-danger\"><div class=\"card-body text-center\"><i class=\"fas fa-exclamation-triangle fa-2x text-danger mb-2\"></i><h4 class=\"mb-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Stats.FailedToday))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 66, Col: 83}
+ }
+ _, 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, "</h4><p class=\"text-muted mb-0\">Failed Today</p></div></div></div></div><!-- Simple task queue display --><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">Task Queue</h5></div><div class=\"card-body\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Tasks) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"text-center text-muted py-4\"><i class=\"fas fa-clipboard-list fa-3x mb-3\"></i><p>No maintenance tasks in queue</p><small>Tasks will appear here when the system detects maintenance needs</small></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"table-responsive\"><table class=\"table table-hover\"><thead><tr><th>ID</th><th>Type</th><th>Status</th><th>Volume</th><th>Server</th><th>Created</th></tr></thead> <tbody>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, task := range data.Tasks {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr><td><code>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(task.ID[:8])
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 103, 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, 9, "...</code></td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(string(task.Type))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 104, Col: 70}
+ }
+ _, 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, 10, "</td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(task.Status))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 105, Col: 72}
+ }
+ _, 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, 11, "</td><td>")
+ 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", task.VolumeID))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 106, Col: 85}
+ }
+ _, 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, 12, "</td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(task.Server)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 107, Col: 64}
+ }
+ _, 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, 13, "</td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(task.CreatedAt.Format("2006-01-02 15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 108, Col: 94}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</tbody></table></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div></div></div><!-- Workers Summary --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">Active Workers</h5></div><div class=\"card-body\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Workers) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"text-center text-muted py-4\"><i class=\"fas fa-robot fa-3x mb-3\"></i><p>No workers are currently active</p><small>Start workers using: <code>weed worker -admin=localhost:9333</code></small></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"row\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, worker := range data.Workers {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"col-md-4 mb-3\"><div class=\"card\"><div class=\"card-body\"><h6 class=\"card-title\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(worker.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 140, Col: 81}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h6><p class=\"card-text\"><small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Address)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 142, Col: 93}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</small><br>Status: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Status)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 143, Col: 74}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<br>Load: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", worker.CurrentLoad, worker.MaxConcurrent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 144, Col: 121}
+ }
+ _, 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, 23, "</p></div></div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></div></div></div></div><script>\n // Auto-refresh every 10 seconds\n setInterval(function() {\n if (!document.hidden) {\n window.location.reload();\n }\n }, 10000);\n\n function triggerScan() {\n fetch('/api/maintenance/scan', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n }\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Maintenance scan triggered successfully');\n setTimeout(() => window.location.reload(), 2000);\n } else {\n alert('Failed to trigger scan: ' + (data.error || 'Unknown error'));\n }\n })\n .catch(error => {\n alert('Error: ' + error.message);\n });\n }\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// Helper components
+func TaskTypeIcon(taskType maintenance.MaintenanceTaskType) 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_Var16 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var16 == nil {
+ templ_7745c5c3_Var16 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var17 = []any{maintenance.GetTaskIcon(taskType) + " me-1"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var17).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></i>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func PriorityBadge(priority maintenance.MaintenanceTaskPriority) 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_Var19 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var19 == nil {
+ templ_7745c5c3_Var19 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ switch priority {
+ case maintenance.PriorityCritical:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"badge bg-danger\">Critical</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.PriorityHigh:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"badge bg-warning\">High</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.PriorityNormal:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"badge bg-primary\">Normal</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.PriorityLow:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"badge bg-secondary\">Low</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ default:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge bg-light text-dark\">Unknown</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func StatusBadge(status maintenance.MaintenanceTaskStatus) 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_Var20 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var20 == nil {
+ templ_7745c5c3_Var20 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ switch status {
+ case maintenance.TaskStatusPending:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"badge bg-secondary\">Pending</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.TaskStatusAssigned:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span class=\"badge bg-info\">Assigned</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.TaskStatusInProgress:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"badge bg-warning\">Running</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.TaskStatusCompleted:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span class=\"badge bg-success\">Completed</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.TaskStatusFailed:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"badge bg-danger\">Failed</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case maintenance.TaskStatusCancelled:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<span class=\"badge bg-light text-dark\">Cancelled</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ default:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"badge bg-light text-dark\">Unknown</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func ProgressBar(progress float64, status maintenance.MaintenanceTaskStatus) 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_Var21 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var21 == nil {
+ templ_7745c5c3_Var21 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if status == maintenance.TaskStatusInProgress || status == maintenance.TaskStatusAssigned {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"progress\" style=\"height: 8px; min-width: 100px;\"><div class=\"progress-bar\" role=\"progressbar\" style=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %.1f%%", progress))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 231, Col: 102}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"></div></div><small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%%", progress))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_queue.templ`, Line: 234, Col: 66}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</small>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if status == maintenance.TaskStatusCompleted {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"progress\" style=\"height: 8px; min-width: 100px;\"><div class=\"progress-bar bg-success\" role=\"progressbar\" style=\"width: 100%\"></div></div><small class=\"text-success\">100%</small>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<span class=\"text-muted\">-</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func WorkerStatusBadge(status string) 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_Var24 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var24 == nil {
+ templ_7745c5c3_Var24 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ switch status {
+ case "active":
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"badge bg-success\">Active</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case "busy":
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"badge bg-warning\">Busy</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case "inactive":
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"badge bg-secondary\">Inactive</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ default:
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"badge bg-light text-dark\">Unknown</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+// Helper functions (would be defined in Go)
+
+func getWorkerStatusColor(status string) string {
+ switch status {
+ case "active":
+ return "success"
+ case "busy":
+ return "warning"
+ case "inactive":
+ return "secondary"
+ default:
+ return "light"
+ }
+}
+
+func formatTimeAgo(t time.Time) string {
+ duration := time.Since(t)
+ if duration < time.Minute {
+ return "just now"
+ } else if duration < time.Hour {
+ minutes := int(duration.Minutes())
+ return fmt.Sprintf("%dm ago", minutes)
+ } else if duration < 24*time.Hour {
+ hours := int(duration.Hours())
+ return fmt.Sprintf("%dh ago", hours)
+ } else {
+ days := int(duration.Hours() / 24)
+ return fmt.Sprintf("%dd ago", days)
+ }
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/maintenance_workers.templ b/weed/admin/view/app/maintenance_workers.templ
new file mode 100644
index 000000000..bfaaa7061
--- /dev/null
+++ b/weed/admin/view/app/maintenance_workers.templ
@@ -0,0 +1,340 @@
+package app
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+ "time"
+)
+
+templ MaintenanceWorkers(data *dash.MaintenanceWorkersData) {
+ <div class="container-fluid">
+ <div class="row">
+ <div class="col-12">
+ <div class="d-flex justify-content-between align-items-center mb-4">
+ <div>
+ <h1 class="h3 mb-0 text-gray-800">Maintenance Workers</h1>
+ <p class="text-muted">Monitor and manage maintenance workers</p>
+ </div>
+ <div class="text-end">
+ <small class="text-muted">Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") }</small>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Summary Cards -->
+ <div class="row mb-4">
+ <div class="col-xl-3 col-md-6 mb-4">
+ <div class="card border-left-primary shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
+ Total Workers
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">{ fmt.Sprintf("%d", len(data.Workers)) }</div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-users fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-3 col-md-6 mb-4">
+ <div class="card border-left-success shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-success text-uppercase mb-1">
+ Active Workers
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ { fmt.Sprintf("%d", data.ActiveWorkers) }
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-check-circle fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-3 col-md-6 mb-4">
+ <div class="card border-left-info shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-info text-uppercase mb-1">
+ Busy Workers
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ { fmt.Sprintf("%d", data.BusyWorkers) }
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-spinner fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-xl-3 col-md-6 mb-4">
+ <div class="card border-left-warning shadow h-100 py-2">
+ <div class="card-body">
+ <div class="row no-gutters align-items-center">
+ <div class="col mr-2">
+ <div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
+ Total Load
+ </div>
+ <div class="h5 mb-0 font-weight-bold text-gray-800">
+ { fmt.Sprintf("%d", data.TotalLoad) }
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fas fa-tasks fa-2x text-gray-300"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Workers Table -->
+ <div class="row">
+ <div class="col-12">
+ <div class="card shadow mb-4">
+ <div class="card-header py-3">
+ <h6 class="m-0 font-weight-bold text-primary">Worker Details</h6>
+ </div>
+ <div class="card-body">
+ if len(data.Workers) == 0 {
+ <div class="text-center py-4">
+ <i class="fas fa-users fa-3x text-gray-300 mb-3"></i>
+ <h5 class="text-gray-600">No Workers Found</h5>
+ <p class="text-muted">No maintenance workers are currently registered.</p>
+ <div class="alert alert-info mt-3">
+ <strong>💡 Tip:</strong> To start a worker, run:
+ <br><code>weed worker -admin=&lt;admin_server&gt; -capabilities=vacuum,ec,replication</code>
+ </div>
+ </div>
+ } else {
+ <div class="table-responsive">
+ <table class="table table-bordered table-hover" id="workersTable">
+ <thead class="table-light">
+ <tr>
+ <th>Worker ID</th>
+ <th>Address</th>
+ <th>Status</th>
+ <th>Capabilities</th>
+ <th>Load</th>
+ <th>Current Tasks</th>
+ <th>Performance</th>
+ <th>Last Heartbeat</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ for _, worker := range data.Workers {
+ <tr>
+ <td>
+ <code>{ worker.Worker.ID }</code>
+ </td>
+ <td>
+ <code>{ worker.Worker.Address }</code>
+ </td>
+ <td>
+ if worker.Worker.Status == "active" {
+ <span class="badge bg-success">Active</span>
+ } else if worker.Worker.Status == "busy" {
+ <span class="badge bg-warning">Busy</span>
+ } else {
+ <span class="badge bg-danger">Inactive</span>
+ }
+ </td>
+ <td>
+ <div class="d-flex flex-wrap gap-1">
+ for _, capability := range worker.Worker.Capabilities {
+ <span class="badge bg-secondary rounded-pill">{ string(capability) }</span>
+ }
+ </div>
+ </td>
+ <td>
+ <div class="progress" style="height: 20px;">
+ if worker.Worker.MaxConcurrent > 0 {
+ <div class="progress-bar" role="progressbar"
+ style={ fmt.Sprintf("width: %d%%", (worker.Worker.CurrentLoad*100)/worker.Worker.MaxConcurrent) }
+ aria-valuenow={ fmt.Sprintf("%d", worker.Worker.CurrentLoad) }
+ aria-valuemin="0"
+ aria-valuemax={ fmt.Sprintf("%d", worker.Worker.MaxConcurrent) }>
+ { fmt.Sprintf("%d/%d", worker.Worker.CurrentLoad, worker.Worker.MaxConcurrent) }
+ </div>
+ } else {
+ <div class="progress-bar" role="progressbar" style="width: 0%">0/0</div>
+ }
+ </div>
+ </td>
+ <td>
+ { fmt.Sprintf("%d", len(worker.CurrentTasks)) }
+ </td>
+ <td>
+ <small>
+ <div>✅ { fmt.Sprintf("%d", worker.Performance.TasksCompleted) }</div>
+ <div>❌ { fmt.Sprintf("%d", worker.Performance.TasksFailed) }</div>
+ <div>📊 { fmt.Sprintf("%.1f%%", worker.Performance.SuccessRate) }</div>
+ </small>
+ </td>
+ <td>
+ if time.Since(worker.Worker.LastHeartbeat) < 2*time.Minute {
+ <span class="text-success">
+ <i class="fas fa-heartbeat"></i>
+ { worker.Worker.LastHeartbeat.Format("15:04:05") }
+ </span>
+ } else {
+ <span class="text-danger">
+ <i class="fas fa-exclamation-triangle"></i>
+ { worker.Worker.LastHeartbeat.Format("15:04:05") }
+ </span>
+ }
+ </td>
+ <td>
+ <div class="btn-group btn-group-sm" role="group">
+ <button type="button" class="btn btn-outline-info" onclick={ templ.ComponentScript{Call: "showWorkerDetails"} } data-worker-id={ worker.Worker.ID }>
+ <i class="fas fa-info-circle"></i>
+ </button>
+ if worker.Worker.Status == "active" {
+ <button type="button" class="btn btn-outline-warning" onclick={ templ.ComponentScript{Call: "pauseWorker"} } data-worker-id={ worker.Worker.ID }>
+ <i class="fas fa-pause"></i>
+ </button>
+ }
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Worker Details Modal -->
+ <div class="modal fade" id="workerDetailsModal" tabindex="-1" aria-labelledby="workerDetailsModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="workerDetailsModalLabel">Worker Details</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body" id="workerDetailsContent">
+ <!-- Content will be loaded dynamically -->
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ function showWorkerDetails(event) {
+ const workerID = event.target.closest('button').getAttribute('data-worker-id');
+
+ // Show modal
+ var modal = new bootstrap.Modal(document.getElementById('workerDetailsModal'));
+
+ // Load worker details
+ fetch(`/api/maintenance/workers/${workerID}`)
+ .then(response => response.json())
+ .then(data => {
+ const content = document.getElementById('workerDetailsContent');
+ content.innerHTML = `
+ <div class="row">
+ <div class="col-md-6">
+ <h6>Worker Information</h6>
+ <ul class="list-unstyled">
+ <li><strong>ID:</strong> ${data.worker.id}</li>
+ <li><strong>Address:</strong> ${data.worker.address}</li>
+ <li><strong>Status:</strong> ${data.worker.status}</li>
+ <li><strong>Max Concurrent:</strong> ${data.worker.max_concurrent}</li>
+ <li><strong>Current Load:</strong> ${data.worker.current_load}</li>
+ </ul>
+ </div>
+ <div class="col-md-6">
+ <h6>Performance Metrics</h6>
+ <ul class="list-unstyled">
+ <li><strong>Tasks Completed:</strong> ${data.performance.tasks_completed}</li>
+ <li><strong>Tasks Failed:</strong> ${data.performance.tasks_failed}</li>
+ <li><strong>Success Rate:</strong> ${data.performance.success_rate.toFixed(1)}%</li>
+ <li><strong>Average Task Time:</strong> ${formatDuration(data.performance.average_task_time)}</li>
+ <li><strong>Uptime:</strong> ${formatDuration(data.performance.uptime)}</li>
+ </ul>
+ </div>
+ </div>
+ <hr>
+ <h6>Current Tasks</h6>
+ ${data.current_tasks.length === 0 ?
+ '<p class="text-muted">No current tasks</p>' :
+ data.current_tasks.map(task => `
+ <div class="card mb-2">
+ <div class="card-body py-2">
+ <div class="d-flex justify-content-between">
+ <span><strong>${task.type}</strong> - Volume ${task.volume_id}</span>
+ <span class="badge bg-info">${task.status}</span>
+ </div>
+ <small class="text-muted">${task.reason}</small>
+ </div>
+ </div>
+ `).join('')
+ }
+ `;
+ modal.show();
+ })
+ .catch(error => {
+ console.error('Error loading worker details:', error);
+ const content = document.getElementById('workerDetailsContent');
+ content.innerHTML = '<div class="alert alert-danger">Failed to load worker details</div>';
+ modal.show();
+ });
+ }
+
+ function pauseWorker(event) {
+ const workerID = event.target.closest('button').getAttribute('data-worker-id');
+
+ if (confirm('Are you sure you want to pause this worker?')) {
+ fetch(`/api/maintenance/workers/${workerID}/pause`, {
+ method: 'POST'
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ location.reload();
+ } else {
+ alert('Failed to pause worker: ' + data.error);
+ }
+ })
+ .catch(error => {
+ console.error('Error pausing worker:', error);
+ alert('Failed to pause worker');
+ });
+ }
+ }
+
+ function formatDuration(nanoseconds) {
+ const seconds = Math.floor(nanoseconds / 1000000000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+
+ if (hours > 0) {
+ return `${hours}h ${minutes % 60}m`;
+ } else if (minutes > 0) {
+ return `${minutes}m ${seconds % 60}s`;
+ } else {
+ return `${seconds}s`;
+ }
+ }
+ </script>
+} \ No newline at end of file
diff --git a/weed/admin/view/app/maintenance_workers_templ.go b/weed/admin/view/app/maintenance_workers_templ.go
new file mode 100644
index 000000000..1cfe112a5
--- /dev/null
+++ b/weed/admin/view/app/maintenance_workers_templ.go
@@ -0,0 +1,431 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+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/dash"
+ "time"
+)
+
+func MaintenanceWorkers(data *dash.MaintenanceWorkersData) 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, "<div class=\"container-fluid\"><div class=\"row\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center mb-4\"><div><h1 class=\"h3 mb-0 text-gray-800\">Maintenance Workers</h1><p class=\"text-muted\">Monitor and manage maintenance workers</p></div><div class=\"text-end\"><small class=\"text-muted\">Last updated: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 19, Col: 112}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</small></div></div></div></div><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Workers</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(data.Workers)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 35, Col: 122}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-users fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Active Workers</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveWorkers))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 54, Col: 75}
+ }
+ _, 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, "</div></div><div class=\"col-auto\"><i class=\"fas fa-check-circle fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Busy Workers</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.BusyWorkers))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 74, Col: 73}
+ }
+ _, 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, "</div></div><div class=\"col-auto\"><i class=\"fas fa-spinner fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-warning shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Total Load</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalLoad))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 94, Col: 71}
+ }
+ _, 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, "</div></div><div class=\"col-auto\"><i class=\"fas fa-tasks fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Workers Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\">Worker Details</h6></div><div class=\"card-body\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Workers) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"text-center py-4\"><i class=\"fas fa-users fa-3x text-gray-300 mb-3\"></i><h5 class=\"text-gray-600\">No Workers Found</h5><p class=\"text-muted\">No maintenance workers are currently registered.</p><div class=\"alert alert-info mt-3\"><strong>💡 Tip:</strong> To start a worker, run:<br><code>weed worker -admin=&lt;admin_server&gt; -capabilities=vacuum,ec,replication</code></div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"table-responsive\"><table class=\"table table-bordered table-hover\" id=\"workersTable\"><thead class=\"table-light\"><tr><th>Worker ID</th><th>Address</th><th>Status</th><th>Capabilities</th><th>Load</th><th>Current Tasks</th><th>Performance</th><th>Last Heartbeat</th><th>Actions</th></tr></thead> <tbody>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, worker := range data.Workers {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tr><td><code>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Worker.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 144, Col: 76}
+ }
+ _, 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, 10, "</code></td><td><code>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Worker.Address)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 147, Col: 81}
+ }
+ _, 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, 11, "</code></td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if worker.Worker.Status == "active" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"badge bg-success\">Active</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if worker.Worker.Status == "busy" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"badge bg-warning\">Busy</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"badge bg-danger\">Inactive</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td><div class=\"d-flex flex-wrap gap-1\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, capability := range worker.Worker.Capabilities {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"badge bg-secondary rounded-pill\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(string(capability))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 161, Col: 126}
+ }
+ _, 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, 17, "</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></td><td><div class=\"progress\" style=\"height: 20px;\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if worker.Worker.MaxConcurrent > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"progress-bar\" role=\"progressbar\" style=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%", (worker.Worker.CurrentLoad*100)/worker.Worker.MaxConcurrent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 169, Col: 160}
+ }
+ _, 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, 20, "\" aria-valuenow=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", worker.Worker.CurrentLoad))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 170, Col: 125}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" aria-valuemin=\"0\" aria-valuemax=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", worker.Worker.MaxConcurrent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 172, Col: 127}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", worker.Worker.CurrentLoad, worker.Worker.MaxConcurrent))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 173, Col: 142}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"progress-bar\" role=\"progressbar\" style=\"width: 0%\">0/0</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(worker.CurrentTasks)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 181, Col: 97}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td><small><div>✅ ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", worker.Performance.TasksCompleted))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 185, Col: 119}
+ }
+ _, 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, 27, "</div><div>❌ ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", worker.Performance.TasksFailed))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 186, Col: 116}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div>📊 ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%%", worker.Performance.SuccessRate))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 187, Col: 121}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div></small></td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if time.Since(worker.Worker.LastHeartbeat) < 2*time.Minute {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"text-success\"><i class=\"fas fa-heartbeat\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Worker.LastHeartbeat.Format("15:04:05"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 194, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"text-danger\"><i class=\"fas fa-exclamation-triangle\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Worker.LastHeartbeat.Format("15:04:05"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 199, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td><div class=\"btn-group btn-group-sm\" role=\"group\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: "showWorkerDetails"})
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button type=\"button\" class=\"btn btn-outline-info\" onclick=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 templ.ComponentScript = templ.ComponentScript{Call: "showWorkerDetails"}
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20.Call)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" data-worker-id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Worker.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 205, Col: 201}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\"><i class=\"fas fa-info-circle\"></i></button> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if worker.Worker.Status == "active" {
+ templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: "pauseWorker"})
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<button type=\"button\" class=\"btn btn-outline-warning\" onclick=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 templ.ComponentScript = templ.ComponentScript{Call: "pauseWorker"}
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22.Call)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" data-worker-id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(worker.Worker.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/maintenance_workers.templ`, Line: 209, Col: 202}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"><i class=\"fas fa-pause\"></i></button>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></td></tr>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</tbody></table></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div></div></div></div><!-- Worker Details Modal --><div class=\"modal fade\" id=\"workerDetailsModal\" tabindex=\"-1\" aria-labelledby=\"workerDetailsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"workerDetailsModalLabel\">Worker Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\" id=\"workerDetailsContent\"><!-- Content will be loaded dynamically --></div></div></div></div><script>\n function showWorkerDetails(event) {\n const workerID = event.target.closest('button').getAttribute('data-worker-id');\n \n // Show modal\n var modal = new bootstrap.Modal(document.getElementById('workerDetailsModal'));\n \n // Load worker details\n fetch(`/api/maintenance/workers/${workerID}`)\n .then(response => response.json())\n .then(data => {\n const content = document.getElementById('workerDetailsContent');\n content.innerHTML = `\n <div class=\"row\">\n <div class=\"col-md-6\">\n <h6>Worker Information</h6>\n <ul class=\"list-unstyled\">\n <li><strong>ID:</strong> ${data.worker.id}</li>\n <li><strong>Address:</strong> ${data.worker.address}</li>\n <li><strong>Status:</strong> ${data.worker.status}</li>\n <li><strong>Max Concurrent:</strong> ${data.worker.max_concurrent}</li>\n <li><strong>Current Load:</strong> ${data.worker.current_load}</li>\n </ul>\n </div>\n <div class=\"col-md-6\">\n <h6>Performance Metrics</h6>\n <ul class=\"list-unstyled\">\n <li><strong>Tasks Completed:</strong> ${data.performance.tasks_completed}</li>\n <li><strong>Tasks Failed:</strong> ${data.performance.tasks_failed}</li>\n <li><strong>Success Rate:</strong> ${data.performance.success_rate.toFixed(1)}%</li>\n <li><strong>Average Task Time:</strong> ${formatDuration(data.performance.average_task_time)}</li>\n <li><strong>Uptime:</strong> ${formatDuration(data.performance.uptime)}</li>\n </ul>\n </div>\n </div>\n <hr>\n <h6>Current Tasks</h6>\n ${data.current_tasks.length === 0 ? \n '<p class=\"text-muted\">No current tasks</p>' :\n data.current_tasks.map(task => `\n <div class=\"card mb-2\">\n <div class=\"card-body py-2\">\n <div class=\"d-flex justify-content-between\">\n <span><strong>${task.type}</strong> - Volume ${task.volume_id}</span>\n <span class=\"badge bg-info\">${task.status}</span>\n </div>\n <small class=\"text-muted\">${task.reason}</small>\n </div>\n </div>\n `).join('')\n }\n `;\n modal.show();\n })\n .catch(error => {\n console.error('Error loading worker details:', error);\n const content = document.getElementById('workerDetailsContent');\n content.innerHTML = '<div class=\"alert alert-danger\">Failed to load worker details</div>';\n modal.show();\n });\n }\n\n function pauseWorker(event) {\n const workerID = event.target.closest('button').getAttribute('data-worker-id');\n \n if (confirm('Are you sure you want to pause this worker?')) {\n fetch(`/api/maintenance/workers/${workerID}/pause`, {\n method: 'POST'\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n location.reload();\n } else {\n alert('Failed to pause worker: ' + data.error);\n }\n })\n .catch(error => {\n console.error('Error pausing worker:', error);\n alert('Failed to pause worker');\n });\n }\n }\n\n function formatDuration(nanoseconds) {\n const seconds = Math.floor(nanoseconds / 1000000000);\n const minutes = Math.floor(seconds / 60);\n const hours = Math.floor(minutes / 60);\n \n if (hours > 0) {\n return `${hours}h ${minutes % 60}m`;\n } else if (minutes > 0) {\n return `${minutes}m ${seconds % 60}s`;\n } else {\n return `${seconds}s`;\n }\n }\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/task_config.templ b/weed/admin/view/app/task_config.templ
new file mode 100644
index 000000000..81e089de6
--- /dev/null
+++ b/weed/admin/view/app/task_config.templ
@@ -0,0 +1,160 @@
+package app
+
+import (
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+)
+
+templ TaskConfig(data *maintenance.TaskConfigData) {
+ <div class="container-fluid">
+ <div class="row mb-4">
+ <div class="col-12">
+ <div class="d-flex justify-content-between align-items-center">
+ <h2 class="mb-0">
+ <i class={data.TaskIcon + " me-2"}></i>
+ {data.TaskName} Configuration
+ </h2>
+ <div class="btn-group">
+ <a href="/maintenance/config" class="btn btn-outline-secondary">
+ <i class="fas fa-arrow-left me-1"></i>
+ Back to Configuration
+ </a>
+ <a href="/maintenance" class="btn btn-outline-primary">
+ <i class="fas fa-list me-1"></i>
+ View Queue
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class={data.TaskIcon + " me-2"}></i>
+ {data.TaskName} Settings
+ </h5>
+ </div>
+ <div class="card-body">
+ <p class="text-muted mb-4">{data.Description}</p>
+
+ <!-- Task-specific configuration form -->
+ <form method="POST">
+ <div class="task-config-form">
+ @templ.Raw(string(data.ConfigFormHTML))
+ </div>
+
+ <hr class="my-4">
+
+ <div class="d-flex gap-2">
+ <button type="submit" class="btn btn-primary">
+ <i class="fas fa-save me-1"></i>
+ Save Configuration
+ </button>
+ <button type="button" class="btn btn-secondary" onclick="resetForm()">
+ <i class="fas fa-undo me-1"></i>
+ Reset to Defaults
+ </button>
+ <a href="/maintenance/config" class="btn btn-outline-secondary">
+ <i class="fas fa-times me-1"></i>
+ Cancel
+ </a>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Task Information -->
+ <div class="row mt-4">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-info-circle me-2"></i>
+ Task Information
+ </h5>
+ </div>
+ <div class="card-body">
+ <div class="row">
+ <div class="col-md-6">
+ <h6 class="text-muted">Task Type</h6>
+ <p class="mb-3">
+ <span class="badge bg-secondary">{string(data.TaskType)}</span>
+ </p>
+ </div>
+ <div class="col-md-6">
+ <h6 class="text-muted">Display Name</h6>
+ <p class="mb-3">{data.TaskName}</p>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-12">
+ <h6 class="text-muted">Description</h6>
+ <p class="mb-0">{data.Description}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ function resetForm() {
+ if (confirm('Are you sure you want to reset all settings to their default values?')) {
+ // Find all form inputs and reset them
+ const form = document.querySelector('form');
+ if (form) {
+ form.reset();
+ }
+ }
+ }
+
+ // Auto-save form data to localStorage for recovery
+ document.addEventListener('DOMContentLoaded', function() {
+ const form = document.querySelector('form');
+ if (form) {
+ const taskType = '{string(data.TaskType)}';
+ const storageKey = 'taskConfig_' + taskType;
+
+ // Load saved data
+ const savedData = localStorage.getItem(storageKey);
+ if (savedData) {
+ try {
+ const data = JSON.parse(savedData);
+ Object.keys(data).forEach(key => {
+ const input = form.querySelector(`[name="${key}"]`);
+ if (input) {
+ if (input.type === 'checkbox') {
+ input.checked = data[key];
+ } else {
+ input.value = data[key];
+ }
+ }
+ });
+ } catch (e) {
+ console.warn('Failed to load saved configuration:', e);
+ }
+ }
+
+ // Save data on input change
+ form.addEventListener('input', function() {
+ const formData = new FormData(form);
+ const data = {};
+ for (let [key, value] of formData.entries()) {
+ data[key] = value;
+ }
+ localStorage.setItem(storageKey, JSON.stringify(data));
+ });
+
+ // Clear saved data on successful submit
+ form.addEventListener('submit', function() {
+ localStorage.removeItem(storageKey);
+ });
+ }
+ });
+ </script>
+} \ No newline at end of file
diff --git a/weed/admin/view/app/task_config_templ.go b/weed/admin/view/app/task_config_templ.go
new file mode 100644
index 000000000..5465df8a6
--- /dev/null
+++ b/weed/admin/view/app/task_config_templ.go
@@ -0,0 +1,174 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+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 (
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+)
+
+func TaskConfig(data *maintenance.TaskConfigData) 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, "<div class=\"container-fluid\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 = []any{data.TaskIcon + " 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, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 14, Col: 38}
+ }
+ _, 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</h2><div class=\"btn-group\"><a href=\"/maintenance/config\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i> Back to Configuration</a> <a href=\"/maintenance\" class=\"btn btn-outline-primary\"><i class=\"fas fa-list me-1\"></i> View Queue</a></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 = []any{data.TaskIcon + " me-2"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 1, Col: 0}
+ }
+ _, 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, "\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 36, Col: 42}
+ }
+ _, 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, 7, " Settings</h5></div><div class=\"card-body\"><p class=\"text-muted mb-4\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 40, Col: 68}
+ }
+ _, 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, 8, "</p><!-- Task-specific configuration form --><form method=\"POST\"><div class=\"task-config-form\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(string(data.ConfigFormHTML)).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div><hr class=\"my-4\"><div class=\"d-flex gap-2\"><button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i> Save Configuration</button> <button type=\"button\" class=\"btn btn-secondary\" onclick=\"resetForm()\"><i class=\"fas fa-undo me-1\"></i> Reset to Defaults</button> <a href=\"/maintenance/config\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-times me-1\"></i> Cancel</a></div></form></div></div></div></div><!-- Task Information --><div class=\"row mt-4\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"fas fa-info-circle me-2\"></i> Task Information</h5></div><div class=\"card-body\"><div class=\"row\"><div class=\"col-md-6\"><h6 class=\"text-muted\">Task Type</h6><p class=\"mb-3\"><span class=\"badge bg-secondary\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(string(data.TaskType))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 85, Col: 91}
+ }
+ _, 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, 10, "</span></p></div><div class=\"col-md-6\"><h6 class=\"text-muted\">Display Name</h6><p class=\"mb-3\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 90, Col: 62}
+ }
+ _, 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, 11, "</p></div></div><div class=\"row\"><div class=\"col-12\"><h6 class=\"text-muted\">Description</h6><p class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config.templ`, Line: 96, Col: 65}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</p></div></div></div></div></div></div></div><script>\n function resetForm() {\n if (confirm('Are you sure you want to reset all settings to their default values?')) {\n // Find all form inputs and reset them\n const form = document.querySelector('form');\n if (form) {\n form.reset();\n }\n }\n }\n\n // Auto-save form data to localStorage for recovery\n document.addEventListener('DOMContentLoaded', function() {\n const form = document.querySelector('form');\n if (form) {\n const taskType = '{string(data.TaskType)}';\n const storageKey = 'taskConfig_' + taskType;\n\n // Load saved data\n const savedData = localStorage.getItem(storageKey);\n if (savedData) {\n try {\n const data = JSON.parse(savedData);\n Object.keys(data).forEach(key => {\n const input = form.querySelector(`[name=\"${key}\"]`);\n if (input) {\n if (input.type === 'checkbox') {\n input.checked = data[key];\n } else {\n input.value = data[key];\n }\n }\n });\n } catch (e) {\n console.warn('Failed to load saved configuration:', e);\n }\n }\n\n // Save data on input change\n form.addEventListener('input', function() {\n const formData = new FormData(form);\n const data = {};\n for (let [key, value] of formData.entries()) {\n data[key] = value;\n }\n localStorage.setItem(storageKey, JSON.stringify(data));\n });\n\n // Clear saved data on successful submit\n form.addEventListener('submit', function() {\n localStorage.removeItem(storageKey);\n });\n }\n });\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/task_config_templ.templ b/weed/admin/view/app/task_config_templ.templ
new file mode 100644
index 000000000..010f5782c
--- /dev/null
+++ b/weed/admin/view/app/task_config_templ.templ
@@ -0,0 +1,160 @@
+package app
+
+import (
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+)
+
+// TaskConfigTemplData represents data for templ-based task configuration
+type TaskConfigTemplData struct {
+ TaskType maintenance.MaintenanceTaskType
+ TaskName string
+ TaskIcon string
+ Description string
+ ConfigSections []components.ConfigSectionData
+}
+
+templ TaskConfigTempl(data *TaskConfigTemplData) {
+ <div class="container-fluid">
+ <div class="row mb-4">
+ <div class="col-12">
+ <div class="d-flex justify-content-between align-items-center">
+ <h2 class="mb-0">
+ <i class={data.TaskIcon + " me-2"}></i>
+ {data.TaskName} Configuration
+ </h2>
+ <div class="btn-group">
+ <a href="/maintenance/config" class="btn btn-outline-secondary">
+ <i class="fas fa-arrow-left me-1"></i>
+ Back to Configuration
+ </a>
+ <a href="/maintenance/queue" class="btn btn-outline-info">
+ <i class="fas fa-list me-1"></i>
+ View Queue
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row mb-4">
+ <div class="col-12">
+ <div class="alert alert-info" role="alert">
+ <i class="fas fa-info-circle me-2"></i>
+ {data.Description}
+ </div>
+ </div>
+ </div>
+
+ <form method="POST" class="needs-validation" novalidate>
+ <!-- Render all configuration sections -->
+ for _, section := range data.ConfigSections {
+ @components.ConfigSection(section)
+ }
+
+ <!-- Form actions -->
+ <div class="row">
+ <div class="col-12">
+ <div class="card">
+ <div class="card-body">
+ <div class="d-flex justify-content-between">
+ <div>
+ <button type="submit" class="btn btn-primary">
+ <i class="fas fa-save me-1"></i>
+ Save Configuration
+ </button>
+ <button type="button" class="btn btn-outline-secondary ms-2" onclick="resetForm()">
+ <i class="fas fa-undo me-1"></i>
+ Reset
+ </button>
+ </div>
+ <div>
+ <button type="button" class="btn btn-outline-info" onclick="testConfiguration()">
+ <i class="fas fa-play me-1"></i>
+ Test Configuration
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+
+ <script>
+ // Form validation
+ (function() {
+ 'use strict';
+ window.addEventListener('load', function() {
+ var forms = document.getElementsByClassName('needs-validation');
+ var validation = Array.prototype.filter.call(forms, function(form) {
+ form.addEventListener('submit', function(event) {
+ if (form.checkValidity() === false) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ form.classList.add('was-validated');
+ }, false);
+ });
+ }, false);
+ })();
+
+ // Auto-save functionality
+ let autoSaveTimeout;
+ function autoSave() {
+ clearTimeout(autoSaveTimeout);
+ autoSaveTimeout = setTimeout(function() {
+ const formData = new FormData(document.querySelector('form'));
+ localStorage.setItem('task_config_' + '{data.TaskType}', JSON.stringify(Object.fromEntries(formData)));
+ }, 1000);
+ }
+
+ // Add auto-save listeners to all form inputs
+ document.addEventListener('DOMContentLoaded', function() {
+ const form = document.querySelector('form');
+ if (form) {
+ form.addEventListener('input', autoSave);
+ form.addEventListener('change', autoSave);
+ }
+ });
+
+ // Reset form function
+ function resetForm() {
+ if (confirm('Are you sure you want to reset all changes?')) {
+ location.reload();
+ }
+ }
+
+ // Test configuration function
+ function testConfiguration() {
+ const formData = new FormData(document.querySelector('form'));
+
+ // Show loading state
+ const testBtn = document.querySelector('button[onclick="testConfiguration()"]');
+ const originalContent = testBtn.innerHTML;
+ testBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Testing...';
+ testBtn.disabled = true;
+
+ fetch('/maintenance/config/{data.TaskType}/test', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ alert('Configuration test successful!');
+ } else {
+ alert('Configuration test failed: ' + data.error);
+ }
+ })
+ .catch(error => {
+ alert('Test failed: ' + error);
+ })
+ .finally(() => {
+ testBtn.innerHTML = originalContent;
+ testBtn.disabled = false;
+ });
+ }
+ </script>
+} \ No newline at end of file
diff --git a/weed/admin/view/app/task_config_templ_templ.go b/weed/admin/view/app/task_config_templ_templ.go
new file mode 100644
index 000000000..c900f132f
--- /dev/null
+++ b/weed/admin/view/app/task_config_templ_templ.go
@@ -0,0 +1,112 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+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 (
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+)
+
+// TaskConfigTemplData represents data for templ-based task configuration
+type TaskConfigTemplData struct {
+ TaskType maintenance.MaintenanceTaskType
+ TaskName string
+ TaskIcon string
+ Description string
+ ConfigSections []components.ConfigSectionData
+}
+
+func TaskConfigTempl(data *TaskConfigTemplData) 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, "<div class=\"container-fluid\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center\"><h2 class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 = []any{data.TaskIcon + " 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, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_templ.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.TaskName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_templ.templ`, Line: 24, Col: 38}
+ }
+ _, 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</h2><div class=\"btn-group\"><a href=\"/maintenance/config\" class=\"btn btn-outline-secondary\"><i class=\"fas fa-arrow-left me-1\"></i> Back to Configuration</a> <a href=\"/maintenance/queue\" class=\"btn btn-outline-info\"><i class=\"fas fa-list me-1\"></i> View Queue</a></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"alert alert-info\" role=\"alert\"><i class=\"fas fa-info-circle me-2\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/task_config_templ.templ`, Line: 44, Col: 37}
+ }
+ _, 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, "</div></div></div><form method=\"POST\" class=\"needs-validation\" novalidate><!-- Render all configuration sections -->")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, section := range data.ConfigSections {
+ templ_7745c5c3_Err = components.ConfigSection(section).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Form actions --><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-body\"><div class=\"d-flex justify-content-between\"><div><button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-save me-1\"></i> Save Configuration</button> <button type=\"button\" class=\"btn btn-outline-secondary ms-2\" onclick=\"resetForm()\"><i class=\"fas fa-undo me-1\"></i> Reset</button></div><div><button type=\"button\" class=\"btn btn-outline-info\" onclick=\"testConfiguration()\"><i class=\"fas fa-play me-1\"></i> Test Configuration</button></div></div></div></div></div></div></form></div><script>\n // Form validation\n (function() {\n 'use strict';\n window.addEventListener('load', function() {\n var forms = document.getElementsByClassName('needs-validation');\n var validation = Array.prototype.filter.call(forms, function(form) {\n form.addEventListener('submit', function(event) {\n if (form.checkValidity() === false) {\n event.preventDefault();\n event.stopPropagation();\n }\n form.classList.add('was-validated');\n }, false);\n });\n }, false);\n })();\n\n // Auto-save functionality\n let autoSaveTimeout;\n function autoSave() {\n clearTimeout(autoSaveTimeout);\n autoSaveTimeout = setTimeout(function() {\n const formData = new FormData(document.querySelector('form'));\n localStorage.setItem('task_config_' + '{data.TaskType}', JSON.stringify(Object.fromEntries(formData)));\n }, 1000);\n }\n\n // Add auto-save listeners to all form inputs\n document.addEventListener('DOMContentLoaded', function() {\n const form = document.querySelector('form');\n if (form) {\n form.addEventListener('input', autoSave);\n form.addEventListener('change', autoSave);\n }\n });\n\n // Reset form function\n function resetForm() {\n if (confirm('Are you sure you want to reset all changes?')) {\n location.reload();\n }\n }\n\n // Test configuration function\n function testConfiguration() {\n const formData = new FormData(document.querySelector('form'));\n \n // Show loading state\n const testBtn = document.querySelector('button[onclick=\"testConfiguration()\"]');\n const originalContent = testBtn.innerHTML;\n testBtn.innerHTML = '<i class=\"fas fa-spinner fa-spin me-1\"></i>Testing...';\n testBtn.disabled = true;\n \n fetch('/maintenance/config/{data.TaskType}/test', {\n method: 'POST',\n body: formData\n })\n .then(response => response.json())\n .then(data => {\n if (data.success) {\n alert('Configuration test successful!');\n } else {\n alert('Configuration test failed: ' + data.error);\n }\n })\n .catch(error => {\n alert('Test failed: ' + error);\n })\n .finally(() => {\n testBtn.innerHTML = originalContent;\n testBtn.disabled = false;\n });\n }\n </script>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/components/config_sections.templ b/weed/admin/view/components/config_sections.templ
new file mode 100644
index 000000000..813f4ba67
--- /dev/null
+++ b/weed/admin/view/components/config_sections.templ
@@ -0,0 +1,83 @@
+package components
+
+
+
+// ConfigSectionData represents data for a configuration section
+type ConfigSectionData struct {
+ Title string
+ Icon string
+ Description string
+ Fields []interface{} // Will hold field data structures
+}
+
+// InfoSectionData represents data for an informational section
+type InfoSectionData struct {
+ Title string
+ Icon string
+ Type string // "info", "warning", "success", "danger"
+ Content string
+}
+
+// ConfigSection renders a Bootstrap card for configuration settings
+templ ConfigSection(data ConfigSectionData) {
+ <div class="row">
+ <div class="col-12">
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">
+ if data.Icon != "" {
+ <i class={ data.Icon + " me-2" }></i>
+ }
+ { data.Title }
+ </h5>
+ if data.Description != "" {
+ <small class="text-muted">{ data.Description }</small>
+ }
+ </div>
+ <div class="card-body">
+ for _, field := range data.Fields {
+ switch v := field.(type) {
+ case TextFieldData:
+ @TextField(v)
+ case NumberFieldData:
+ @NumberField(v)
+ case CheckboxFieldData:
+ @CheckboxField(v)
+ case SelectFieldData:
+ @SelectField(v)
+ case DurationFieldData:
+ @DurationField(v)
+ case DurationInputFieldData:
+ @DurationInputField(v)
+ }
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+}
+
+// InfoSection renders a Bootstrap alert section for informational content
+templ InfoSection(data InfoSectionData) {
+ <div class="row">
+ <div class="col-12">
+ <div class="card mb-3">
+ <div class="card-header">
+ <h5 class="mb-0">
+ if data.Icon != "" {
+ <i class={ data.Icon + " me-2" }></i>
+ }
+ { data.Title }
+ </h5>
+ </div>
+ <div class="card-body">
+ <div class={ "alert alert-" + data.Type } role="alert">
+ {data.Content}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+}
+
+ \ No newline at end of file
diff --git a/weed/admin/view/components/config_sections_templ.go b/weed/admin/view/components/config_sections_templ.go
new file mode 100644
index 000000000..3385050e5
--- /dev/null
+++ b/weed/admin/view/components/config_sections_templ.go
@@ -0,0 +1,257 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+package components
+
+//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"
+
+// ConfigSectionData represents data for a configuration section
+type ConfigSectionData struct {
+ Title string
+ Icon string
+ Description string
+ Fields []interface{} // Will hold field data structures
+}
+
+// InfoSectionData represents data for an informational section
+type InfoSectionData struct {
+ Title string
+ Icon string
+ Type string // "info", "warning", "success", "danger"
+ Content string
+}
+
+// ConfigSection renders a Bootstrap card for configuration settings
+func ConfigSection(data ConfigSectionData) 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, "<div class=\"row\"><div class=\"col-12\"><div class=\"card mb-4\"><div class=\"card-header\"><h5 class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Icon != "" {
+ var templ_7745c5c3_Var2 = []any{data.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, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 31, Col: 36}
+ }
+ _, 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, "</h5>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<small class=\"text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 34, Col: 68}
+ }
+ _, 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, 6, "</small>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"card-body\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, field := range data.Fields {
+ switch v := field.(type) {
+ case TextFieldData:
+ templ_7745c5c3_Err = TextField(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case NumberFieldData:
+ templ_7745c5c3_Err = NumberField(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case CheckboxFieldData:
+ templ_7745c5c3_Err = CheckboxField(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case SelectFieldData:
+ templ_7745c5c3_Err = SelectField(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case DurationFieldData:
+ templ_7745c5c3_Err = DurationField(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ case DurationInputFieldData:
+ templ_7745c5c3_Err = DurationInputField(v).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></div></div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// InfoSection renders a Bootstrap alert section for informational content
+func InfoSection(data InfoSectionData) 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_Var6 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var6 == nil {
+ templ_7745c5c3_Var6 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"row\"><div class=\"col-12\"><div class=\"card mb-3\"><div class=\"card-header\"><h5 class=\"mb-0\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Icon != "" {
+ var templ_7745c5c3_Var7 = []any{data.Icon + " me-2"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 1, Col: 0}
+ }
+ _, 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, 11, "\"></i> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 70, Col: 36}
+ }
+ _, 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, 12, "</h5></div><div class=\"card-body\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 = []any{"alert alert-" + data.Type}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" role=\"alert\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.Content)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/config_sections.templ`, Line: 75, Col: 37}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div></div></div></div></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/components/form_fields.templ b/weed/admin/view/components/form_fields.templ
new file mode 100644
index 000000000..5ac5c9241
--- /dev/null
+++ b/weed/admin/view/components/form_fields.templ
@@ -0,0 +1,306 @@
+package components
+
+import "fmt"
+
+// FormFieldData represents common form field data
+type FormFieldData struct {
+ Name string
+ Label string
+ Description string
+ Required bool
+}
+
+// TextFieldData represents text input field data
+type TextFieldData struct {
+ FormFieldData
+ Value string
+ Placeholder string
+}
+
+// NumberFieldData represents number input field data
+type NumberFieldData struct {
+ FormFieldData
+ Value float64
+ Step string
+ Min *float64
+ Max *float64
+}
+
+// CheckboxFieldData represents checkbox field data
+type CheckboxFieldData struct {
+ FormFieldData
+ Checked bool
+}
+
+// SelectFieldData represents select field data
+type SelectFieldData struct {
+ FormFieldData
+ Value string
+ Options []SelectOption
+}
+
+type SelectOption struct {
+ Value string
+ Label string
+}
+
+// DurationFieldData represents duration input field data
+type DurationFieldData struct {
+ FormFieldData
+ Value string
+ Placeholder string
+}
+
+// DurationInputFieldData represents duration input with number + unit dropdown
+type DurationInputFieldData struct {
+ FormFieldData
+ Seconds int // The duration value in seconds
+}
+
+// TextField renders a Bootstrap text input field
+templ TextField(data TextFieldData) {
+ <div class="mb-3">
+ <label for={ data.Name } class="form-label">
+ { data.Label }
+ if data.Required {
+ <span class="text-danger">*</span>
+ }
+ </label>
+ <input
+ type="text"
+ class="form-control"
+ id={ data.Name }
+ name={ data.Name }
+ value={ data.Value }
+ if data.Placeholder != "" {
+ placeholder={ data.Placeholder }
+ }
+ if data.Required {
+ required
+ }
+ />
+ if data.Description != "" {
+ <div class="form-text text-muted">{ data.Description }</div>
+ }
+ </div>
+}
+
+// NumberField renders a Bootstrap number input field
+templ NumberField(data NumberFieldData) {
+ <div class="mb-3">
+ <label for={ data.Name } class="form-label">
+ { data.Label }
+ if data.Required {
+ <span class="text-danger">*</span>
+ }
+ </label>
+ <input
+ type="number"
+ class="form-control"
+ id={ data.Name }
+ name={ data.Name }
+ value={ fmt.Sprintf("%.6g", data.Value) }
+ if data.Step != "" {
+ step={ data.Step }
+ } else {
+ step="any"
+ }
+ if data.Min != nil {
+ min={ fmt.Sprintf("%.6g", *data.Min) }
+ }
+ if data.Max != nil {
+ max={ fmt.Sprintf("%.6g", *data.Max) }
+ }
+ if data.Required {
+ required
+ }
+ />
+ if data.Description != "" {
+ <div class="form-text text-muted">{ data.Description }</div>
+ }
+ </div>
+}
+
+// CheckboxField renders a Bootstrap checkbox field
+templ CheckboxField(data CheckboxFieldData) {
+ <div class="mb-3">
+ <div class="form-check">
+ <input
+ type="checkbox"
+ class="form-check-input"
+ id={ data.Name }
+ name={ data.Name }
+ if data.Checked {
+ checked
+ }
+ />
+ <label class="form-check-label" for={ data.Name }>
+ { data.Label }
+ </label>
+ </div>
+ if data.Description != "" {
+ <div class="form-text text-muted">{ data.Description }</div>
+ }
+ </div>
+}
+
+// SelectField renders a Bootstrap select field
+templ SelectField(data SelectFieldData) {
+ <div class="mb-3">
+ <label for={ data.Name } class="form-label">
+ { data.Label }
+ if data.Required {
+ <span class="text-danger">*</span>
+ }
+ </label>
+ <select
+ class="form-select"
+ id={ data.Name }
+ name={ data.Name }
+ if data.Required {
+ required
+ }
+ >
+ for _, option := range data.Options {
+ <option
+ value={ option.Value }
+ if option.Value == data.Value {
+ selected
+ }
+ >
+ { option.Label }
+ </option>
+ }
+ </select>
+ if data.Description != "" {
+ <div class="form-text text-muted">{ data.Description }</div>
+ }
+ </div>
+}
+
+// DurationField renders a Bootstrap duration input field
+templ DurationField(data DurationFieldData) {
+ <div class="mb-3">
+ <label for={ data.Name } class="form-label">
+ { data.Label }
+ if data.Required {
+ <span class="text-danger">*</span>
+ }
+ </label>
+ <input
+ type="text"
+ class="form-control"
+ id={ data.Name }
+ name={ data.Name }
+ value={ data.Value }
+ if data.Placeholder != "" {
+ placeholder={ data.Placeholder }
+ } else {
+ placeholder="e.g., 30m, 2h, 24h"
+ }
+ if data.Required {
+ required
+ }
+ />
+ if data.Description != "" {
+ <div class="form-text text-muted">{ data.Description }</div>
+ }
+ </div>
+}
+
+// DurationInputField renders a Bootstrap duration input with number + unit dropdown
+templ DurationInputField(data DurationInputFieldData) {
+ <div class="mb-3">
+ <label for={ data.Name } class="form-label">
+ { data.Label }
+ if data.Required {
+ <span class="text-danger">*</span>
+ }
+ </label>
+ <div class="input-group">
+ <input
+ type="number"
+ class="form-control"
+ id={ data.Name }
+ name={ data.Name }
+ value={ fmt.Sprintf("%.0f", convertSecondsToValue(data.Seconds, convertSecondsToUnit(data.Seconds))) }
+ step="1"
+ min="1"
+ if data.Required {
+ required
+ }
+ />
+ <select
+ class="form-select"
+ id={ data.Name + "_unit" }
+ name={ data.Name + "_unit" }
+ style="max-width: 120px;"
+ >
+ <option
+ value="minutes"
+ if convertSecondsToUnit(data.Seconds) == "minutes" {
+ selected
+ }
+ >
+ Minutes
+ </option>
+ <option
+ value="hours"
+ if convertSecondsToUnit(data.Seconds) == "hours" {
+ selected
+ }
+ >
+ Hours
+ </option>
+ <option
+ value="days"
+ if convertSecondsToUnit(data.Seconds) == "days" {
+ selected
+ }
+ >
+ Days
+ </option>
+ </select>
+ </div>
+ if data.Description != "" {
+ <div class="form-text text-muted">{ data.Description }</div>
+ }
+ </div>
+}
+
+// Helper functions for duration conversion (used by DurationInputField)
+func convertSecondsToUnit(seconds int) string {
+ if seconds == 0 {
+ return "minutes"
+ }
+
+ // Try days first
+ if seconds%(24*3600) == 0 && seconds >= 24*3600 {
+ return "days"
+ }
+
+ // Try hours
+ if seconds%3600 == 0 && seconds >= 3600 {
+ return "hours"
+ }
+
+ // Default to minutes
+ return "minutes"
+}
+
+func convertSecondsToValue(seconds int, unit string) float64 {
+ if seconds == 0 {
+ return 0
+ }
+
+ switch unit {
+ case "days":
+ return float64(seconds / (24 * 3600))
+ case "hours":
+ return float64(seconds / 3600)
+ case "minutes":
+ return float64(seconds / 60)
+ default:
+ return float64(seconds / 60) // Default to minutes
+ }
+} \ No newline at end of file
diff --git a/weed/admin/view/components/form_fields_templ.go b/weed/admin/view/components/form_fields_templ.go
new file mode 100644
index 000000000..f640c5575
--- /dev/null
+++ b/weed/admin/view/components/form_fields_templ.go
@@ -0,0 +1,1104 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.833
+package components
+
+//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"
+
+// FormFieldData represents common form field data
+type FormFieldData struct {
+ Name string
+ Label string
+ Description string
+ Required bool
+}
+
+// TextFieldData represents text input field data
+type TextFieldData struct {
+ FormFieldData
+ Value string
+ Placeholder string
+}
+
+// NumberFieldData represents number input field data
+type NumberFieldData struct {
+ FormFieldData
+ Value float64
+ Step string
+ Min *float64
+ Max *float64
+}
+
+// CheckboxFieldData represents checkbox field data
+type CheckboxFieldData struct {
+ FormFieldData
+ Checked bool
+}
+
+// SelectFieldData represents select field data
+type SelectFieldData struct {
+ FormFieldData
+ Value string
+ Options []SelectOption
+}
+
+type SelectOption struct {
+ Value string
+ Label string
+}
+
+// DurationFieldData represents duration input field data
+type DurationFieldData struct {
+ FormFieldData
+ Value string
+ Placeholder string
+}
+
+// DurationInputFieldData represents duration input with number + unit dropdown
+type DurationInputFieldData struct {
+ FormFieldData
+ Seconds int // The duration value in seconds
+}
+
+// TextField renders a Bootstrap text input field
+func TextField(data TextFieldData) 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, "<div class=\"mb-3\"><label for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 63, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"form-label\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 64, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"text-danger\">*</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</label> <input type=\"text\" class=\"form-control\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 72, Col: 26}
+ }
+ _, 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, 6, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 73, Col: 28}
+ }
+ _, 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, "\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.Value)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 74, Col: 30}
+ }
+ _, 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, 8, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Placeholder != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " placeholder=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Placeholder)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 76, Col: 46}
+ }
+ _, 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, 10, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " required")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"form-text text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 83, Col: 64}
+ }
+ _, 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, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// NumberField renders a Bootstrap number input field
+func NumberField(data NumberFieldData) 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_Var9 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var9 == nil {
+ templ_7745c5c3_Var9 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"mb-3\"><label for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 91, Col: 30}
+ }
+ _, 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, 17, "\" class=\"form-label\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 92, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-danger\">*</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</label> <input type=\"number\" class=\"form-control\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 100, Col: 26}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 101, Col: 28}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.6g", data.Value))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 102, Col: 51}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Step != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " step=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.Step)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 104, Col: 32}
+ }
+ _, 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, 25, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " step=\"any\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Min != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " min=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.6g", *data.Min))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 109, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Max != nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " max=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.6g", *data.Max))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 112, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " required")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"form-text text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 119, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// CheckboxField renders a Bootstrap checkbox field
+func CheckboxField(data CheckboxFieldData) 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_Var19 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var19 == nil {
+ templ_7745c5c3_Var19 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"mb-3\"><div class=\"form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 131, Col: 30}
+ }
+ _, 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, 37, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 132, Col: 32}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Checked {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " checked")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "> <label class=\"form-check-label\" for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 137, Col: 59}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 138, Col: 28}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</label></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"form-text text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 142, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// SelectField renders a Bootstrap select field
+func SelectField(data SelectFieldData) 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_Var25 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var25 == nil {
+ templ_7745c5c3_Var25 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"mb-3\"><label for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var26 string
+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 150, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"form-label\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 151, Col: 24}
+ }
+ _, 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, 48, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"text-danger\">*</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</label> <select class=\"form-select\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 158, Col: 26}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 159, Col: 28}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " required")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, option := range data.Options {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<option value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var30 string
+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(option.Value)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 166, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if option.Value == data.Value {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " selected")
+ 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
+ }
+ var templ_7745c5c3_Var31 string
+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(option.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 171, Col: 34}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</option>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</select> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"form-text text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var32 string
+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 176, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// DurationField renders a Bootstrap duration input field
+func DurationField(data DurationFieldData) 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_Var33 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var33 == nil {
+ templ_7745c5c3_Var33 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"mb-3\"><label for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var34 string
+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 184, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"form-label\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var35 string
+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 185, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<span class=\"text-danger\">*</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</label> <input type=\"text\" class=\"form-control\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var36 string
+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 193, Col: 26}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var37 string
+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 194, Col: 28}
+ }
+ _, 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, 70, "\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var38 string
+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(data.Value)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 195, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Placeholder != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " placeholder=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var39 string
+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(data.Placeholder)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 197, Col: 46}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " placeholder=\"e.g., 30m, 2h, 24h\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " required")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"form-text text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var40 string
+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 206, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// DurationInputField renders a Bootstrap duration input with number + unit dropdown
+func DurationInputField(data DurationInputFieldData) 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_Var41 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var41 == nil {
+ templ_7745c5c3_Var41 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"mb-3\"><label for=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var42 string
+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 214, Col: 24}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"form-label\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var43 string
+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(data.Label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 215, Col: 15}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"text-danger\">*</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</label><div class=\"input-group\"><input type=\"number\" class=\"form-control\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var44 string
+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 224, Col: 18}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var45 string
+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 225, Col: 20}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" value=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var46 string
+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f", convertSecondsToValue(data.Seconds, convertSecondsToUnit(data.Seconds))))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 226, Col: 104}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" step=\"1\" min=\"1\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Required {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " required")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "> <select class=\"form-select\" id=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var47 string
+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name + "_unit")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 235, Col: 28}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var48 string
+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(data.Name + "_unit")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 236, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" style=\"max-width: 120px;\"><option value=\"minutes\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if convertSecondsToUnit(data.Seconds) == "minutes" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " selected")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, ">Minutes</option> <option value=\"hours\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if convertSecondsToUnit(data.Seconds) == "hours" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " selected")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, ">Hours</option> <option value=\"days\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if convertSecondsToUnit(data.Seconds) == "days" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " selected")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, ">Days</option></select></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Description != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<div class=\"form-text text-muted\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var49 string
+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(data.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/components/form_fields.templ`, Line: 266, Col: 55}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// Helper functions for duration conversion (used by DurationInputField)
+func convertSecondsToUnit(seconds int) string {
+ if seconds == 0 {
+ return "minutes"
+ }
+
+ // Try days first
+ if seconds%(24*3600) == 0 && seconds >= 24*3600 {
+ return "days"
+ }
+
+ // Try hours
+ if seconds%3600 == 0 && seconds >= 3600 {
+ return "hours"
+ }
+
+ // Default to minutes
+ return "minutes"
+}
+
+func convertSecondsToValue(seconds int, unit string) float64 {
+ if seconds == 0 {
+ return 0
+ }
+
+ switch unit {
+ case "days":
+ return float64(seconds / (24 * 3600))
+ case "hours":
+ return float64(seconds / 3600)
+ case "minutes":
+ return float64(seconds / 60)
+ default:
+ return float64(seconds / 60) // Default to minutes
+ }
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ
index 5c010a9ff..cfb1a96bb 100644
--- a/weed/admin/view/layout/layout.templ
+++ b/weed/admin/view/layout/layout.templ
@@ -14,6 +14,10 @@ templ Layout(c *gin.Context, content templ.Component) {
if username == "" {
username = "admin"
}
+
+ // Detect if we're on a configuration page to keep submenu expanded
+ currentPath := c.Request.URL.Path
+ isConfigPage := strings.HasPrefix(currentPath, "/maintenance/config") || currentPath == "/config"
}}
<!DOCTYPE html>
<html lang="en">
@@ -160,14 +164,73 @@ templ Layout(c *gin.Context, content templ.Component) {
</h6>
<ul class="nav flex-column">
<li class="nav-item">
- <a class="nav-link" href="/config">
- <i class="fas fa-cog me-2"></i>Configuration
- </a>
+ if isConfigPage {
+ <a class="nav-link" href="#" data-bs-toggle="collapse" data-bs-target="#configurationSubmenu" aria-expanded="true" aria-controls="configurationSubmenu">
+ <i class="fas fa-cogs me-2"></i>Configuration
+ <i class="fas fa-chevron-down ms-auto"></i>
+ </a>
+ } else {
+ <a class="nav-link collapsed" href="#" data-bs-toggle="collapse" data-bs-target="#configurationSubmenu" aria-expanded="false" aria-controls="configurationSubmenu">
+ <i class="fas fa-cogs me-2"></i>Configuration
+ <i class="fas fa-chevron-right ms-auto"></i>
+ </a>
+ }
+ if isConfigPage {
+ <div class="collapse show" id="configurationSubmenu">
+ <ul class="nav flex-column ms-3">
+ for _, menuItem := range GetConfigurationMenuItems() {
+ {{
+ isActiveItem := currentPath == menuItem.URL
+ }}
+ <li class="nav-item">
+ if isActiveItem {
+ <a class="nav-link py-2 active" href={templ.SafeURL(menuItem.URL)}>
+ <i class={menuItem.Icon + " me-2"}></i>{menuItem.Name}
+ </a>
+ } else {
+ <a class="nav-link py-2" href={templ.SafeURL(menuItem.URL)}>
+ <i class={menuItem.Icon + " me-2"}></i>{menuItem.Name}
+ </a>
+ }
+ </li>
+ }
+ </ul>
+ </div>
+ } else {
+ <div class="collapse" id="configurationSubmenu">
+ <ul class="nav flex-column ms-3">
+ for _, menuItem := range GetConfigurationMenuItems() {
+ <li class="nav-item">
+ <a class="nav-link py-2" href={templ.SafeURL(menuItem.URL)}>
+ <i class={menuItem.Icon + " me-2"}></i>{menuItem.Name}
+ </a>
+ </li>
+ }
+ </ul>
+ </div>
+ }
</li>
<li class="nav-item">
- <a class="nav-link" href="/maintenance">
- <i class="fas fa-tools me-2"></i>Maintenance
- </a>
+ if currentPath == "/maintenance" {
+ <a class="nav-link active" href="/maintenance">
+ <i class="fas fa-list me-2"></i>Maintenance Queue
+ </a>
+ } else {
+ <a class="nav-link" href="/maintenance">
+ <i class="fas fa-list me-2"></i>Maintenance Queue
+ </a>
+ }
+ </li>
+ <li class="nav-item">
+ if currentPath == "/maintenance/workers" {
+ <a class="nav-link active" href="/maintenance/workers">
+ <i class="fas fa-user-cog me-2"></i>Maintenance Workers
+ </a>
+ } else {
+ <a class="nav-link" href="/maintenance/workers">
+ <i class="fas fa-user-cog me-2"></i>Maintenance Workers
+ </a>
+ }
</li>
</ul>
</div>
diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go
index eded66b13..93600fd3e 100644
--- a/weed/admin/view/layout/layout_templ.go
+++ b/weed/admin/view/layout/layout_templ.go
@@ -42,6 +42,10 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
if username == "" {
username = "admin"
}
+
+ // Detect if we're on a configuration page to keep submenu expanded
+ currentPath := c.Request.URL.Path
+ isConfigPage := strings.HasPrefix(currentPath, "/maintenance/config") || currentPath == "/config"
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>SeaweedFS Admin</title><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><!-- Bootstrap CSS --><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\"><!-- Font Awesome CSS --><link href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" rel=\"stylesheet\"><!-- HTMX --><script src=\"https://unpkg.com/htmx.org@1.9.8/dist/htmx.min.js\"></script><!-- Custom CSS --><link rel=\"stylesheet\" href=\"/static/css/admin.css\"></head><body><div class=\"container-fluid\"><!-- Header --><header class=\"navbar navbar-expand-lg navbar-dark bg-primary sticky-top\"><div class=\"container-fluid\"><a class=\"navbar-brand fw-bold\" href=\"/admin\"><i class=\"fas fa-server me-2\"></i> SeaweedFS Admin <span class=\"badge bg-warning text-dark ms-2\">ALPHA</span></a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNav\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse\" id=\"navbarNav\"><ul class=\"navbar-nav ms-auto\"><li class=\"nav-item dropdown\"><a class=\"nav-link dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-user me-1\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -49,13 +53,238 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(username)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 54, Col: 73}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 58, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li></ul></div></li><li class=\"nav-item\"><a class=\"nav-link\" href=\"/metrics\"><i class=\"fas fa-chart-line me-2\"></i>Metrics</a></li><li class=\"nav-item\"><a class=\"nav-link\" href=\"/logs\"><i class=\"fas fa-file-alt me-2\"></i>Logs</a></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>SYSTEM</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/config\"><i class=\"fas fa-cog me-2\"></i>Configuration</a></li><li class=\"nav-item\"><a class=\"nav-link\" href=\"/maintenance\"><i class=\"fas fa-tools me-2\"></i>Maintenance</a></li></ul></div></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-md-4\"><div class=\"pt-3\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#clusterSubmenu\" aria-expanded=\"false\" aria-controls=\"clusterSubmenu\"><i class=\"fas fa-sitemap me-2\"></i>Cluster <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"clusterSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/masters\"><i class=\"fas fa-crown me-2\"></i>Masters</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volume-servers\"><i class=\"fas fa-server me-2\"></i>Volume Servers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/filers\"><i class=\"fas fa-folder-open me-2\"></i>Filers</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/volumes\"><i class=\"fas fa-database me-2\"></i>Volumes</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/cluster/collections\"><i class=\"fas fa-layer-group me-2\"></i>Collections</a></li></ul></div></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MANAGEMENT</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/files\"><i class=\"fas fa-folder me-2\"></i>File Browser</a></li><li class=\"nav-item\"><a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#objectStoreSubmenu\" aria-expanded=\"false\" aria-controls=\"objectStoreSubmenu\"><i class=\"fas fa-cloud me-2\"></i>Object Store <i class=\"fas fa-chevron-down ms-auto\"></i></a><div class=\"collapse\" id=\"objectStoreSubmenu\"><ul class=\"nav flex-column ms-3\"><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/buckets\"><i class=\"fas fa-cube me-2\"></i>Buckets</a></li><li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"/object-store/users\"><i class=\"fas fa-users me-2\"></i>Users</a></li></ul></div></li><li class=\"nav-item\"><a class=\"nav-link\" href=\"/metrics\"><i class=\"fas fa-chart-line me-2\"></i>Metrics</a></li><li class=\"nav-item\"><a class=\"nav-link\" href=\"/logs\"><i class=\"fas fa-file-alt me-2\"></i>Logs</a></li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>SYSTEM</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if isConfigPage {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<a class=\"nav-link\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#configurationSubmenu\" aria-expanded=\"true\" aria-controls=\"configurationSubmenu\"><i class=\"fas fa-cogs me-2\"></i>Configuration <i class=\"fas fa-chevron-down ms-auto\"></i></a> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a class=\"nav-link collapsed\" href=\"#\" data-bs-toggle=\"collapse\" data-bs-target=\"#configurationSubmenu\" aria-expanded=\"false\" aria-controls=\"configurationSubmenu\"><i class=\"fas fa-cogs me-2\"></i>Configuration <i class=\"fas fa-chevron-right ms-auto\"></i></a> ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if isConfigPage {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"collapse show\" id=\"configurationSubmenu\"><ul class=\"nav flex-column ms-3\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, menuItem := range GetConfigurationMenuItems() {
+
+ isActiveItem := currentPath == menuItem.URL
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<li class=\"nav-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if isActiveItem {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a class=\"nav-link py-2 active\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL(menuItem.URL)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 = []any{menuItem.Icon + " me-2"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ }
+ _, 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, 10, "\"></i>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 188, Col: 109}
+ }
+ _, 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, "</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<a class=\"nav-link py-2\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(menuItem.URL)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 = []any{menuItem.Icon + " me-2"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ }
+ _, 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, "\"></i>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 192, Col: 109}
+ }
+ _, 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, "</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</ul></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"collapse\" id=\"configurationSubmenu\"><ul class=\"nav flex-column ms-3\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, menuItem := range GetConfigurationMenuItems() {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<li class=\"nav-item\"><a class=\"nav-link py-2\" href=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 templ.SafeURL = templ.SafeURL(menuItem.URL)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var11)))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 = []any{menuItem.Icon + " me-2"}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<i class=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"></i>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 205, Col: 105}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</ul></div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</li><li class=\"nav-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if currentPath == "/maintenance" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a class=\"nav-link active\" href=\"/maintenance\"><i class=\"fas fa-list me-2\"></i>Maintenance Queue</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"nav-link\" href=\"/maintenance\"><i class=\"fas fa-list me-2\"></i>Maintenance Queue</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</li><li class=\"nav-item\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if currentPath == "/maintenance/workers" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a class=\"nav-link active\" href=\"/maintenance/workers\"><i class=\"fas fa-user-cog me-2\"></i>Maintenance Workers</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a class=\"nav-link\" href=\"/maintenance/workers\"><i class=\"fas fa-user-cog me-2\"></i>Maintenance Workers</a>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</li></ul></div></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-md-4\"><div class=\"pt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -63,43 +292,43 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></main></div></div><!-- Footer --><footer class=\"footer mt-auto py-3 bg-light\"><div class=\"container-fluid text-center\"><small class=\"text-muted\">&copy; ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></main></div></div><!-- Footer --><footer class=\"footer mt-auto py-3 bg-light\"><div class=\"container-fluid text-center\"><small class=\"text-muted\">&copy; ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var3 string
- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 189, Col: 60}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 252, Col: 60}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ _, 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, 4, " SeaweedFS Admin v")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " SeaweedFS Admin v")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var4 string
- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 189, Col: 102}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 252, Col: 102}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !strings.Contains(version.VERSION, "enterprise") {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</small></div></footer><!-- Bootstrap JS --><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script></body></html>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></footer><!-- Bootstrap JS --><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -123,61 +352,61 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var5 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var5 == nil {
- templ_7745c5c3_Var5 = templ.NopComponent
+ templ_7745c5c3_Var17 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var17 == nil {
+ templ_7745c5c3_Var17 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var6 string
- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 213, Col: 17}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 276, Col: 17}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 227, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 290, Col: 57}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errorMessage != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var8 string
- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 234, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 297, Col: 45}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ _, 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, 12, "</div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<form method=\"POST\" action=\"/login\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"></script></body></html>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<form method=\"POST\" action=\"/login\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/layout/menu_helper.go b/weed/admin/view/layout/menu_helper.go
new file mode 100644
index 000000000..fc8954423
--- /dev/null
+++ b/weed/admin/view/layout/menu_helper.go
@@ -0,0 +1,47 @@
+package layout
+
+import (
+ "github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
+
+ // Import task packages to trigger their auto-registration
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
+)
+
+// MenuItemData represents a menu item
+type MenuItemData struct {
+ Name string
+ URL string
+ Icon string
+ Description string
+}
+
+// GetConfigurationMenuItems returns the dynamic configuration menu items
+func GetConfigurationMenuItems() []*MenuItemData {
+ var menuItems []*MenuItemData
+
+ // Add system configuration item
+ menuItems = append(menuItems, &MenuItemData{
+ Name: "System",
+ URL: "/maintenance/config",
+ Icon: "fas fa-cogs",
+ Description: "System-level configuration",
+ })
+
+ // Get all registered task types and add them as submenu items
+ registeredTypes := maintenance.GetRegisteredMaintenanceTaskTypes()
+
+ for _, taskType := range registeredTypes {
+ menuItem := &MenuItemData{
+ Name: maintenance.GetTaskDisplayName(taskType),
+ URL: "/maintenance/config/" + string(taskType),
+ Icon: maintenance.GetTaskIcon(taskType),
+ Description: maintenance.GetTaskDescription(taskType),
+ }
+
+ menuItems = append(menuItems, menuItem)
+ }
+
+ return menuItems
+}
diff --git a/weed/command/admin.go b/weed/command/admin.go
index ef1d54bb3..027fbec68 100644
--- a/weed/command/admin.go
+++ b/weed/command/admin.go
@@ -3,12 +3,12 @@ package command
import (
"context"
"crypto/rand"
- "crypto/tls"
"fmt"
"log"
"net/http"
"os"
"os/signal"
+ "os/user"
"path/filepath"
"strings"
"syscall"
@@ -17,9 +17,12 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
+ "github.com/spf13/viper"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/handlers"
+ "github.com/seaweedfs/seaweedfs/weed/security"
+ "github.com/seaweedfs/seaweedfs/weed/util"
)
var (
@@ -29,25 +32,23 @@ var (
type AdminOptions struct {
port *int
masters *string
- tlsCertPath *string
- tlsKeyPath *string
adminUser *string
adminPassword *string
+ dataDir *string
}
func init() {
cmdAdmin.Run = runAdmin // break init cycle
a.port = cmdAdmin.Flag.Int("port", 23646, "admin server port")
a.masters = cmdAdmin.Flag.String("masters", "localhost:9333", "comma-separated master servers")
- a.tlsCertPath = cmdAdmin.Flag.String("tlsCert", "", "path to TLS certificate file")
- a.tlsKeyPath = cmdAdmin.Flag.String("tlsKey", "", "path to TLS private key file")
+ a.dataDir = cmdAdmin.Flag.String("dataDir", "", "directory to store admin configuration and data files")
a.adminUser = cmdAdmin.Flag.String("adminUser", "admin", "admin interface username")
a.adminPassword = cmdAdmin.Flag.String("adminPassword", "", "admin interface password (if empty, auth is disabled)")
}
var cmdAdmin = &Command{
- UsageLine: "admin -port=23646 -masters=localhost:9333",
+ UsageLine: "admin -port=23646 -masters=localhost:9333 [-dataDir=/path/to/data]",
Short: "start SeaweedFS web admin interface",
Long: `Start a web admin interface for SeaweedFS cluster management.
@@ -60,25 +61,56 @@ var cmdAdmin = &Command{
- Maintenance operations
The admin interface automatically discovers filers from the master servers.
+ A gRPC server for worker connections runs on HTTP port + 10000.
Example Usage:
weed admin -port=23646 -masters="master1:9333,master2:9333"
- weed admin -port=443 -tlsCert=/etc/ssl/admin.crt -tlsKey=/etc/ssl/admin.key
+ weed admin -port=23646 -masters="localhost:9333" -dataDir="/var/lib/seaweedfs-admin"
+ weed admin -port=23646 -masters="localhost:9333" -dataDir="~/seaweedfs-admin"
+
+ Data Directory:
+ - If dataDir is specified, admin configuration and maintenance data is persisted
+ - The directory will be created if it doesn't exist
+ - Configuration files are stored in JSON format for easy editing
+ - Without dataDir, all configuration is kept in memory only
Authentication:
- If adminPassword is not set, the admin interface runs without authentication
- If adminPassword is set, users must login with adminUser/adminPassword
- Sessions are secured with auto-generated session keys
- Security:
- - Use HTTPS in production by providing TLS certificates
+ Security Configuration:
+ - The admin server reads TLS configuration from security.toml
+ - Configure [https.admin] section in security.toml for HTTPS support
+ - If https.admin.key is set, the server will start in TLS mode
+ - If https.admin.ca is set, mutual TLS authentication is enabled
- Set strong adminPassword for production deployments
- Configure firewall rules to restrict admin interface access
+ security.toml Example:
+ [https.admin]
+ cert = "/etc/ssl/admin.crt"
+ key = "/etc/ssl/admin.key"
+ ca = "/etc/ssl/ca.crt" # optional, for mutual TLS
+
+ Worker Communication:
+ - Workers connect via gRPC on HTTP port + 10000
+ - Workers use [grpc.admin] configuration from security.toml
+ - TLS is automatically used if certificates are configured
+ - Workers fall back to insecure connections if TLS is unavailable
+
+ Configuration File:
+ - The security.toml file is read from ".", "$HOME/.seaweedfs/",
+ "/usr/local/etc/seaweedfs/", or "/etc/seaweedfs/", in that order
+ - Generate example security.toml: weed scaffold -config=security
+
`,
}
func runAdmin(cmd *Command, args []string) bool {
+ // Load security configuration
+ util.LoadSecurityConfiguration()
+
// Validate required parameters
if *a.masters == "" {
fmt.Println("Error: masters parameter is required")
@@ -86,37 +118,25 @@ func runAdmin(cmd *Command, args []string) bool {
return false
}
- // Validate TLS configuration
- if (*a.tlsCertPath != "" && *a.tlsKeyPath == "") ||
- (*a.tlsCertPath == "" && *a.tlsKeyPath != "") {
- fmt.Println("Error: Both tlsCert and tlsKey must be provided for TLS")
- return false
- }
-
// Security warnings
if *a.adminPassword == "" {
fmt.Println("WARNING: Admin interface is running without authentication!")
fmt.Println(" Set -adminPassword for production use")
}
- if *a.tlsCertPath == "" {
- fmt.Println("WARNING: Admin interface is running without TLS encryption!")
- fmt.Println(" Use -tlsCert and -tlsKey for production use")
- }
-
fmt.Printf("Starting SeaweedFS Admin Interface on port %d\n", *a.port)
fmt.Printf("Masters: %s\n", *a.masters)
fmt.Printf("Filers will be discovered automatically from masters\n")
+ if *a.dataDir != "" {
+ fmt.Printf("Data Directory: %s\n", *a.dataDir)
+ } else {
+ fmt.Printf("Data Directory: Not specified (configuration will be in-memory only)\n")
+ }
if *a.adminPassword != "" {
fmt.Printf("Authentication: Enabled (user: %s)\n", *a.adminUser)
} else {
fmt.Printf("Authentication: Disabled\n")
}
- if *a.tlsCertPath != "" {
- fmt.Printf("TLS: Enabled\n")
- } else {
- fmt.Printf("TLS: Disabled\n")
- }
// Set up graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
@@ -169,8 +189,29 @@ func startAdminServer(ctx context.Context, options AdminOptions) error {
log.Printf("Warning: Static files not found at %s", staticPath)
}
+ // Create data directory if specified
+ var dataDir string
+ if *options.dataDir != "" {
+ // Expand tilde (~) to home directory
+ expandedDir, err := expandHomeDir(*options.dataDir)
+ if err != nil {
+ return fmt.Errorf("failed to expand dataDir path %s: %v", *options.dataDir, err)
+ }
+ dataDir = expandedDir
+
+ // Show path expansion if it occurred
+ if dataDir != *options.dataDir {
+ fmt.Printf("Expanded dataDir: %s -> %s\n", *options.dataDir, dataDir)
+ }
+
+ if err := os.MkdirAll(dataDir, 0755); err != nil {
+ return fmt.Errorf("failed to create data directory %s: %v", dataDir, err)
+ }
+ fmt.Printf("Data directory created/verified: %s\n", dataDir)
+ }
+
// Create admin server
- adminServer := dash.NewAdminServer(*options.masters, nil)
+ adminServer := dash.NewAdminServer(*options.masters, nil, dataDir)
// Show discovered filers
filers := adminServer.GetAllFilers()
@@ -180,6 +221,19 @@ func startAdminServer(ctx context.Context, options AdminOptions) error {
fmt.Printf("No filers discovered from masters\n")
}
+ // Start worker gRPC server for worker connections
+ err = adminServer.StartWorkerGrpcServer(*options.port)
+ if err != nil {
+ return fmt.Errorf("failed to start worker gRPC server: %v", err)
+ }
+
+ // Set up cleanup for gRPC server
+ defer func() {
+ if stopErr := adminServer.StopWorkerGrpcServer(); stopErr != nil {
+ log.Printf("Error stopping worker gRPC server: %v", stopErr)
+ }
+ }()
+
// Create handlers and setup routes
adminHandlers := handlers.NewAdminHandlers(adminServer)
adminHandlers.SetupRoutes(r, *options.adminPassword != "", *options.adminUser, *options.adminPassword)
@@ -191,21 +245,37 @@ func startAdminServer(ctx context.Context, options AdminOptions) error {
Handler: r,
}
- // TLS configuration
- if *options.tlsCertPath != "" && *options.tlsKeyPath != "" {
- server.TLSConfig = &tls.Config{
- MinVersion: tls.VersionTLS12,
- }
- }
-
// Start server
go func() {
log.Printf("Starting SeaweedFS Admin Server on port %d", *options.port)
- var err error
- if *options.tlsCertPath != "" && *options.tlsKeyPath != "" {
- log.Printf("Using TLS with cert: %s, key: %s", *options.tlsCertPath, *options.tlsKeyPath)
- err = server.ListenAndServeTLS(*options.tlsCertPath, *options.tlsKeyPath)
+ // start http or https server with security.toml
+ var (
+ clientCertFile,
+ certFile,
+ keyFile string
+ )
+ useTLS := false
+ useMTLS := false
+
+ if viper.GetString("https.admin.key") != "" {
+ useTLS = true
+ certFile = viper.GetString("https.admin.cert")
+ keyFile = viper.GetString("https.admin.key")
+ }
+
+ if viper.GetString("https.admin.ca") != "" {
+ useMTLS = true
+ clientCertFile = viper.GetString("https.admin.ca")
+ }
+
+ if useMTLS {
+ server.TLSConfig = security.LoadClientTLSHTTP(clientCertFile)
+ }
+
+ if useTLS {
+ log.Printf("Starting SeaweedFS Admin Server with TLS on port %d", *options.port)
+ err = server.ListenAndServeTLS(certFile, keyFile)
} else {
err = server.ListenAndServe()
}
@@ -234,3 +304,47 @@ func startAdminServer(ctx context.Context, options AdminOptions) error {
func GetAdminOptions() *AdminOptions {
return &AdminOptions{}
}
+
+// expandHomeDir expands the tilde (~) in a path to the user's home directory
+func expandHomeDir(path string) (string, error) {
+ if path == "" {
+ return path, nil
+ }
+
+ if !strings.HasPrefix(path, "~") {
+ return path, nil
+ }
+
+ // Get current user
+ currentUser, err := user.Current()
+ if err != nil {
+ return "", fmt.Errorf("failed to get current user: %v", err)
+ }
+
+ // Handle different tilde patterns
+ if path == "~" {
+ return currentUser.HomeDir, nil
+ }
+
+ if strings.HasPrefix(path, "~/") {
+ return filepath.Join(currentUser.HomeDir, path[2:]), nil
+ }
+
+ // Handle ~username/ patterns
+ if strings.HasPrefix(path, "~") {
+ parts := strings.SplitN(path[1:], "/", 2)
+ username := parts[0]
+
+ targetUser, err := user.Lookup(username)
+ if err != nil {
+ return "", fmt.Errorf("user %s not found: %v", username, err)
+ }
+
+ if len(parts) == 1 {
+ return targetUser.HomeDir, nil
+ }
+ return filepath.Join(targetUser.HomeDir, parts[1]), nil
+ }
+
+ return path, nil
+}
diff --git a/weed/command/command.go b/weed/command/command.go
index 65ddce717..06474fbb9 100644
--- a/weed/command/command.go
+++ b/weed/command/command.go
@@ -45,6 +45,7 @@ var Commands = []*Command{
cmdVolume,
cmdWebDav,
cmdSftp,
+ cmdWorker,
}
type Command struct {
diff --git a/weed/command/scaffold/security.toml b/weed/command/scaffold/security.toml
index 2efcac354..bc95ecf2e 100644
--- a/weed/command/scaffold/security.toml
+++ b/weed/command/scaffold/security.toml
@@ -2,7 +2,7 @@
# ./security.toml
# $HOME/.seaweedfs/security.toml
# /etc/seaweedfs/security.toml
-# this file is read by master, volume server, and filer
+# this file is read by master, volume server, filer, and worker
# comma separated origins allowed to make requests to the filer and s3 gateway.
# enter in this format: https://domain.com, or http://localhost:port
@@ -94,6 +94,16 @@ cert = ""
key = ""
allowed_commonNames = "" # comma-separated SSL certificate common names
+[grpc.admin]
+cert = ""
+key = ""
+allowed_commonNames = "" # comma-separated SSL certificate common names
+
+[grpc.worker]
+cert = ""
+key = ""
+allowed_commonNames = "" # comma-separated SSL certificate common names
+
# use this for any place needs a grpc client
# i.e., "weed backup|benchmark|filer.copy|filer.replicate|mount|s3|upload"
[grpc.client]
@@ -101,7 +111,7 @@ cert = ""
key = ""
# https client for master|volume|filer|etc connection
-# It is necessary that the parameters [https.volume]|[https.master]|[https.filer] are set
+# It is necessary that the parameters [https.volume]|[https.master]|[https.filer]|[https.admin] are set
[https.client]
enabled = false
cert = ""
@@ -127,6 +137,12 @@ key = ""
ca = ""
# disable_tls_verify_client_cert = true|false (default: false)
+# admin server https options
+[https.admin]
+cert = ""
+key = ""
+ca = ""
+
# white list. It's checking request ip address.
[guard]
white_list = ""
diff --git a/weed/command/worker.go b/weed/command/worker.go
new file mode 100644
index 000000000..f217e57f7
--- /dev/null
+++ b/weed/command/worker.go
@@ -0,0 +1,182 @@
+package command
+
+import (
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/security"
+ "github.com/seaweedfs/seaweedfs/weed/util"
+ "github.com/seaweedfs/seaweedfs/weed/worker"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+
+ // Import task packages to trigger their auto-registration
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
+)
+
+var cmdWorker = &Command{
+ UsageLine: "worker -admin=<admin_server> [-capabilities=<task_types>] [-maxConcurrent=<num>]",
+ Short: "start a maintenance worker to process cluster maintenance tasks",
+ Long: `Start a maintenance worker that connects to an admin server to process
+maintenance tasks like vacuum, erasure coding, remote upload, and replication fixes.
+
+The worker ID and address are automatically generated.
+The worker connects to the admin server via gRPC (admin HTTP port + 10000).
+
+Examples:
+ weed worker -admin=localhost:23646
+ weed worker -admin=admin.example.com:23646
+ weed worker -admin=localhost:23646 -capabilities=vacuum,replication
+ weed worker -admin=localhost:23646 -maxConcurrent=4
+`,
+}
+
+var (
+ workerAdminServer = cmdWorker.Flag.String("admin", "localhost:23646", "admin server address")
+ workerCapabilities = cmdWorker.Flag.String("capabilities", "vacuum,ec,remote,replication,balance", "comma-separated list of task types this worker can handle")
+ workerMaxConcurrent = cmdWorker.Flag.Int("maxConcurrent", 2, "maximum number of concurrent tasks")
+ workerHeartbeatInterval = cmdWorker.Flag.Duration("heartbeat", 30*time.Second, "heartbeat interval")
+ workerTaskRequestInterval = cmdWorker.Flag.Duration("taskInterval", 5*time.Second, "task request interval")
+)
+
+func init() {
+ cmdWorker.Run = runWorker
+
+ // Set default capabilities from registered task types
+ // This happens after package imports have triggered auto-registration
+ tasks.SetDefaultCapabilitiesFromRegistry()
+}
+
+func runWorker(cmd *Command, args []string) bool {
+ util.LoadConfiguration("security", false)
+
+ glog.Infof("Starting maintenance worker")
+ glog.Infof("Admin server: %s", *workerAdminServer)
+ glog.Infof("Capabilities: %s", *workerCapabilities)
+
+ // Parse capabilities
+ capabilities := parseCapabilities(*workerCapabilities)
+ if len(capabilities) == 0 {
+ glog.Fatalf("No valid capabilities specified")
+ return false
+ }
+
+ // Create worker configuration
+ config := &types.WorkerConfig{
+ AdminServer: *workerAdminServer,
+ Capabilities: capabilities,
+ MaxConcurrent: *workerMaxConcurrent,
+ HeartbeatInterval: *workerHeartbeatInterval,
+ TaskRequestInterval: *workerTaskRequestInterval,
+ }
+
+ // Create worker instance
+ workerInstance, err := worker.NewWorker(config)
+ if err != nil {
+ glog.Fatalf("Failed to create worker: %v", err)
+ return false
+ }
+
+ // Create admin client with LoadClientTLS
+ grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.worker")
+ adminClient, err := worker.CreateAdminClient(*workerAdminServer, workerInstance.ID(), grpcDialOption)
+ if err != nil {
+ glog.Fatalf("Failed to create admin client: %v", err)
+ return false
+ }
+
+ // Set admin client
+ workerInstance.SetAdminClient(adminClient)
+
+ // Start the worker
+ err = workerInstance.Start()
+ if err != nil {
+ glog.Fatalf("Failed to start worker: %v", err)
+ return false
+ }
+
+ // Set up signal handling
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+ glog.Infof("Maintenance worker %s started successfully", workerInstance.ID())
+ glog.Infof("Press Ctrl+C to stop the worker")
+
+ // Wait for shutdown signal
+ <-sigChan
+ glog.Infof("Shutdown signal received, stopping worker...")
+
+ // Gracefully stop the worker
+ err = workerInstance.Stop()
+ if err != nil {
+ glog.Errorf("Error stopping worker: %v", err)
+ }
+ glog.Infof("Worker stopped")
+
+ return true
+}
+
+// parseCapabilities converts comma-separated capability string to task types
+func parseCapabilities(capabilityStr string) []types.TaskType {
+ if capabilityStr == "" {
+ return nil
+ }
+
+ capabilityMap := map[string]types.TaskType{}
+
+ // Populate capabilityMap with registered task types
+ typesRegistry := tasks.GetGlobalTypesRegistry()
+ for taskType := range typesRegistry.GetAllDetectors() {
+ // Use the task type string directly as the key
+ capabilityMap[strings.ToLower(string(taskType))] = taskType
+ }
+
+ // Add common aliases for convenience
+ if taskType, exists := capabilityMap["erasure_coding"]; exists {
+ capabilityMap["ec"] = taskType
+ }
+ if taskType, exists := capabilityMap["remote_upload"]; exists {
+ capabilityMap["remote"] = taskType
+ }
+ if taskType, exists := capabilityMap["fix_replication"]; exists {
+ capabilityMap["replication"] = taskType
+ }
+
+ var capabilities []types.TaskType
+ parts := strings.Split(capabilityStr, ",")
+
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if taskType, exists := capabilityMap[part]; exists {
+ capabilities = append(capabilities, taskType)
+ } else {
+ glog.Warningf("Unknown capability: %s", part)
+ }
+ }
+
+ return capabilities
+}
+
+// Legacy compatibility types for backward compatibility
+// These will be deprecated in future versions
+
+// WorkerStatus represents the current status of a worker (deprecated)
+type WorkerStatus struct {
+ WorkerID string `json:"worker_id"`
+ Address string `json:"address"`
+ Status string `json:"status"`
+ Capabilities []types.TaskType `json:"capabilities"`
+ MaxConcurrent int `json:"max_concurrent"`
+ CurrentLoad int `json:"current_load"`
+ LastHeartbeat time.Time `json:"last_heartbeat"`
+ CurrentTasks []types.Task `json:"current_tasks"`
+ Uptime time.Duration `json:"uptime"`
+ TasksCompleted int `json:"tasks_completed"`
+ TasksFailed int `json:"tasks_failed"`
+}
diff --git a/weed/pb/Makefile b/weed/pb/Makefile
index e1ff89dfd..e5db76426 100644
--- a/weed/pb/Makefile
+++ b/weed/pb/Makefile
@@ -13,6 +13,7 @@ gen:
protoc mq_broker.proto --go_out=./mq_pb --go-grpc_out=./mq_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
protoc mq_schema.proto --go_out=./schema_pb --go-grpc_out=./schema_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
protoc mq_agent.proto --go_out=./mq_agent_pb --go-grpc_out=./mq_agent_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
+ protoc worker.proto --go_out=./worker_pb --go-grpc_out=./worker_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
# protoc filer.proto --java_out=../../other/java/client/src/main/java
cp filer.proto ../../other/java/client/src/main/proto
diff --git a/weed/pb/grpc_client_server.go b/weed/pb/grpc_client_server.go
index d88c0ce3d..3bca1d07e 100644
--- a/weed/pb/grpc_client_server.go
+++ b/weed/pb/grpc_client_server.go
@@ -24,6 +24,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
+ "github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
)
const (
@@ -312,3 +313,10 @@ func WithOneOfGrpcFilerClients(streamingMode bool, filerAddresses []ServerAddres
return err
}
+
+func WithWorkerClient(streamingMode bool, workerAddress string, grpcDialOption grpc.DialOption, fn func(client worker_pb.WorkerServiceClient) error) error {
+ return WithGrpcClient(streamingMode, 0, func(grpcConnection *grpc.ClientConn) error {
+ client := worker_pb.NewWorkerServiceClient(grpcConnection)
+ return fn(client)
+ }, workerAddress, false, grpcDialOption)
+}
diff --git a/weed/pb/worker.proto b/weed/pb/worker.proto
new file mode 100644
index 000000000..d96fce7d0
--- /dev/null
+++ b/weed/pb/worker.proto
@@ -0,0 +1,142 @@
+syntax = "proto3";
+
+package worker_pb;
+
+option go_package = "github.com/seaweedfs/seaweedfs/weed/pb/worker_pb";
+
+// WorkerService provides bidirectional communication between admin and worker
+service WorkerService {
+ // WorkerStream maintains a bidirectional stream for worker communication
+ rpc WorkerStream(stream WorkerMessage) returns (stream AdminMessage);
+}
+
+// WorkerMessage represents messages from worker to admin
+message WorkerMessage {
+ string worker_id = 1;
+ int64 timestamp = 2;
+
+ oneof message {
+ WorkerRegistration registration = 3;
+ WorkerHeartbeat heartbeat = 4;
+ TaskRequest task_request = 5;
+ TaskUpdate task_update = 6;
+ TaskComplete task_complete = 7;
+ WorkerShutdown shutdown = 8;
+ }
+}
+
+// AdminMessage represents messages from admin to worker
+message AdminMessage {
+ string admin_id = 1;
+ int64 timestamp = 2;
+
+ oneof message {
+ RegistrationResponse registration_response = 3;
+ HeartbeatResponse heartbeat_response = 4;
+ TaskAssignment task_assignment = 5;
+ TaskCancellation task_cancellation = 6;
+ AdminShutdown admin_shutdown = 7;
+ }
+}
+
+// WorkerRegistration message when worker connects
+message WorkerRegistration {
+ string worker_id = 1;
+ string address = 2;
+ repeated string capabilities = 3;
+ int32 max_concurrent = 4;
+ map<string, string> metadata = 5;
+}
+
+// RegistrationResponse confirms worker registration
+message RegistrationResponse {
+ bool success = 1;
+ string message = 2;
+ string assigned_worker_id = 3;
+}
+
+// WorkerHeartbeat sent periodically by worker
+message WorkerHeartbeat {
+ string worker_id = 1;
+ string status = 2;
+ int32 current_load = 3;
+ int32 max_concurrent = 4;
+ repeated string current_task_ids = 5;
+ int32 tasks_completed = 6;
+ int32 tasks_failed = 7;
+ int64 uptime_seconds = 8;
+}
+
+// HeartbeatResponse acknowledges heartbeat
+message HeartbeatResponse {
+ bool success = 1;
+ string message = 2;
+}
+
+// TaskRequest from worker asking for new tasks
+message TaskRequest {
+ string worker_id = 1;
+ repeated string capabilities = 2;
+ int32 available_slots = 3;
+}
+
+// TaskAssignment from admin to worker
+message TaskAssignment {
+ string task_id = 1;
+ string task_type = 2;
+ TaskParams params = 3;
+ int32 priority = 4;
+ int64 created_time = 5;
+ map<string, string> metadata = 6;
+}
+
+// TaskParams contains task-specific parameters
+message TaskParams {
+ uint32 volume_id = 1;
+ string server = 2;
+ string collection = 3;
+ string data_center = 4;
+ string rack = 5;
+ repeated string replicas = 6;
+ map<string, string> parameters = 7;
+}
+
+// TaskUpdate reports task progress
+message TaskUpdate {
+ string task_id = 1;
+ string worker_id = 2;
+ string status = 3;
+ float progress = 4;
+ string message = 5;
+ map<string, string> metadata = 6;
+}
+
+// TaskComplete reports task completion
+message TaskComplete {
+ string task_id = 1;
+ string worker_id = 2;
+ bool success = 3;
+ string error_message = 4;
+ int64 completion_time = 5;
+ map<string, string> result_metadata = 6;
+}
+
+// TaskCancellation from admin to cancel a task
+message TaskCancellation {
+ string task_id = 1;
+ string reason = 2;
+ bool force = 3;
+}
+
+// WorkerShutdown notifies admin that worker is shutting down
+message WorkerShutdown {
+ string worker_id = 1;
+ string reason = 2;
+ repeated string pending_task_ids = 3;
+}
+
+// AdminShutdown notifies worker that admin is shutting down
+message AdminShutdown {
+ string reason = 1;
+ int32 graceful_shutdown_seconds = 2;
+} \ No newline at end of file
diff --git a/weed/pb/worker_pb/worker.pb.go b/weed/pb/worker_pb/worker.pb.go
new file mode 100644
index 000000000..6f47e04f0
--- /dev/null
+++ b/weed/pb/worker_pb/worker.pb.go
@@ -0,0 +1,1724 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.34.2
+// protoc v5.29.3
+// source: worker.proto
+
+package worker_pb
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// WorkerMessage represents messages from worker to admin
+type WorkerMessage struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ WorkerId string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Timestamp int64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
+ // Types that are assignable to Message:
+ //
+ // *WorkerMessage_Registration
+ // *WorkerMessage_Heartbeat
+ // *WorkerMessage_TaskRequest
+ // *WorkerMessage_TaskUpdate
+ // *WorkerMessage_TaskComplete
+ // *WorkerMessage_Shutdown
+ Message isWorkerMessage_Message `protobuf_oneof:"message"`
+}
+
+func (x *WorkerMessage) Reset() {
+ *x = WorkerMessage{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *WorkerMessage) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WorkerMessage) ProtoMessage() {}
+
+func (x *WorkerMessage) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[0]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WorkerMessage.ProtoReflect.Descriptor instead.
+func (*WorkerMessage) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *WorkerMessage) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *WorkerMessage) GetTimestamp() int64 {
+ if x != nil {
+ return x.Timestamp
+ }
+ return 0
+}
+
+func (m *WorkerMessage) GetMessage() isWorkerMessage_Message {
+ if m != nil {
+ return m.Message
+ }
+ return nil
+}
+
+func (x *WorkerMessage) GetRegistration() *WorkerRegistration {
+ if x, ok := x.GetMessage().(*WorkerMessage_Registration); ok {
+ return x.Registration
+ }
+ return nil
+}
+
+func (x *WorkerMessage) GetHeartbeat() *WorkerHeartbeat {
+ if x, ok := x.GetMessage().(*WorkerMessage_Heartbeat); ok {
+ return x.Heartbeat
+ }
+ return nil
+}
+
+func (x *WorkerMessage) GetTaskRequest() *TaskRequest {
+ if x, ok := x.GetMessage().(*WorkerMessage_TaskRequest); ok {
+ return x.TaskRequest
+ }
+ return nil
+}
+
+func (x *WorkerMessage) GetTaskUpdate() *TaskUpdate {
+ if x, ok := x.GetMessage().(*WorkerMessage_TaskUpdate); ok {
+ return x.TaskUpdate
+ }
+ return nil
+}
+
+func (x *WorkerMessage) GetTaskComplete() *TaskComplete {
+ if x, ok := x.GetMessage().(*WorkerMessage_TaskComplete); ok {
+ return x.TaskComplete
+ }
+ return nil
+}
+
+func (x *WorkerMessage) GetShutdown() *WorkerShutdown {
+ if x, ok := x.GetMessage().(*WorkerMessage_Shutdown); ok {
+ return x.Shutdown
+ }
+ return nil
+}
+
+type isWorkerMessage_Message interface {
+ isWorkerMessage_Message()
+}
+
+type WorkerMessage_Registration struct {
+ Registration *WorkerRegistration `protobuf:"bytes,3,opt,name=registration,proto3,oneof"`
+}
+
+type WorkerMessage_Heartbeat struct {
+ Heartbeat *WorkerHeartbeat `protobuf:"bytes,4,opt,name=heartbeat,proto3,oneof"`
+}
+
+type WorkerMessage_TaskRequest struct {
+ TaskRequest *TaskRequest `protobuf:"bytes,5,opt,name=task_request,json=taskRequest,proto3,oneof"`
+}
+
+type WorkerMessage_TaskUpdate struct {
+ TaskUpdate *TaskUpdate `protobuf:"bytes,6,opt,name=task_update,json=taskUpdate,proto3,oneof"`
+}
+
+type WorkerMessage_TaskComplete struct {
+ TaskComplete *TaskComplete `protobuf:"bytes,7,opt,name=task_complete,json=taskComplete,proto3,oneof"`
+}
+
+type WorkerMessage_Shutdown struct {
+ Shutdown *WorkerShutdown `protobuf:"bytes,8,opt,name=shutdown,proto3,oneof"`
+}
+
+func (*WorkerMessage_Registration) isWorkerMessage_Message() {}
+
+func (*WorkerMessage_Heartbeat) isWorkerMessage_Message() {}
+
+func (*WorkerMessage_TaskRequest) isWorkerMessage_Message() {}
+
+func (*WorkerMessage_TaskUpdate) isWorkerMessage_Message() {}
+
+func (*WorkerMessage_TaskComplete) isWorkerMessage_Message() {}
+
+func (*WorkerMessage_Shutdown) isWorkerMessage_Message() {}
+
+// AdminMessage represents messages from admin to worker
+type AdminMessage struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ AdminId string `protobuf:"bytes,1,opt,name=admin_id,json=adminId,proto3" json:"admin_id,omitempty"`
+ Timestamp int64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
+ // Types that are assignable to Message:
+ //
+ // *AdminMessage_RegistrationResponse
+ // *AdminMessage_HeartbeatResponse
+ // *AdminMessage_TaskAssignment
+ // *AdminMessage_TaskCancellation
+ // *AdminMessage_AdminShutdown
+ Message isAdminMessage_Message `protobuf_oneof:"message"`
+}
+
+func (x *AdminMessage) Reset() {
+ *x = AdminMessage{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *AdminMessage) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AdminMessage) ProtoMessage() {}
+
+func (x *AdminMessage) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[1]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AdminMessage.ProtoReflect.Descriptor instead.
+func (*AdminMessage) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *AdminMessage) GetAdminId() string {
+ if x != nil {
+ return x.AdminId
+ }
+ return ""
+}
+
+func (x *AdminMessage) GetTimestamp() int64 {
+ if x != nil {
+ return x.Timestamp
+ }
+ return 0
+}
+
+func (m *AdminMessage) GetMessage() isAdminMessage_Message {
+ if m != nil {
+ return m.Message
+ }
+ return nil
+}
+
+func (x *AdminMessage) GetRegistrationResponse() *RegistrationResponse {
+ if x, ok := x.GetMessage().(*AdminMessage_RegistrationResponse); ok {
+ return x.RegistrationResponse
+ }
+ return nil
+}
+
+func (x *AdminMessage) GetHeartbeatResponse() *HeartbeatResponse {
+ if x, ok := x.GetMessage().(*AdminMessage_HeartbeatResponse); ok {
+ return x.HeartbeatResponse
+ }
+ return nil
+}
+
+func (x *AdminMessage) GetTaskAssignment() *TaskAssignment {
+ if x, ok := x.GetMessage().(*AdminMessage_TaskAssignment); ok {
+ return x.TaskAssignment
+ }
+ return nil
+}
+
+func (x *AdminMessage) GetTaskCancellation() *TaskCancellation {
+ if x, ok := x.GetMessage().(*AdminMessage_TaskCancellation); ok {
+ return x.TaskCancellation
+ }
+ return nil
+}
+
+func (x *AdminMessage) GetAdminShutdown() *AdminShutdown {
+ if x, ok := x.GetMessage().(*AdminMessage_AdminShutdown); ok {
+ return x.AdminShutdown
+ }
+ return nil
+}
+
+type isAdminMessage_Message interface {
+ isAdminMessage_Message()
+}
+
+type AdminMessage_RegistrationResponse struct {
+ RegistrationResponse *RegistrationResponse `protobuf:"bytes,3,opt,name=registration_response,json=registrationResponse,proto3,oneof"`
+}
+
+type AdminMessage_HeartbeatResponse struct {
+ HeartbeatResponse *HeartbeatResponse `protobuf:"bytes,4,opt,name=heartbeat_response,json=heartbeatResponse,proto3,oneof"`
+}
+
+type AdminMessage_TaskAssignment struct {
+ TaskAssignment *TaskAssignment `protobuf:"bytes,5,opt,name=task_assignment,json=taskAssignment,proto3,oneof"`
+}
+
+type AdminMessage_TaskCancellation struct {
+ TaskCancellation *TaskCancellation `protobuf:"bytes,6,opt,name=task_cancellation,json=taskCancellation,proto3,oneof"`
+}
+
+type AdminMessage_AdminShutdown struct {
+ AdminShutdown *AdminShutdown `protobuf:"bytes,7,opt,name=admin_shutdown,json=adminShutdown,proto3,oneof"`
+}
+
+func (*AdminMessage_RegistrationResponse) isAdminMessage_Message() {}
+
+func (*AdminMessage_HeartbeatResponse) isAdminMessage_Message() {}
+
+func (*AdminMessage_TaskAssignment) isAdminMessage_Message() {}
+
+func (*AdminMessage_TaskCancellation) isAdminMessage_Message() {}
+
+func (*AdminMessage_AdminShutdown) isAdminMessage_Message() {}
+
+// WorkerRegistration message when worker connects
+type WorkerRegistration struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ WorkerId string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"`
+ Capabilities []string `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty"`
+ MaxConcurrent int32 `protobuf:"varint,4,opt,name=max_concurrent,json=maxConcurrent,proto3" json:"max_concurrent,omitempty"`
+ Metadata map[string]string `protobuf:"bytes,5,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+}
+
+func (x *WorkerRegistration) Reset() {
+ *x = WorkerRegistration{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *WorkerRegistration) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WorkerRegistration) ProtoMessage() {}
+
+func (x *WorkerRegistration) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[2]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WorkerRegistration.ProtoReflect.Descriptor instead.
+func (*WorkerRegistration) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *WorkerRegistration) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *WorkerRegistration) GetAddress() string {
+ if x != nil {
+ return x.Address
+ }
+ return ""
+}
+
+func (x *WorkerRegistration) GetCapabilities() []string {
+ if x != nil {
+ return x.Capabilities
+ }
+ return nil
+}
+
+func (x *WorkerRegistration) GetMaxConcurrent() int32 {
+ if x != nil {
+ return x.MaxConcurrent
+ }
+ return 0
+}
+
+func (x *WorkerRegistration) GetMetadata() map[string]string {
+ if x != nil {
+ return x.Metadata
+ }
+ return nil
+}
+
+// RegistrationResponse confirms worker registration
+type RegistrationResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+ Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
+ AssignedWorkerId string `protobuf:"bytes,3,opt,name=assigned_worker_id,json=assignedWorkerId,proto3" json:"assigned_worker_id,omitempty"`
+}
+
+func (x *RegistrationResponse) Reset() {
+ *x = RegistrationResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *RegistrationResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RegistrationResponse) ProtoMessage() {}
+
+func (x *RegistrationResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[3]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RegistrationResponse.ProtoReflect.Descriptor instead.
+func (*RegistrationResponse) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *RegistrationResponse) GetSuccess() bool {
+ if x != nil {
+ return x.Success
+ }
+ return false
+}
+
+func (x *RegistrationResponse) GetMessage() string {
+ if x != nil {
+ return x.Message
+ }
+ return ""
+}
+
+func (x *RegistrationResponse) GetAssignedWorkerId() string {
+ if x != nil {
+ return x.AssignedWorkerId
+ }
+ return ""
+}
+
+// WorkerHeartbeat sent periodically by worker
+type WorkerHeartbeat struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ WorkerId string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
+ CurrentLoad int32 `protobuf:"varint,3,opt,name=current_load,json=currentLoad,proto3" json:"current_load,omitempty"`
+ MaxConcurrent int32 `protobuf:"varint,4,opt,name=max_concurrent,json=maxConcurrent,proto3" json:"max_concurrent,omitempty"`
+ CurrentTaskIds []string `protobuf:"bytes,5,rep,name=current_task_ids,json=currentTaskIds,proto3" json:"current_task_ids,omitempty"`
+ TasksCompleted int32 `protobuf:"varint,6,opt,name=tasks_completed,json=tasksCompleted,proto3" json:"tasks_completed,omitempty"`
+ TasksFailed int32 `protobuf:"varint,7,opt,name=tasks_failed,json=tasksFailed,proto3" json:"tasks_failed,omitempty"`
+ UptimeSeconds int64 `protobuf:"varint,8,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"`
+}
+
+func (x *WorkerHeartbeat) Reset() {
+ *x = WorkerHeartbeat{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *WorkerHeartbeat) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WorkerHeartbeat) ProtoMessage() {}
+
+func (x *WorkerHeartbeat) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[4]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WorkerHeartbeat.ProtoReflect.Descriptor instead.
+func (*WorkerHeartbeat) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *WorkerHeartbeat) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *WorkerHeartbeat) GetStatus() string {
+ if x != nil {
+ return x.Status
+ }
+ return ""
+}
+
+func (x *WorkerHeartbeat) GetCurrentLoad() int32 {
+ if x != nil {
+ return x.CurrentLoad
+ }
+ return 0
+}
+
+func (x *WorkerHeartbeat) GetMaxConcurrent() int32 {
+ if x != nil {
+ return x.MaxConcurrent
+ }
+ return 0
+}
+
+func (x *WorkerHeartbeat) GetCurrentTaskIds() []string {
+ if x != nil {
+ return x.CurrentTaskIds
+ }
+ return nil
+}
+
+func (x *WorkerHeartbeat) GetTasksCompleted() int32 {
+ if x != nil {
+ return x.TasksCompleted
+ }
+ return 0
+}
+
+func (x *WorkerHeartbeat) GetTasksFailed() int32 {
+ if x != nil {
+ return x.TasksFailed
+ }
+ return 0
+}
+
+func (x *WorkerHeartbeat) GetUptimeSeconds() int64 {
+ if x != nil {
+ return x.UptimeSeconds
+ }
+ return 0
+}
+
+// HeartbeatResponse acknowledges heartbeat
+type HeartbeatResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+ Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
+}
+
+func (x *HeartbeatResponse) Reset() {
+ *x = HeartbeatResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *HeartbeatResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HeartbeatResponse) ProtoMessage() {}
+
+func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[5]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead.
+func (*HeartbeatResponse) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *HeartbeatResponse) GetSuccess() bool {
+ if x != nil {
+ return x.Success
+ }
+ return false
+}
+
+func (x *HeartbeatResponse) GetMessage() string {
+ if x != nil {
+ return x.Message
+ }
+ return ""
+}
+
+// TaskRequest from worker asking for new tasks
+type TaskRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ WorkerId string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Capabilities []string `protobuf:"bytes,2,rep,name=capabilities,proto3" json:"capabilities,omitempty"`
+ AvailableSlots int32 `protobuf:"varint,3,opt,name=available_slots,json=availableSlots,proto3" json:"available_slots,omitempty"`
+}
+
+func (x *TaskRequest) Reset() {
+ *x = TaskRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *TaskRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TaskRequest) ProtoMessage() {}
+
+func (x *TaskRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[6]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TaskRequest.ProtoReflect.Descriptor instead.
+func (*TaskRequest) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *TaskRequest) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *TaskRequest) GetCapabilities() []string {
+ if x != nil {
+ return x.Capabilities
+ }
+ return nil
+}
+
+func (x *TaskRequest) GetAvailableSlots() int32 {
+ if x != nil {
+ return x.AvailableSlots
+ }
+ return 0
+}
+
+// TaskAssignment from admin to worker
+type TaskAssignment struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
+ TaskType string `protobuf:"bytes,2,opt,name=task_type,json=taskType,proto3" json:"task_type,omitempty"`
+ Params *TaskParams `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"`
+ Priority int32 `protobuf:"varint,4,opt,name=priority,proto3" json:"priority,omitempty"`
+ CreatedTime int64 `protobuf:"varint,5,opt,name=created_time,json=createdTime,proto3" json:"created_time,omitempty"`
+ Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+}
+
+func (x *TaskAssignment) Reset() {
+ *x = TaskAssignment{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *TaskAssignment) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TaskAssignment) ProtoMessage() {}
+
+func (x *TaskAssignment) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[7]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TaskAssignment.ProtoReflect.Descriptor instead.
+func (*TaskAssignment) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *TaskAssignment) GetTaskId() string {
+ if x != nil {
+ return x.TaskId
+ }
+ return ""
+}
+
+func (x *TaskAssignment) GetTaskType() string {
+ if x != nil {
+ return x.TaskType
+ }
+ return ""
+}
+
+func (x *TaskAssignment) GetParams() *TaskParams {
+ if x != nil {
+ return x.Params
+ }
+ return nil
+}
+
+func (x *TaskAssignment) GetPriority() int32 {
+ if x != nil {
+ return x.Priority
+ }
+ return 0
+}
+
+func (x *TaskAssignment) GetCreatedTime() int64 {
+ if x != nil {
+ return x.CreatedTime
+ }
+ return 0
+}
+
+func (x *TaskAssignment) GetMetadata() map[string]string {
+ if x != nil {
+ return x.Metadata
+ }
+ return nil
+}
+
+// TaskParams contains task-specific parameters
+type TaskParams struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ VolumeId uint32 `protobuf:"varint,1,opt,name=volume_id,json=volumeId,proto3" json:"volume_id,omitempty"`
+ Server string `protobuf:"bytes,2,opt,name=server,proto3" json:"server,omitempty"`
+ Collection string `protobuf:"bytes,3,opt,name=collection,proto3" json:"collection,omitempty"`
+ DataCenter string `protobuf:"bytes,4,opt,name=data_center,json=dataCenter,proto3" json:"data_center,omitempty"`
+ Rack string `protobuf:"bytes,5,opt,name=rack,proto3" json:"rack,omitempty"`
+ Replicas []string `protobuf:"bytes,6,rep,name=replicas,proto3" json:"replicas,omitempty"`
+ Parameters map[string]string `protobuf:"bytes,7,rep,name=parameters,proto3" json:"parameters,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+}
+
+func (x *TaskParams) Reset() {
+ *x = TaskParams{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *TaskParams) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TaskParams) ProtoMessage() {}
+
+func (x *TaskParams) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[8]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TaskParams.ProtoReflect.Descriptor instead.
+func (*TaskParams) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *TaskParams) GetVolumeId() uint32 {
+ if x != nil {
+ return x.VolumeId
+ }
+ return 0
+}
+
+func (x *TaskParams) GetServer() string {
+ if x != nil {
+ return x.Server
+ }
+ return ""
+}
+
+func (x *TaskParams) GetCollection() string {
+ if x != nil {
+ return x.Collection
+ }
+ return ""
+}
+
+func (x *TaskParams) GetDataCenter() string {
+ if x != nil {
+ return x.DataCenter
+ }
+ return ""
+}
+
+func (x *TaskParams) GetRack() string {
+ if x != nil {
+ return x.Rack
+ }
+ return ""
+}
+
+func (x *TaskParams) GetReplicas() []string {
+ if x != nil {
+ return x.Replicas
+ }
+ return nil
+}
+
+func (x *TaskParams) GetParameters() map[string]string {
+ if x != nil {
+ return x.Parameters
+ }
+ return nil
+}
+
+// TaskUpdate reports task progress
+type TaskUpdate struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
+ WorkerId string `protobuf:"bytes,2,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
+ Progress float32 `protobuf:"fixed32,4,opt,name=progress,proto3" json:"progress,omitempty"`
+ Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"`
+ Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+}
+
+func (x *TaskUpdate) Reset() {
+ *x = TaskUpdate{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *TaskUpdate) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TaskUpdate) ProtoMessage() {}
+
+func (x *TaskUpdate) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[9]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TaskUpdate.ProtoReflect.Descriptor instead.
+func (*TaskUpdate) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *TaskUpdate) GetTaskId() string {
+ if x != nil {
+ return x.TaskId
+ }
+ return ""
+}
+
+func (x *TaskUpdate) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *TaskUpdate) GetStatus() string {
+ if x != nil {
+ return x.Status
+ }
+ return ""
+}
+
+func (x *TaskUpdate) GetProgress() float32 {
+ if x != nil {
+ return x.Progress
+ }
+ return 0
+}
+
+func (x *TaskUpdate) GetMessage() string {
+ if x != nil {
+ return x.Message
+ }
+ return ""
+}
+
+func (x *TaskUpdate) GetMetadata() map[string]string {
+ if x != nil {
+ return x.Metadata
+ }
+ return nil
+}
+
+// TaskComplete reports task completion
+type TaskComplete struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
+ WorkerId string `protobuf:"bytes,2,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Success bool `protobuf:"varint,3,opt,name=success,proto3" json:"success,omitempty"`
+ ErrorMessage string `protobuf:"bytes,4,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"`
+ CompletionTime int64 `protobuf:"varint,5,opt,name=completion_time,json=completionTime,proto3" json:"completion_time,omitempty"`
+ ResultMetadata map[string]string `protobuf:"bytes,6,rep,name=result_metadata,json=resultMetadata,proto3" json:"result_metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+}
+
+func (x *TaskComplete) Reset() {
+ *x = TaskComplete{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *TaskComplete) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TaskComplete) ProtoMessage() {}
+
+func (x *TaskComplete) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[10]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TaskComplete.ProtoReflect.Descriptor instead.
+func (*TaskComplete) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *TaskComplete) GetTaskId() string {
+ if x != nil {
+ return x.TaskId
+ }
+ return ""
+}
+
+func (x *TaskComplete) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *TaskComplete) GetSuccess() bool {
+ if x != nil {
+ return x.Success
+ }
+ return false
+}
+
+func (x *TaskComplete) GetErrorMessage() string {
+ if x != nil {
+ return x.ErrorMessage
+ }
+ return ""
+}
+
+func (x *TaskComplete) GetCompletionTime() int64 {
+ if x != nil {
+ return x.CompletionTime
+ }
+ return 0
+}
+
+func (x *TaskComplete) GetResultMetadata() map[string]string {
+ if x != nil {
+ return x.ResultMetadata
+ }
+ return nil
+}
+
+// TaskCancellation from admin to cancel a task
+type TaskCancellation struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"`
+ Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
+ Force bool `protobuf:"varint,3,opt,name=force,proto3" json:"force,omitempty"`
+}
+
+func (x *TaskCancellation) Reset() {
+ *x = TaskCancellation{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[11]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *TaskCancellation) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TaskCancellation) ProtoMessage() {}
+
+func (x *TaskCancellation) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[11]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TaskCancellation.ProtoReflect.Descriptor instead.
+func (*TaskCancellation) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *TaskCancellation) GetTaskId() string {
+ if x != nil {
+ return x.TaskId
+ }
+ return ""
+}
+
+func (x *TaskCancellation) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+func (x *TaskCancellation) GetForce() bool {
+ if x != nil {
+ return x.Force
+ }
+ return false
+}
+
+// WorkerShutdown notifies admin that worker is shutting down
+type WorkerShutdown struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ WorkerId string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"`
+ Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
+ PendingTaskIds []string `protobuf:"bytes,3,rep,name=pending_task_ids,json=pendingTaskIds,proto3" json:"pending_task_ids,omitempty"`
+}
+
+func (x *WorkerShutdown) Reset() {
+ *x = WorkerShutdown{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[12]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *WorkerShutdown) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*WorkerShutdown) ProtoMessage() {}
+
+func (x *WorkerShutdown) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[12]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use WorkerShutdown.ProtoReflect.Descriptor instead.
+func (*WorkerShutdown) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *WorkerShutdown) GetWorkerId() string {
+ if x != nil {
+ return x.WorkerId
+ }
+ return ""
+}
+
+func (x *WorkerShutdown) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+func (x *WorkerShutdown) GetPendingTaskIds() []string {
+ if x != nil {
+ return x.PendingTaskIds
+ }
+ return nil
+}
+
+// AdminShutdown notifies worker that admin is shutting down
+type AdminShutdown struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"`
+ GracefulShutdownSeconds int32 `protobuf:"varint,2,opt,name=graceful_shutdown_seconds,json=gracefulShutdownSeconds,proto3" json:"graceful_shutdown_seconds,omitempty"`
+}
+
+func (x *AdminShutdown) Reset() {
+ *x = AdminShutdown{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_worker_proto_msgTypes[13]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *AdminShutdown) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AdminShutdown) ProtoMessage() {}
+
+func (x *AdminShutdown) ProtoReflect() protoreflect.Message {
+ mi := &file_worker_proto_msgTypes[13]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AdminShutdown.ProtoReflect.Descriptor instead.
+func (*AdminShutdown) Descriptor() ([]byte, []int) {
+ return file_worker_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *AdminShutdown) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
+func (x *AdminShutdown) GetGracefulShutdownSeconds() int32 {
+ if x != nil {
+ return x.GracefulShutdownSeconds
+ }
+ return 0
+}
+
+var File_worker_proto protoreflect.FileDescriptor
+
+var file_worker_proto_rawDesc = []byte{
+ 0x0a, 0x0c, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09,
+ 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x22, 0xc6, 0x03, 0x0a, 0x0d, 0x57, 0x6f,
+ 0x72, 0x6b, 0x65, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x77,
+ 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
+ 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65,
+ 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d,
+ 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x43, 0x0a, 0x0c, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74,
+ 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x77,
+ 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x52,
+ 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, 0x0c, 0x72,
+ 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x0a, 0x09, 0x68,
+ 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+ 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x65,
+ 0x72, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65,
+ 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x12, 0x3b, 0x0a, 0x0c, 0x74, 0x61, 0x73, 0x6b, 0x5f,
+ 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e,
+ 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x74, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x0b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x75, 0x70, 0x64,
+ 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x6f, 0x72, 0x6b,
+ 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+ 0x48, 0x00, 0x52, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x3e,
+ 0x0a, 0x0d, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18,
+ 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70,
+ 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x00,
+ 0x52, 0x0c, 0x74, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x37,
+ 0x0a, 0x08, 0x73, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x19, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x57, 0x6f, 0x72,
+ 0x6b, 0x65, 0x72, 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x48, 0x00, 0x52, 0x08, 0x73,
+ 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61,
+ 0x67, 0x65, 0x22, 0xce, 0x03, 0x0a, 0x0c, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x4d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x49, 0x64, 0x12, 0x1c,
+ 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x56, 0x0a, 0x15,
+ 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x6f,
+ 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61,
+ 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x14,
+ 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x12, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61,
+ 0x74, 0x5f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x1c, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x48, 0x65, 0x61,
+ 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00,
+ 0x52, 0x11, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
+ 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0f, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x61, 0x73, 0x73, 0x69,
+ 0x67, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x77,
+ 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x41, 0x73, 0x73,
+ 0x69, 0x67, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x74, 0x61, 0x73, 0x6b, 0x41,
+ 0x73, 0x73, 0x69, 0x67, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x11, 0x74, 0x61, 0x73,
+ 0x6b, 0x5f, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06,
+ 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62,
+ 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c, 0x61, 0x74, 0x69, 0x6f,
+ 0x6e, 0x48, 0x00, 0x52, 0x10, 0x74, 0x61, 0x73, 0x6b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c,
+ 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x41, 0x0a, 0x0e, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x73,
+ 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e,
+ 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53,
+ 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x48, 0x00, 0x52, 0x0d, 0x61, 0x64, 0x6d, 0x69, 0x6e,
+ 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x22, 0x9c, 0x02, 0x0a, 0x12, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x52, 0x65,
+ 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x6f,
+ 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77,
+ 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65,
+ 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73,
+ 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65,
+ 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c,
+ 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x61, 0x78, 0x5f, 0x63, 0x6f, 0x6e,
+ 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x6d,
+ 0x61, 0x78, 0x43, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x47, 0x0a, 0x08,
+ 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b,
+ 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x65,
+ 0x72, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x4d, 0x65,
+ 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74,
+ 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+ 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75,
+ 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02,
+ 0x38, 0x01, 0x22, 0x78, 0x0a, 0x14, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69,
+ 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75,
+ 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63,
+ 0x63, 0x65, 0x73, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2c,
+ 0x0a, 0x12, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x65,
+ 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x61, 0x73, 0x73, 0x69,
+ 0x67, 0x6e, 0x65, 0x64, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49, 0x64, 0x22, 0xad, 0x02, 0x0a,
+ 0x0f, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74,
+ 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a,
+ 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73,
+ 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
+ 0x5f, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x63, 0x75, 0x72,
+ 0x72, 0x65, 0x6e, 0x74, 0x4c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x61, 0x78, 0x5f,
+ 0x63, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05,
+ 0x52, 0x0d, 0x6d, 0x61, 0x78, 0x43, 0x6f, 0x6e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x12,
+ 0x28, 0x0a, 0x10, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x5f,
+ 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65,
+ 0x6e, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x61, 0x73,
+ 0x6b, 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01,
+ 0x28, 0x05, 0x52, 0x0e, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74,
+ 0x65, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x5f, 0x66, 0x61, 0x69, 0x6c,
+ 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x46,
+ 0x61, 0x69, 0x6c, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x5f,
+ 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x75,
+ 0x70, 0x74, 0x69, 0x6d, 0x65, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x22, 0x47, 0x0a, 0x11,
+ 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+ 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6d,
+ 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65,
+ 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x77, 0x0a, 0x0b, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x69,
+ 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49,
+ 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65,
+ 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c,
+ 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62,
+ 0x6c, 0x65, 0x5f, 0x73, 0x6c, 0x6f, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0e,
+ 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x73, 0x22, 0xb6,
+ 0x02, 0x0a, 0x0e, 0x54, 0x61, 0x73, 0x6b, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x6d, 0x65, 0x6e,
+ 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x61,
+ 0x73, 0x6b, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74,
+ 0x61, 0x73, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2d, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d,
+ 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72,
+ 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x52, 0x06,
+ 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69,
+ 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69,
+ 0x74, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x69,
+ 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
+ 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x43, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+ 0x61, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72,
+ 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79,
+ 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65,
+ 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
+ 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a,
+ 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61,
+ 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xb8, 0x02, 0x0a, 0x0a, 0x54, 0x61, 0x73, 0x6b,
+ 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65,
+ 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x76, 0x6f, 0x6c, 0x75, 0x6d,
+ 0x65, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x1e, 0x0a, 0x0a, 0x63,
+ 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x0a, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x64,
+ 0x61, 0x74, 0x61, 0x5f, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04,
+ 0x72, 0x61, 0x63, 0x6b, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x61, 0x63, 0x6b,
+ 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x73, 0x18, 0x06, 0x20, 0x03,
+ 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x73, 0x12, 0x45, 0x0a, 0x0a,
+ 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b,
+ 0x32, 0x25, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73,
+ 0x6b, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65,
+ 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74,
+ 0x65, 0x72, 0x73, 0x1a, 0x3d, 0x0a, 0x0f, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72,
+ 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75,
+ 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02,
+ 0x38, 0x01, 0x22, 0x8e, 0x02, 0x0a, 0x0a, 0x54, 0x61, 0x73, 0x6b, 0x55, 0x70, 0x64, 0x61, 0x74,
+ 0x65, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x6f,
+ 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77,
+ 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
+ 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x02, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6d,
+ 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65,
+ 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+ 0x61, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72,
+ 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x4d,
+ 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65,
+ 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
+ 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+ 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
+ 0x02, 0x38, 0x01, 0x22, 0xc5, 0x02, 0x0a, 0x0c, 0x54, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x6d, 0x70,
+ 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, 0x0a,
+ 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75,
+ 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63,
+ 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65,
+ 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72,
+ 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x63, 0x6f, 0x6d,
+ 0x70, 0x6c, 0x65, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01,
+ 0x28, 0x03, 0x52, 0x0e, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69,
+ 0x6d, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x6d, 0x65, 0x74,
+ 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x77, 0x6f,
+ 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x43, 0x6f, 0x6d, 0x70,
+ 0x6c, 0x65, 0x74, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64,
+ 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74,
+ 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x41, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x75,
+ 0x6c, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
+ 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
+ 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x59, 0x0a, 0x10, 0x54,
+ 0x61, 0x73, 0x6b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12,
+ 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73,
+ 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
+ 0x12, 0x14, 0x0a, 0x05, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x05, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x22, 0x6f, 0x0a, 0x0e, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72,
+ 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x77, 0x6f, 0x72, 0x6b,
+ 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x6f, 0x72,
+ 0x6b, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x28, 0x0a,
+ 0x10, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64,
+ 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67,
+ 0x54, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x73, 0x22, 0x63, 0x0a, 0x0d, 0x41, 0x64, 0x6d, 0x69, 0x6e,
+ 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73,
+ 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
+ 0x12, 0x3a, 0x0a, 0x19, 0x67, 0x72, 0x61, 0x63, 0x65, 0x66, 0x75, 0x6c, 0x5f, 0x73, 0x68, 0x75,
+ 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x05, 0x52, 0x17, 0x67, 0x72, 0x61, 0x63, 0x65, 0x66, 0x75, 0x6c, 0x53, 0x68, 0x75,
+ 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x32, 0x56, 0x0a, 0x0d,
+ 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a,
+ 0x0c, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x18, 0x2e,
+ 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72,
+ 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x17, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72,
+ 0x5f, 0x70, 0x62, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+ 0x28, 0x01, 0x30, 0x01, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63,
+ 0x6f, 0x6d, 0x2f, 0x73, 0x65, 0x61, 0x77, 0x65, 0x65, 0x64, 0x66, 0x73, 0x2f, 0x73, 0x65, 0x61,
+ 0x77, 0x65, 0x65, 0x64, 0x66, 0x73, 0x2f, 0x77, 0x65, 0x65, 0x64, 0x2f, 0x70, 0x62, 0x2f, 0x77,
+ 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x5f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+ file_worker_proto_rawDescOnce sync.Once
+ file_worker_proto_rawDescData = file_worker_proto_rawDesc
+)
+
+func file_worker_proto_rawDescGZIP() []byte {
+ file_worker_proto_rawDescOnce.Do(func() {
+ file_worker_proto_rawDescData = protoimpl.X.CompressGZIP(file_worker_proto_rawDescData)
+ })
+ return file_worker_proto_rawDescData
+}
+
+var file_worker_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
+var file_worker_proto_goTypes = []any{
+ (*WorkerMessage)(nil), // 0: worker_pb.WorkerMessage
+ (*AdminMessage)(nil), // 1: worker_pb.AdminMessage
+ (*WorkerRegistration)(nil), // 2: worker_pb.WorkerRegistration
+ (*RegistrationResponse)(nil), // 3: worker_pb.RegistrationResponse
+ (*WorkerHeartbeat)(nil), // 4: worker_pb.WorkerHeartbeat
+ (*HeartbeatResponse)(nil), // 5: worker_pb.HeartbeatResponse
+ (*TaskRequest)(nil), // 6: worker_pb.TaskRequest
+ (*TaskAssignment)(nil), // 7: worker_pb.TaskAssignment
+ (*TaskParams)(nil), // 8: worker_pb.TaskParams
+ (*TaskUpdate)(nil), // 9: worker_pb.TaskUpdate
+ (*TaskComplete)(nil), // 10: worker_pb.TaskComplete
+ (*TaskCancellation)(nil), // 11: worker_pb.TaskCancellation
+ (*WorkerShutdown)(nil), // 12: worker_pb.WorkerShutdown
+ (*AdminShutdown)(nil), // 13: worker_pb.AdminShutdown
+ nil, // 14: worker_pb.WorkerRegistration.MetadataEntry
+ nil, // 15: worker_pb.TaskAssignment.MetadataEntry
+ nil, // 16: worker_pb.TaskParams.ParametersEntry
+ nil, // 17: worker_pb.TaskUpdate.MetadataEntry
+ nil, // 18: worker_pb.TaskComplete.ResultMetadataEntry
+}
+var file_worker_proto_depIdxs = []int32{
+ 2, // 0: worker_pb.WorkerMessage.registration:type_name -> worker_pb.WorkerRegistration
+ 4, // 1: worker_pb.WorkerMessage.heartbeat:type_name -> worker_pb.WorkerHeartbeat
+ 6, // 2: worker_pb.WorkerMessage.task_request:type_name -> worker_pb.TaskRequest
+ 9, // 3: worker_pb.WorkerMessage.task_update:type_name -> worker_pb.TaskUpdate
+ 10, // 4: worker_pb.WorkerMessage.task_complete:type_name -> worker_pb.TaskComplete
+ 12, // 5: worker_pb.WorkerMessage.shutdown:type_name -> worker_pb.WorkerShutdown
+ 3, // 6: worker_pb.AdminMessage.registration_response:type_name -> worker_pb.RegistrationResponse
+ 5, // 7: worker_pb.AdminMessage.heartbeat_response:type_name -> worker_pb.HeartbeatResponse
+ 7, // 8: worker_pb.AdminMessage.task_assignment:type_name -> worker_pb.TaskAssignment
+ 11, // 9: worker_pb.AdminMessage.task_cancellation:type_name -> worker_pb.TaskCancellation
+ 13, // 10: worker_pb.AdminMessage.admin_shutdown:type_name -> worker_pb.AdminShutdown
+ 14, // 11: worker_pb.WorkerRegistration.metadata:type_name -> worker_pb.WorkerRegistration.MetadataEntry
+ 8, // 12: worker_pb.TaskAssignment.params:type_name -> worker_pb.TaskParams
+ 15, // 13: worker_pb.TaskAssignment.metadata:type_name -> worker_pb.TaskAssignment.MetadataEntry
+ 16, // 14: worker_pb.TaskParams.parameters:type_name -> worker_pb.TaskParams.ParametersEntry
+ 17, // 15: worker_pb.TaskUpdate.metadata:type_name -> worker_pb.TaskUpdate.MetadataEntry
+ 18, // 16: worker_pb.TaskComplete.result_metadata:type_name -> worker_pb.TaskComplete.ResultMetadataEntry
+ 0, // 17: worker_pb.WorkerService.WorkerStream:input_type -> worker_pb.WorkerMessage
+ 1, // 18: worker_pb.WorkerService.WorkerStream:output_type -> worker_pb.AdminMessage
+ 18, // [18:19] is the sub-list for method output_type
+ 17, // [17:18] is the sub-list for method input_type
+ 17, // [17:17] is the sub-list for extension type_name
+ 17, // [17:17] is the sub-list for extension extendee
+ 0, // [0:17] is the sub-list for field type_name
+}
+
+func init() { file_worker_proto_init() }
+func file_worker_proto_init() {
+ if File_worker_proto != nil {
+ return
+ }
+ if !protoimpl.UnsafeEnabled {
+ file_worker_proto_msgTypes[0].Exporter = func(v any, i int) any {
+ switch v := v.(*WorkerMessage); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[1].Exporter = func(v any, i int) any {
+ switch v := v.(*AdminMessage); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[2].Exporter = func(v any, i int) any {
+ switch v := v.(*WorkerRegistration); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[3].Exporter = func(v any, i int) any {
+ switch v := v.(*RegistrationResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[4].Exporter = func(v any, i int) any {
+ switch v := v.(*WorkerHeartbeat); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[5].Exporter = func(v any, i int) any {
+ switch v := v.(*HeartbeatResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[6].Exporter = func(v any, i int) any {
+ switch v := v.(*TaskRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[7].Exporter = func(v any, i int) any {
+ switch v := v.(*TaskAssignment); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[8].Exporter = func(v any, i int) any {
+ switch v := v.(*TaskParams); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[9].Exporter = func(v any, i int) any {
+ switch v := v.(*TaskUpdate); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[10].Exporter = func(v any, i int) any {
+ switch v := v.(*TaskComplete); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[11].Exporter = func(v any, i int) any {
+ switch v := v.(*TaskCancellation); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[12].Exporter = func(v any, i int) any {
+ switch v := v.(*WorkerShutdown); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_worker_proto_msgTypes[13].Exporter = func(v any, i int) any {
+ switch v := v.(*AdminShutdown); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ }
+ file_worker_proto_msgTypes[0].OneofWrappers = []any{
+ (*WorkerMessage_Registration)(nil),
+ (*WorkerMessage_Heartbeat)(nil),
+ (*WorkerMessage_TaskRequest)(nil),
+ (*WorkerMessage_TaskUpdate)(nil),
+ (*WorkerMessage_TaskComplete)(nil),
+ (*WorkerMessage_Shutdown)(nil),
+ }
+ file_worker_proto_msgTypes[1].OneofWrappers = []any{
+ (*AdminMessage_RegistrationResponse)(nil),
+ (*AdminMessage_HeartbeatResponse)(nil),
+ (*AdminMessage_TaskAssignment)(nil),
+ (*AdminMessage_TaskCancellation)(nil),
+ (*AdminMessage_AdminShutdown)(nil),
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: file_worker_proto_rawDesc,
+ NumEnums: 0,
+ NumMessages: 19,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_worker_proto_goTypes,
+ DependencyIndexes: file_worker_proto_depIdxs,
+ MessageInfos: file_worker_proto_msgTypes,
+ }.Build()
+ File_worker_proto = out.File
+ file_worker_proto_rawDesc = nil
+ file_worker_proto_goTypes = nil
+ file_worker_proto_depIdxs = nil
+}
diff --git a/weed/pb/worker_pb/worker_grpc.pb.go b/weed/pb/worker_pb/worker_grpc.pb.go
new file mode 100644
index 000000000..85bad96f4
--- /dev/null
+++ b/weed/pb/worker_pb/worker_grpc.pb.go
@@ -0,0 +1,121 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.5.1
+// - protoc v5.29.3
+// source: worker.proto
+
+package worker_pb
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.64.0 or later.
+const _ = grpc.SupportPackageIsVersion9
+
+const (
+ WorkerService_WorkerStream_FullMethodName = "/worker_pb.WorkerService/WorkerStream"
+)
+
+// WorkerServiceClient is the client API for WorkerService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+//
+// WorkerService provides bidirectional communication between admin and worker
+type WorkerServiceClient interface {
+ // WorkerStream maintains a bidirectional stream for worker communication
+ WorkerStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[WorkerMessage, AdminMessage], error)
+}
+
+type workerServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewWorkerServiceClient(cc grpc.ClientConnInterface) WorkerServiceClient {
+ return &workerServiceClient{cc}
+}
+
+func (c *workerServiceClient) WorkerStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[WorkerMessage, AdminMessage], error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx, &WorkerService_ServiceDesc.Streams[0], WorkerService_WorkerStream_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[WorkerMessage, AdminMessage]{ClientStream: stream}
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type WorkerService_WorkerStreamClient = grpc.BidiStreamingClient[WorkerMessage, AdminMessage]
+
+// WorkerServiceServer is the server API for WorkerService service.
+// All implementations must embed UnimplementedWorkerServiceServer
+// for forward compatibility.
+//
+// WorkerService provides bidirectional communication between admin and worker
+type WorkerServiceServer interface {
+ // WorkerStream maintains a bidirectional stream for worker communication
+ WorkerStream(grpc.BidiStreamingServer[WorkerMessage, AdminMessage]) error
+ mustEmbedUnimplementedWorkerServiceServer()
+}
+
+// UnimplementedWorkerServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedWorkerServiceServer struct{}
+
+func (UnimplementedWorkerServiceServer) WorkerStream(grpc.BidiStreamingServer[WorkerMessage, AdminMessage]) error {
+ return status.Errorf(codes.Unimplemented, "method WorkerStream not implemented")
+}
+func (UnimplementedWorkerServiceServer) mustEmbedUnimplementedWorkerServiceServer() {}
+func (UnimplementedWorkerServiceServer) testEmbeddedByValue() {}
+
+// UnsafeWorkerServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to WorkerServiceServer will
+// result in compilation errors.
+type UnsafeWorkerServiceServer interface {
+ mustEmbedUnimplementedWorkerServiceServer()
+}
+
+func RegisterWorkerServiceServer(s grpc.ServiceRegistrar, srv WorkerServiceServer) {
+ // If the following call pancis, it indicates UnimplementedWorkerServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&WorkerService_ServiceDesc, srv)
+}
+
+func _WorkerService_WorkerStream_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(WorkerServiceServer).WorkerStream(&grpc.GenericServerStream[WorkerMessage, AdminMessage]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type WorkerService_WorkerStreamServer = grpc.BidiStreamingServer[WorkerMessage, AdminMessage]
+
+// WorkerService_ServiceDesc is the grpc.ServiceDesc for WorkerService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var WorkerService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "worker_pb.WorkerService",
+ HandlerType: (*WorkerServiceServer)(nil),
+ Methods: []grpc.MethodDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "WorkerStream",
+ Handler: _WorkerService_WorkerStream_Handler,
+ ServerStreams: true,
+ ClientStreams: true,
+ },
+ },
+ Metadata: "worker.proto",
+}
diff --git a/weed/worker/client.go b/weed/worker/client.go
new file mode 100644
index 000000000..f9b42087c
--- /dev/null
+++ b/weed/worker/client.go
@@ -0,0 +1,761 @@
+package worker
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb"
+ "github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+ "google.golang.org/grpc"
+)
+
+// GrpcAdminClient implements AdminClient using gRPC bidirectional streaming
+type GrpcAdminClient struct {
+ adminAddress string
+ workerID string
+ dialOption grpc.DialOption
+
+ conn *grpc.ClientConn
+ client worker_pb.WorkerServiceClient
+ stream worker_pb.WorkerService_WorkerStreamClient
+ streamCtx context.Context
+ streamCancel context.CancelFunc
+
+ connected bool
+ reconnecting bool
+ shouldReconnect bool
+ mutex sync.RWMutex
+
+ // Reconnection parameters
+ maxReconnectAttempts int
+ reconnectBackoff time.Duration
+ maxReconnectBackoff time.Duration
+ reconnectMultiplier float64
+
+ // Worker registration info for re-registration after reconnection
+ lastWorkerInfo *types.Worker
+
+ // Channels for communication
+ outgoing chan *worker_pb.WorkerMessage
+ incoming chan *worker_pb.AdminMessage
+ responseChans map[string]chan *worker_pb.AdminMessage
+ responsesMutex sync.RWMutex
+
+ // Shutdown channel
+ shutdownChan chan struct{}
+}
+
+// NewGrpcAdminClient creates a new gRPC admin client
+func NewGrpcAdminClient(adminAddress string, workerID string, dialOption grpc.DialOption) *GrpcAdminClient {
+ // Admin uses HTTP port + 10000 as gRPC port
+ grpcAddress := pb.ServerToGrpcAddress(adminAddress)
+
+ return &GrpcAdminClient{
+ adminAddress: grpcAddress,
+ workerID: workerID,
+ dialOption: dialOption,
+ shouldReconnect: true,
+ maxReconnectAttempts: 0, // 0 means infinite attempts
+ reconnectBackoff: 1 * time.Second,
+ maxReconnectBackoff: 30 * time.Second,
+ reconnectMultiplier: 1.5,
+ outgoing: make(chan *worker_pb.WorkerMessage, 100),
+ incoming: make(chan *worker_pb.AdminMessage, 100),
+ responseChans: make(map[string]chan *worker_pb.AdminMessage),
+ shutdownChan: make(chan struct{}),
+ }
+}
+
+// Connect establishes gRPC connection to admin server with TLS detection
+func (c *GrpcAdminClient) Connect() error {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ if c.connected {
+ return fmt.Errorf("already connected")
+ }
+
+ // Detect TLS support and create appropriate connection
+ conn, err := c.createConnection()
+ if err != nil {
+ return fmt.Errorf("failed to connect to admin server: %v", err)
+ }
+
+ c.conn = conn
+ c.client = worker_pb.NewWorkerServiceClient(conn)
+
+ // Create bidirectional stream
+ c.streamCtx, c.streamCancel = context.WithCancel(context.Background())
+ stream, err := c.client.WorkerStream(c.streamCtx)
+ if err != nil {
+ c.conn.Close()
+ return fmt.Errorf("failed to create worker stream: %v", err)
+ }
+
+ c.stream = stream
+ c.connected = true
+
+ // Start stream handlers and reconnection loop
+ go c.handleOutgoing()
+ go c.handleIncoming()
+ go c.reconnectionLoop()
+
+ glog.Infof("Connected to admin server at %s", c.adminAddress)
+ return nil
+}
+
+// createConnection attempts to connect using the provided dial option
+func (c *GrpcAdminClient) createConnection() (*grpc.ClientConn, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ conn, err := pb.GrpcDial(ctx, c.adminAddress, false, c.dialOption)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to admin server: %v", err)
+ }
+
+ glog.Infof("Connected to admin server at %s", c.adminAddress)
+ return conn, nil
+}
+
+// Disconnect closes the gRPC connection
+func (c *GrpcAdminClient) Disconnect() error {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ if !c.connected {
+ return nil
+ }
+
+ c.connected = false
+ c.shouldReconnect = false
+
+ // Send shutdown signal to stop reconnection loop
+ select {
+ case c.shutdownChan <- struct{}{}:
+ default:
+ }
+
+ // Send shutdown message
+ shutdownMsg := &worker_pb.WorkerMessage{
+ WorkerId: c.workerID,
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.WorkerMessage_Shutdown{
+ Shutdown: &worker_pb.WorkerShutdown{
+ WorkerId: c.workerID,
+ Reason: "normal shutdown",
+ },
+ },
+ }
+
+ select {
+ case c.outgoing <- shutdownMsg:
+ case <-time.After(time.Second):
+ glog.Warningf("Failed to send shutdown message")
+ }
+
+ // Cancel stream context
+ if c.streamCancel != nil {
+ c.streamCancel()
+ }
+
+ // Close stream
+ if c.stream != nil {
+ c.stream.CloseSend()
+ }
+
+ // Close connection
+ if c.conn != nil {
+ c.conn.Close()
+ }
+
+ // Close channels
+ close(c.outgoing)
+ close(c.incoming)
+
+ glog.Infof("Disconnected from admin server")
+ return nil
+}
+
+// reconnectionLoop handles automatic reconnection with exponential backoff
+func (c *GrpcAdminClient) reconnectionLoop() {
+ backoff := c.reconnectBackoff
+ attempts := 0
+
+ for {
+ select {
+ case <-c.shutdownChan:
+ return
+ default:
+ }
+
+ c.mutex.RLock()
+ shouldReconnect := c.shouldReconnect && !c.connected && !c.reconnecting
+ c.mutex.RUnlock()
+
+ if !shouldReconnect {
+ time.Sleep(time.Second)
+ continue
+ }
+
+ c.mutex.Lock()
+ c.reconnecting = true
+ c.mutex.Unlock()
+
+ glog.Infof("Attempting to reconnect to admin server (attempt %d)", attempts+1)
+
+ // Attempt to reconnect
+ if err := c.reconnect(); err != nil {
+ attempts++
+ glog.Errorf("Reconnection attempt %d failed: %v", attempts, err)
+
+ // Reset reconnecting flag
+ c.mutex.Lock()
+ c.reconnecting = false
+ c.mutex.Unlock()
+
+ // Check if we should give up
+ if c.maxReconnectAttempts > 0 && attempts >= c.maxReconnectAttempts {
+ glog.Errorf("Max reconnection attempts (%d) reached, giving up", c.maxReconnectAttempts)
+ c.mutex.Lock()
+ c.shouldReconnect = false
+ c.mutex.Unlock()
+ return
+ }
+
+ // Wait with exponential backoff
+ glog.Infof("Waiting %v before next reconnection attempt", backoff)
+
+ select {
+ case <-c.shutdownChan:
+ return
+ case <-time.After(backoff):
+ }
+
+ // Increase backoff
+ backoff = time.Duration(float64(backoff) * c.reconnectMultiplier)
+ if backoff > c.maxReconnectBackoff {
+ backoff = c.maxReconnectBackoff
+ }
+ } else {
+ // Successful reconnection
+ attempts = 0
+ backoff = c.reconnectBackoff
+ glog.Infof("Successfully reconnected to admin server")
+
+ c.mutex.Lock()
+ c.reconnecting = false
+ c.mutex.Unlock()
+ }
+ }
+}
+
+// reconnect attempts to re-establish the connection
+func (c *GrpcAdminClient) reconnect() error {
+ // Clean up existing connection completely
+ c.mutex.Lock()
+ if c.streamCancel != nil {
+ c.streamCancel()
+ }
+ if c.stream != nil {
+ c.stream.CloseSend()
+ }
+ if c.conn != nil {
+ c.conn.Close()
+ }
+ c.mutex.Unlock()
+
+ // Create new connection
+ conn, err := c.createConnection()
+ if err != nil {
+ return fmt.Errorf("failed to create connection: %v", err)
+ }
+
+ client := worker_pb.NewWorkerServiceClient(conn)
+
+ // Create new stream
+ streamCtx, streamCancel := context.WithCancel(context.Background())
+ stream, err := client.WorkerStream(streamCtx)
+ if err != nil {
+ conn.Close()
+ streamCancel()
+ return fmt.Errorf("failed to create stream: %v", err)
+ }
+
+ // Update client state
+ c.mutex.Lock()
+ c.conn = conn
+ c.client = client
+ c.stream = stream
+ c.streamCtx = streamCtx
+ c.streamCancel = streamCancel
+ c.connected = true
+ c.mutex.Unlock()
+
+ // Restart stream handlers
+ go c.handleOutgoing()
+ go c.handleIncoming()
+
+ // Re-register worker if we have previous registration info
+ c.mutex.RLock()
+ workerInfo := c.lastWorkerInfo
+ c.mutex.RUnlock()
+
+ if workerInfo != nil {
+ glog.Infof("Re-registering worker after reconnection...")
+ if err := c.sendRegistration(workerInfo); err != nil {
+ glog.Errorf("Failed to re-register worker: %v", err)
+ // Don't fail the reconnection because of registration failure
+ // The registration will be retried on next heartbeat or operation
+ }
+ }
+
+ return nil
+}
+
+// handleOutgoing processes outgoing messages to admin
+func (c *GrpcAdminClient) handleOutgoing() {
+ for msg := range c.outgoing {
+ c.mutex.RLock()
+ connected := c.connected
+ stream := c.stream
+ c.mutex.RUnlock()
+
+ if !connected {
+ break
+ }
+
+ if err := stream.Send(msg); err != nil {
+ glog.Errorf("Failed to send message to admin: %v", err)
+ c.mutex.Lock()
+ c.connected = false
+ c.mutex.Unlock()
+ break
+ }
+ }
+}
+
+// handleIncoming processes incoming messages from admin
+func (c *GrpcAdminClient) handleIncoming() {
+ for {
+ c.mutex.RLock()
+ connected := c.connected
+ stream := c.stream
+ c.mutex.RUnlock()
+
+ if !connected {
+ break
+ }
+
+ msg, err := stream.Recv()
+ if err != nil {
+ if err == io.EOF {
+ glog.Infof("Admin server closed the stream")
+ } else {
+ glog.Errorf("Failed to receive message from admin: %v", err)
+ }
+ c.mutex.Lock()
+ c.connected = false
+ c.mutex.Unlock()
+ break
+ }
+
+ // Route message to waiting goroutines or general handler
+ select {
+ case c.incoming <- msg:
+ case <-time.After(time.Second):
+ glog.Warningf("Incoming message buffer full, dropping message")
+ }
+ }
+}
+
+// RegisterWorker registers the worker with the admin server
+func (c *GrpcAdminClient) RegisterWorker(worker *types.Worker) error {
+ if !c.connected {
+ return fmt.Errorf("not connected to admin server")
+ }
+
+ // Store worker info for re-registration after reconnection
+ c.mutex.Lock()
+ c.lastWorkerInfo = worker
+ c.mutex.Unlock()
+
+ return c.sendRegistration(worker)
+}
+
+// sendRegistration sends the registration message and waits for response
+func (c *GrpcAdminClient) sendRegistration(worker *types.Worker) error {
+ capabilities := make([]string, len(worker.Capabilities))
+ for i, cap := range worker.Capabilities {
+ capabilities[i] = string(cap)
+ }
+
+ msg := &worker_pb.WorkerMessage{
+ WorkerId: c.workerID,
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.WorkerMessage_Registration{
+ Registration: &worker_pb.WorkerRegistration{
+ WorkerId: c.workerID,
+ Address: worker.Address,
+ Capabilities: capabilities,
+ MaxConcurrent: int32(worker.MaxConcurrent),
+ Metadata: make(map[string]string),
+ },
+ },
+ }
+
+ select {
+ case c.outgoing <- msg:
+ case <-time.After(5 * time.Second):
+ return fmt.Errorf("failed to send registration message: timeout")
+ }
+
+ // Wait for registration response
+ timeout := time.NewTimer(10 * time.Second)
+ defer timeout.Stop()
+
+ for {
+ select {
+ case response := <-c.incoming:
+ if regResp := response.GetRegistrationResponse(); regResp != nil {
+ if regResp.Success {
+ glog.Infof("Worker registered successfully: %s", regResp.Message)
+ return nil
+ }
+ return fmt.Errorf("registration failed: %s", regResp.Message)
+ }
+ case <-timeout.C:
+ return fmt.Errorf("registration timeout")
+ }
+ }
+}
+
+// SendHeartbeat sends heartbeat to admin server
+func (c *GrpcAdminClient) SendHeartbeat(workerID string, status *types.WorkerStatus) error {
+ if !c.connected {
+ // Wait for reconnection for a short time
+ if err := c.waitForConnection(10 * time.Second); err != nil {
+ return fmt.Errorf("not connected to admin server: %v", err)
+ }
+ }
+
+ taskIds := make([]string, len(status.CurrentTasks))
+ for i, task := range status.CurrentTasks {
+ taskIds[i] = task.ID
+ }
+
+ msg := &worker_pb.WorkerMessage{
+ WorkerId: c.workerID,
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.WorkerMessage_Heartbeat{
+ Heartbeat: &worker_pb.WorkerHeartbeat{
+ WorkerId: c.workerID,
+ Status: status.Status,
+ CurrentLoad: int32(status.CurrentLoad),
+ MaxConcurrent: int32(status.MaxConcurrent),
+ CurrentTaskIds: taskIds,
+ TasksCompleted: int32(status.TasksCompleted),
+ TasksFailed: int32(status.TasksFailed),
+ UptimeSeconds: int64(status.Uptime.Seconds()),
+ },
+ },
+ }
+
+ select {
+ case c.outgoing <- msg:
+ return nil
+ case <-time.After(time.Second):
+ return fmt.Errorf("failed to send heartbeat: timeout")
+ }
+}
+
+// RequestTask requests a new task from admin server
+func (c *GrpcAdminClient) RequestTask(workerID string, capabilities []types.TaskType) (*types.Task, error) {
+ if !c.connected {
+ // Wait for reconnection for a short time
+ if err := c.waitForConnection(5 * time.Second); err != nil {
+ return nil, fmt.Errorf("not connected to admin server: %v", err)
+ }
+ }
+
+ caps := make([]string, len(capabilities))
+ for i, cap := range capabilities {
+ caps[i] = string(cap)
+ }
+
+ msg := &worker_pb.WorkerMessage{
+ WorkerId: c.workerID,
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.WorkerMessage_TaskRequest{
+ TaskRequest: &worker_pb.TaskRequest{
+ WorkerId: c.workerID,
+ Capabilities: caps,
+ AvailableSlots: 1, // Request one task
+ },
+ },
+ }
+
+ select {
+ case c.outgoing <- msg:
+ case <-time.After(time.Second):
+ return nil, fmt.Errorf("failed to send task request: timeout")
+ }
+
+ // Wait for task assignment
+ timeout := time.NewTimer(5 * time.Second)
+ defer timeout.Stop()
+
+ for {
+ select {
+ case response := <-c.incoming:
+ if taskAssign := response.GetTaskAssignment(); taskAssign != nil {
+ // Convert parameters map[string]string to map[string]interface{}
+ parameters := make(map[string]interface{})
+ for k, v := range taskAssign.Params.Parameters {
+ parameters[k] = v
+ }
+
+ // Convert to our task type
+ task := &types.Task{
+ ID: taskAssign.TaskId,
+ Type: types.TaskType(taskAssign.TaskType),
+ Status: types.TaskStatusAssigned,
+ VolumeID: taskAssign.Params.VolumeId,
+ Server: taskAssign.Params.Server,
+ Collection: taskAssign.Params.Collection,
+ Priority: types.TaskPriority(taskAssign.Priority),
+ CreatedAt: time.Unix(taskAssign.CreatedTime, 0),
+ Parameters: parameters,
+ }
+ return task, nil
+ }
+ case <-timeout.C:
+ return nil, nil // No task available
+ }
+ }
+}
+
+// CompleteTask reports task completion to admin server
+func (c *GrpcAdminClient) CompleteTask(taskID string, success bool, errorMsg string) error {
+ if !c.connected {
+ // Wait for reconnection for a short time
+ if err := c.waitForConnection(5 * time.Second); err != nil {
+ return fmt.Errorf("not connected to admin server: %v", err)
+ }
+ }
+
+ msg := &worker_pb.WorkerMessage{
+ WorkerId: c.workerID,
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.WorkerMessage_TaskComplete{
+ TaskComplete: &worker_pb.TaskComplete{
+ TaskId: taskID,
+ WorkerId: c.workerID,
+ Success: success,
+ ErrorMessage: errorMsg,
+ CompletionTime: time.Now().Unix(),
+ },
+ },
+ }
+
+ select {
+ case c.outgoing <- msg:
+ return nil
+ case <-time.After(time.Second):
+ return fmt.Errorf("failed to send task completion: timeout")
+ }
+}
+
+// UpdateTaskProgress updates task progress to admin server
+func (c *GrpcAdminClient) UpdateTaskProgress(taskID string, progress float64) error {
+ if !c.connected {
+ // Wait for reconnection for a short time
+ if err := c.waitForConnection(5 * time.Second); err != nil {
+ return fmt.Errorf("not connected to admin server: %v", err)
+ }
+ }
+
+ msg := &worker_pb.WorkerMessage{
+ WorkerId: c.workerID,
+ Timestamp: time.Now().Unix(),
+ Message: &worker_pb.WorkerMessage_TaskUpdate{
+ TaskUpdate: &worker_pb.TaskUpdate{
+ TaskId: taskID,
+ WorkerId: c.workerID,
+ Status: "in_progress",
+ Progress: float32(progress),
+ },
+ },
+ }
+
+ select {
+ case c.outgoing <- msg:
+ return nil
+ case <-time.After(time.Second):
+ return fmt.Errorf("failed to send task progress: timeout")
+ }
+}
+
+// IsConnected returns whether the client is connected
+func (c *GrpcAdminClient) IsConnected() bool {
+ c.mutex.RLock()
+ defer c.mutex.RUnlock()
+ return c.connected
+}
+
+// IsReconnecting returns whether the client is currently attempting to reconnect
+func (c *GrpcAdminClient) IsReconnecting() bool {
+ c.mutex.RLock()
+ defer c.mutex.RUnlock()
+ return c.reconnecting
+}
+
+// SetReconnectionSettings allows configuration of reconnection behavior
+func (c *GrpcAdminClient) SetReconnectionSettings(maxAttempts int, initialBackoff, maxBackoff time.Duration, multiplier float64) {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+ c.maxReconnectAttempts = maxAttempts
+ c.reconnectBackoff = initialBackoff
+ c.maxReconnectBackoff = maxBackoff
+ c.reconnectMultiplier = multiplier
+}
+
+// StopReconnection stops the reconnection loop
+func (c *GrpcAdminClient) StopReconnection() {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+ c.shouldReconnect = false
+}
+
+// StartReconnection starts the reconnection loop
+func (c *GrpcAdminClient) StartReconnection() {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+ c.shouldReconnect = true
+}
+
+// waitForConnection waits for the connection to be established or timeout
+func (c *GrpcAdminClient) waitForConnection(timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+
+ for time.Now().Before(deadline) {
+ c.mutex.RLock()
+ connected := c.connected
+ shouldReconnect := c.shouldReconnect
+ c.mutex.RUnlock()
+
+ if connected {
+ return nil
+ }
+
+ if !shouldReconnect {
+ return fmt.Errorf("reconnection is disabled")
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ return fmt.Errorf("timeout waiting for connection")
+}
+
+// MockAdminClient provides a mock implementation for testing
+type MockAdminClient struct {
+ workerID string
+ connected bool
+ tasks []*types.Task
+ mutex sync.RWMutex
+}
+
+// NewMockAdminClient creates a new mock admin client
+func NewMockAdminClient() *MockAdminClient {
+ return &MockAdminClient{
+ connected: true,
+ tasks: make([]*types.Task, 0),
+ }
+}
+
+// Connect mock implementation
+func (m *MockAdminClient) Connect() error {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ m.connected = true
+ return nil
+}
+
+// Disconnect mock implementation
+func (m *MockAdminClient) Disconnect() error {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ m.connected = false
+ return nil
+}
+
+// RegisterWorker mock implementation
+func (m *MockAdminClient) RegisterWorker(worker *types.Worker) error {
+ m.workerID = worker.ID
+ glog.Infof("Mock: Worker %s registered with capabilities: %v", worker.ID, worker.Capabilities)
+ return nil
+}
+
+// SendHeartbeat mock implementation
+func (m *MockAdminClient) SendHeartbeat(workerID string, status *types.WorkerStatus) error {
+ glog.V(2).Infof("Mock: Heartbeat from worker %s, status: %s, load: %d/%d",
+ workerID, status.Status, status.CurrentLoad, status.MaxConcurrent)
+ return nil
+}
+
+// RequestTask mock implementation
+func (m *MockAdminClient) RequestTask(workerID string, capabilities []types.TaskType) (*types.Task, error) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ if len(m.tasks) > 0 {
+ task := m.tasks[0]
+ m.tasks = m.tasks[1:]
+ glog.Infof("Mock: Assigned task %s to worker %s", task.ID, workerID)
+ return task, nil
+ }
+
+ // No tasks available
+ return nil, nil
+}
+
+// CompleteTask mock implementation
+func (m *MockAdminClient) CompleteTask(taskID string, success bool, errorMsg string) error {
+ if success {
+ glog.Infof("Mock: Task %s completed successfully", taskID)
+ } else {
+ glog.Infof("Mock: Task %s failed: %s", taskID, errorMsg)
+ }
+ return nil
+}
+
+// UpdateTaskProgress mock implementation
+func (m *MockAdminClient) UpdateTaskProgress(taskID string, progress float64) error {
+ glog.V(2).Infof("Mock: Task %s progress: %.1f%%", taskID, progress)
+ return nil
+}
+
+// IsConnected mock implementation
+func (m *MockAdminClient) IsConnected() bool {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+ return m.connected
+}
+
+// AddMockTask adds a mock task for testing
+func (m *MockAdminClient) AddMockTask(task *types.Task) {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+ m.tasks = append(m.tasks, task)
+}
+
+// CreateAdminClient creates an admin client with the provided dial option
+func CreateAdminClient(adminServer string, workerID string, dialOption grpc.DialOption) (AdminClient, error) {
+ return NewGrpcAdminClient(adminServer, workerID, dialOption), nil
+}
diff --git a/weed/worker/client_test.go b/weed/worker/client_test.go
new file mode 100644
index 000000000..c57ea0240
--- /dev/null
+++ b/weed/worker/client_test.go
@@ -0,0 +1,111 @@
+package worker
+
+import (
+ "context"
+ "testing"
+
+ "github.com/seaweedfs/seaweedfs/weed/pb"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+func TestGrpcConnection(t *testing.T) {
+ // Test that we can create a gRPC connection with insecure credentials
+ // This tests the connection setup without requiring a running server
+ adminAddress := "localhost:33646" // gRPC port for admin server on port 23646
+
+ // This should not fail with transport security errors
+ conn, err := pb.GrpcDial(context.Background(), adminAddress, false, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ // Connection failure is expected when no server is running
+ // But it should NOT be a transport security error
+ if err.Error() == "grpc: no transport security set" {
+ t.Fatalf("Transport security error should not occur with insecure credentials: %v", err)
+ }
+ t.Logf("Connection failed as expected (no server running): %v", err)
+ } else {
+ // If connection succeeds, clean up
+ conn.Close()
+ t.Log("Connection succeeded")
+ }
+}
+
+func TestGrpcAdminClient_Connect(t *testing.T) {
+ // Test that the GrpcAdminClient can be created and attempt connection
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("localhost:23646", "test-worker", dialOption)
+
+ // This should not fail with transport security errors
+ err := client.Connect()
+ if err != nil {
+ // Connection failure is expected when no server is running
+ // But it should NOT be a transport security error
+ if err.Error() == "grpc: no transport security set" {
+ t.Fatalf("Transport security error should not occur with insecure credentials: %v", err)
+ }
+ t.Logf("Connection failed as expected (no server running): %v", err)
+ } else {
+ // If connection succeeds, clean up
+ client.Disconnect()
+ t.Log("Connection succeeded")
+ }
+}
+
+func TestAdminAddressToGrpcAddress(t *testing.T) {
+ tests := []struct {
+ adminAddress string
+ expected string
+ }{
+ {"localhost:9333", "localhost:19333"},
+ {"localhost:23646", "localhost:33646"},
+ {"admin.example.com:9333", "admin.example.com:19333"},
+ {"127.0.0.1:8080", "127.0.0.1:18080"},
+ }
+
+ for _, test := range tests {
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient(test.adminAddress, "test-worker", dialOption)
+ result := client.adminAddress
+ if result != test.expected {
+ t.Errorf("For admin address %s, expected gRPC address %s, got %s",
+ test.adminAddress, test.expected, result)
+ }
+ }
+}
+
+func TestMockAdminClient(t *testing.T) {
+ // Test that the mock client works correctly
+ client := NewMockAdminClient()
+
+ // Should be able to connect/disconnect without errors
+ err := client.Connect()
+ if err != nil {
+ t.Fatalf("Mock client connect failed: %v", err)
+ }
+
+ if !client.IsConnected() {
+ t.Error("Mock client should be connected")
+ }
+
+ err = client.Disconnect()
+ if err != nil {
+ t.Fatalf("Mock client disconnect failed: %v", err)
+ }
+
+ if client.IsConnected() {
+ t.Error("Mock client should be disconnected")
+ }
+}
+
+func TestCreateAdminClient(t *testing.T) {
+ // Test client creation
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client, err := CreateAdminClient("localhost:9333", "test-worker", dialOption)
+ if err != nil {
+ t.Fatalf("Failed to create admin client: %v", err)
+ }
+
+ if client == nil {
+ t.Fatal("Client should not be nil")
+ }
+}
diff --git a/weed/worker/client_tls_test.go b/weed/worker/client_tls_test.go
new file mode 100644
index 000000000..d95d5f4f5
--- /dev/null
+++ b/weed/worker/client_tls_test.go
@@ -0,0 +1,146 @@
+package worker
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+func TestGrpcClientTLSDetection(t *testing.T) {
+ // Test that the client can be created with a dial option
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("localhost:33646", "test-worker", dialOption)
+
+ // Test that the client has the correct dial option
+ if client.dialOption == nil {
+ t.Error("Client should have a dial option")
+ }
+
+ t.Logf("Client created successfully with dial option")
+}
+
+func TestCreateAdminClientGrpc(t *testing.T) {
+ // Test client creation - admin server port gets transformed to gRPC port
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client, err := CreateAdminClient("localhost:23646", "test-worker", dialOption)
+ if err != nil {
+ t.Fatalf("Failed to create admin client: %v", err)
+ }
+
+ if client == nil {
+ t.Fatal("Client should not be nil")
+ }
+
+ // Verify it's the correct type
+ grpcClient, ok := client.(*GrpcAdminClient)
+ if !ok {
+ t.Fatal("Client should be GrpcAdminClient type")
+ }
+
+ // The admin address should be transformed to the gRPC port (HTTP + 10000)
+ expectedAddress := "localhost:33646" // 23646 + 10000
+ if grpcClient.adminAddress != expectedAddress {
+ t.Errorf("Expected admin address %s, got %s", expectedAddress, grpcClient.adminAddress)
+ }
+
+ if grpcClient.workerID != "test-worker" {
+ t.Errorf("Expected worker ID test-worker, got %s", grpcClient.workerID)
+ }
+}
+
+func TestConnectionTimeouts(t *testing.T) {
+ // Test that connections have proper timeouts
+ // Use localhost with a port that's definitely closed
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("localhost:1", "test-worker", dialOption) // Port 1 is reserved and won't be open
+
+ // Test that the connection creation fails when actually trying to use it
+ start := time.Now()
+ err := client.Connect() // This should fail when trying to establish the stream
+ duration := time.Since(start)
+
+ if err == nil {
+ t.Error("Expected connection to closed port to fail")
+ } else {
+ t.Logf("Connection failed as expected: %v", err)
+ }
+
+ // Should fail quickly but not too quickly
+ if duration > 10*time.Second {
+ t.Errorf("Connection attempt took too long: %v", duration)
+ }
+}
+
+func TestConnectionWithDialOption(t *testing.T) {
+ // Test that the connection uses the provided dial option
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("localhost:1", "test-worker", dialOption) // Port 1 is reserved and won't be open
+
+ // Test the actual connection
+ err := client.Connect()
+ if err == nil {
+ t.Error("Expected connection to closed port to fail")
+ client.Disconnect() // Clean up if it somehow succeeded
+ } else {
+ t.Logf("Connection failed as expected: %v", err)
+ }
+
+ // The error should indicate a connection failure
+ if err != nil && err.Error() != "" {
+ t.Logf("Connection error message: %s", err.Error())
+ // The error should contain connection-related terms
+ if !strings.Contains(err.Error(), "connection") && !strings.Contains(err.Error(), "dial") {
+ t.Logf("Error message doesn't indicate connection issues: %s", err.Error())
+ }
+ }
+}
+
+func TestClientWithSecureDialOption(t *testing.T) {
+ // Test that the client correctly uses a secure dial option
+ // This would normally use LoadClientTLS, but for testing we'll use insecure
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("localhost:33646", "test-worker", dialOption)
+
+ if client.dialOption == nil {
+ t.Error("Client should have a dial option")
+ }
+
+ t.Logf("Client created successfully with dial option")
+}
+
+func TestConnectionWithRealAddress(t *testing.T) {
+ // Test connection behavior with a real address that doesn't support gRPC
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("www.google.com:80", "test-worker", dialOption) // HTTP port, not gRPC
+
+ err := client.Connect()
+ if err == nil {
+ t.Log("Connection succeeded unexpectedly")
+ client.Disconnect()
+ } else {
+ t.Logf("Connection failed as expected: %v", err)
+ }
+}
+
+func TestDialOptionUsage(t *testing.T) {
+ // Test that the provided dial option is used for connections
+ dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
+ client := NewGrpcAdminClient("localhost:1", "test-worker", dialOption) // Port 1 won't support gRPC at all
+
+ // Verify the dial option is stored
+ if client.dialOption == nil {
+ t.Error("Dial option should be stored in client")
+ }
+
+ // Test connection fails appropriately
+ err := client.Connect()
+ if err == nil {
+ t.Error("Connection should fail to non-gRPC port")
+ client.Disconnect()
+ } else {
+ t.Logf("Connection failed as expected: %v", err)
+ }
+}
diff --git a/weed/worker/registry.go b/weed/worker/registry.go
new file mode 100644
index 000000000..e227beb6a
--- /dev/null
+++ b/weed/worker/registry.go
@@ -0,0 +1,348 @@
+package worker
+
+import (
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Registry manages workers and their statistics
+type Registry struct {
+ workers map[string]*types.Worker
+ stats *types.RegistryStats
+ mutex sync.RWMutex
+}
+
+// NewRegistry creates a new worker registry
+func NewRegistry() *Registry {
+ return &Registry{
+ workers: make(map[string]*types.Worker),
+ stats: &types.RegistryStats{
+ TotalWorkers: 0,
+ ActiveWorkers: 0,
+ BusyWorkers: 0,
+ IdleWorkers: 0,
+ TotalTasks: 0,
+ CompletedTasks: 0,
+ FailedTasks: 0,
+ StartTime: time.Now(),
+ },
+ }
+}
+
+// RegisterWorker registers a new worker
+func (r *Registry) RegisterWorker(worker *types.Worker) error {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ if _, exists := r.workers[worker.ID]; exists {
+ return fmt.Errorf("worker %s already registered", worker.ID)
+ }
+
+ r.workers[worker.ID] = worker
+ r.updateStats()
+ return nil
+}
+
+// UnregisterWorker removes a worker from the registry
+func (r *Registry) UnregisterWorker(workerID string) error {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ if _, exists := r.workers[workerID]; !exists {
+ return fmt.Errorf("worker %s not found", workerID)
+ }
+
+ delete(r.workers, workerID)
+ r.updateStats()
+ return nil
+}
+
+// GetWorker returns a worker by ID
+func (r *Registry) GetWorker(workerID string) (*types.Worker, bool) {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ worker, exists := r.workers[workerID]
+ return worker, exists
+}
+
+// ListWorkers returns all registered workers
+func (r *Registry) ListWorkers() []*types.Worker {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ workers := make([]*types.Worker, 0, len(r.workers))
+ for _, worker := range r.workers {
+ workers = append(workers, worker)
+ }
+ return workers
+}
+
+// GetWorkersByCapability returns workers that support a specific capability
+func (r *Registry) GetWorkersByCapability(capability types.TaskType) []*types.Worker {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ var workers []*types.Worker
+ for _, worker := range r.workers {
+ for _, cap := range worker.Capabilities {
+ if cap == capability {
+ workers = append(workers, worker)
+ break
+ }
+ }
+ }
+ return workers
+}
+
+// GetAvailableWorkers returns workers that are available for new tasks
+func (r *Registry) GetAvailableWorkers() []*types.Worker {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ var workers []*types.Worker
+ for _, worker := range r.workers {
+ if worker.Status == "active" && worker.CurrentLoad < worker.MaxConcurrent {
+ workers = append(workers, worker)
+ }
+ }
+ return workers
+}
+
+// GetBestWorkerForTask returns the best worker for a specific task
+func (r *Registry) GetBestWorkerForTask(taskType types.TaskType) *types.Worker {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ var bestWorker *types.Worker
+ var bestScore float64
+
+ for _, worker := range r.workers {
+ // Check if worker supports this task type
+ supportsTask := false
+ for _, cap := range worker.Capabilities {
+ if cap == taskType {
+ supportsTask = true
+ break
+ }
+ }
+
+ if !supportsTask {
+ continue
+ }
+
+ // Check if worker is available
+ if worker.Status != "active" || worker.CurrentLoad >= worker.MaxConcurrent {
+ continue
+ }
+
+ // Calculate score based on current load and capacity
+ score := float64(worker.MaxConcurrent-worker.CurrentLoad) / float64(worker.MaxConcurrent)
+ if bestWorker == nil || score > bestScore {
+ bestWorker = worker
+ bestScore = score
+ }
+ }
+
+ return bestWorker
+}
+
+// UpdateWorkerHeartbeat updates the last heartbeat time for a worker
+func (r *Registry) UpdateWorkerHeartbeat(workerID string) error {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ worker, exists := r.workers[workerID]
+ if !exists {
+ return fmt.Errorf("worker %s not found", workerID)
+ }
+
+ worker.LastHeartbeat = time.Now()
+ return nil
+}
+
+// UpdateWorkerLoad updates the current load for a worker
+func (r *Registry) UpdateWorkerLoad(workerID string, load int) error {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ worker, exists := r.workers[workerID]
+ if !exists {
+ return fmt.Errorf("worker %s not found", workerID)
+ }
+
+ worker.CurrentLoad = load
+ if load >= worker.MaxConcurrent {
+ worker.Status = "busy"
+ } else {
+ worker.Status = "active"
+ }
+
+ r.updateStats()
+ return nil
+}
+
+// UpdateWorkerStatus updates the status of a worker
+func (r *Registry) UpdateWorkerStatus(workerID string, status string) error {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ worker, exists := r.workers[workerID]
+ if !exists {
+ return fmt.Errorf("worker %s not found", workerID)
+ }
+
+ worker.Status = status
+ r.updateStats()
+ return nil
+}
+
+// CleanupStaleWorkers removes workers that haven't sent heartbeats recently
+func (r *Registry) CleanupStaleWorkers(timeout time.Duration) int {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ var removedCount int
+ cutoff := time.Now().Add(-timeout)
+
+ for workerID, worker := range r.workers {
+ if worker.LastHeartbeat.Before(cutoff) {
+ delete(r.workers, workerID)
+ removedCount++
+ }
+ }
+
+ if removedCount > 0 {
+ r.updateStats()
+ }
+
+ return removedCount
+}
+
+// GetStats returns current registry statistics
+func (r *Registry) GetStats() *types.RegistryStats {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ // Create a copy of the stats to avoid race conditions
+ stats := *r.stats
+ return &stats
+}
+
+// updateStats updates the registry statistics (must be called with lock held)
+func (r *Registry) updateStats() {
+ r.stats.TotalWorkers = len(r.workers)
+ r.stats.ActiveWorkers = 0
+ r.stats.BusyWorkers = 0
+ r.stats.IdleWorkers = 0
+
+ for _, worker := range r.workers {
+ switch worker.Status {
+ case "active":
+ if worker.CurrentLoad > 0 {
+ r.stats.ActiveWorkers++
+ } else {
+ r.stats.IdleWorkers++
+ }
+ case "busy":
+ r.stats.BusyWorkers++
+ }
+ }
+
+ r.stats.Uptime = time.Since(r.stats.StartTime)
+ r.stats.LastUpdated = time.Now()
+}
+
+// GetTaskCapabilities returns all task capabilities available in the registry
+func (r *Registry) GetTaskCapabilities() []types.TaskType {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ capabilitySet := make(map[types.TaskType]bool)
+ for _, worker := range r.workers {
+ for _, cap := range worker.Capabilities {
+ capabilitySet[cap] = true
+ }
+ }
+
+ var capabilities []types.TaskType
+ for cap := range capabilitySet {
+ capabilities = append(capabilities, cap)
+ }
+
+ return capabilities
+}
+
+// GetWorkersByStatus returns workers filtered by status
+func (r *Registry) GetWorkersByStatus(status string) []*types.Worker {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ var workers []*types.Worker
+ for _, worker := range r.workers {
+ if worker.Status == status {
+ workers = append(workers, worker)
+ }
+ }
+ return workers
+}
+
+// GetWorkerCount returns the total number of registered workers
+func (r *Registry) GetWorkerCount() int {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+ return len(r.workers)
+}
+
+// GetWorkerIDs returns all worker IDs
+func (r *Registry) GetWorkerIDs() []string {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ ids := make([]string, 0, len(r.workers))
+ for id := range r.workers {
+ ids = append(ids, id)
+ }
+ return ids
+}
+
+// GetWorkerSummary returns a summary of all workers
+func (r *Registry) GetWorkerSummary() *types.WorkerSummary {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ summary := &types.WorkerSummary{
+ TotalWorkers: len(r.workers),
+ ByStatus: make(map[string]int),
+ ByCapability: make(map[types.TaskType]int),
+ TotalLoad: 0,
+ MaxCapacity: 0,
+ }
+
+ for _, worker := range r.workers {
+ summary.ByStatus[worker.Status]++
+ summary.TotalLoad += worker.CurrentLoad
+ summary.MaxCapacity += worker.MaxConcurrent
+
+ for _, cap := range worker.Capabilities {
+ summary.ByCapability[cap]++
+ }
+ }
+
+ return summary
+}
+
+// Default global registry instance
+var defaultRegistry *Registry
+var registryOnce sync.Once
+
+// GetDefaultRegistry returns the default global registry
+func GetDefaultRegistry() *Registry {
+ registryOnce.Do(func() {
+ defaultRegistry = NewRegistry()
+ })
+ return defaultRegistry
+}
diff --git a/weed/worker/tasks/balance/balance.go b/weed/worker/tasks/balance/balance.go
new file mode 100644
index 000000000..ea867d950
--- /dev/null
+++ b/weed/worker/tasks/balance/balance.go
@@ -0,0 +1,82 @@
+package balance
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Task implements balance operation to redistribute volumes across volume servers
+type Task struct {
+ *tasks.BaseTask
+ server string
+ volumeID uint32
+ collection string
+}
+
+// NewTask creates a new balance task instance
+func NewTask(server string, volumeID uint32, collection string) *Task {
+ task := &Task{
+ BaseTask: tasks.NewBaseTask(types.TaskTypeBalance),
+ server: server,
+ volumeID: volumeID,
+ collection: collection,
+ }
+ return task
+}
+
+// Execute executes the balance task
+func (t *Task) Execute(params types.TaskParams) error {
+ glog.Infof("Starting balance task for volume %d on server %s (collection: %s)", t.volumeID, t.server, t.collection)
+
+ // Simulate balance operation with progress updates
+ steps := []struct {
+ name string
+ duration time.Duration
+ progress float64
+ }{
+ {"Analyzing cluster state", 2 * time.Second, 15},
+ {"Identifying optimal placement", 3 * time.Second, 35},
+ {"Moving volume data", 6 * time.Second, 75},
+ {"Updating cluster metadata", 2 * time.Second, 95},
+ {"Verifying balance", 1 * time.Second, 100},
+ }
+
+ for _, step := range steps {
+ if t.IsCancelled() {
+ return fmt.Errorf("balance task cancelled")
+ }
+
+ glog.V(1).Infof("Balance task step: %s", step.name)
+ t.SetProgress(step.progress)
+
+ // Simulate work
+ time.Sleep(step.duration)
+ }
+
+ glog.Infof("Balance task completed for volume %d on server %s", t.volumeID, t.server)
+ return nil
+}
+
+// Validate validates the task parameters
+func (t *Task) Validate(params types.TaskParams) error {
+ if params.VolumeID == 0 {
+ return fmt.Errorf("volume_id is required")
+ }
+ if params.Server == "" {
+ return fmt.Errorf("server is required")
+ }
+ return nil
+}
+
+// EstimateTime estimates the time needed for the task
+func (t *Task) EstimateTime(params types.TaskParams) time.Duration {
+ // Base time for balance operation
+ baseTime := 35 * time.Second
+
+ // Could adjust based on volume size or cluster state
+ return baseTime
+}
diff --git a/weed/worker/tasks/balance/balance_detector.go b/weed/worker/tasks/balance/balance_detector.go
new file mode 100644
index 000000000..f082b7a77
--- /dev/null
+++ b/weed/worker/tasks/balance/balance_detector.go
@@ -0,0 +1,171 @@
+package balance
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// BalanceDetector implements TaskDetector for balance tasks
+type BalanceDetector struct {
+ enabled bool
+ threshold float64 // Imbalance threshold (0.1 = 10%)
+ minCheckInterval time.Duration
+ minVolumeCount int
+ lastCheck time.Time
+}
+
+// Compile-time interface assertions
+var (
+ _ types.TaskDetector = (*BalanceDetector)(nil)
+)
+
+// NewBalanceDetector creates a new balance detector
+func NewBalanceDetector() *BalanceDetector {
+ return &BalanceDetector{
+ enabled: true,
+ threshold: 0.1, // 10% imbalance threshold
+ minCheckInterval: 1 * time.Hour,
+ minVolumeCount: 10, // Don't balance small clusters
+ lastCheck: time.Time{},
+ }
+}
+
+// GetTaskType returns the task type
+func (d *BalanceDetector) GetTaskType() types.TaskType {
+ return types.TaskTypeBalance
+}
+
+// ScanForTasks checks if cluster balance is needed
+func (d *BalanceDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo) ([]*types.TaskDetectionResult, error) {
+ if !d.enabled {
+ return nil, nil
+ }
+
+ glog.V(2).Infof("Scanning for balance tasks...")
+
+ // Don't check too frequently
+ if time.Since(d.lastCheck) < d.minCheckInterval {
+ return nil, nil
+ }
+ d.lastCheck = time.Now()
+
+ // Skip if cluster is too small
+ if len(volumeMetrics) < d.minVolumeCount {
+ glog.V(2).Infof("Cluster too small for balance (%d volumes < %d minimum)", len(volumeMetrics), d.minVolumeCount)
+ return nil, nil
+ }
+
+ // Analyze volume distribution across servers
+ serverVolumeCounts := make(map[string]int)
+ for _, metric := range volumeMetrics {
+ serverVolumeCounts[metric.Server]++
+ }
+
+ if len(serverVolumeCounts) < 2 {
+ glog.V(2).Infof("Not enough servers for balance (%d servers)", len(serverVolumeCounts))
+ return nil, nil
+ }
+
+ // Calculate balance metrics
+ totalVolumes := len(volumeMetrics)
+ avgVolumesPerServer := float64(totalVolumes) / float64(len(serverVolumeCounts))
+
+ maxVolumes := 0
+ minVolumes := totalVolumes
+ maxServer := ""
+ minServer := ""
+
+ for server, count := range serverVolumeCounts {
+ if count > maxVolumes {
+ maxVolumes = count
+ maxServer = server
+ }
+ if count < minVolumes {
+ minVolumes = count
+ minServer = server
+ }
+ }
+
+ // Check if imbalance exceeds threshold
+ imbalanceRatio := float64(maxVolumes-minVolumes) / avgVolumesPerServer
+ if imbalanceRatio <= d.threshold {
+ glog.V(2).Infof("Cluster is balanced (imbalance ratio: %.2f <= %.2f)", imbalanceRatio, d.threshold)
+ return nil, nil
+ }
+
+ // Create balance task
+ reason := fmt.Sprintf("Cluster imbalance detected: %.1f%% (max: %d on %s, min: %d on %s, avg: %.1f)",
+ imbalanceRatio*100, maxVolumes, maxServer, minVolumes, minServer, avgVolumesPerServer)
+
+ task := &types.TaskDetectionResult{
+ TaskType: types.TaskTypeBalance,
+ Priority: types.TaskPriorityNormal,
+ Reason: reason,
+ ScheduleAt: time.Now(),
+ Parameters: map[string]interface{}{
+ "imbalance_ratio": imbalanceRatio,
+ "threshold": d.threshold,
+ "max_volumes": maxVolumes,
+ "min_volumes": minVolumes,
+ "avg_volumes_per_server": avgVolumesPerServer,
+ "max_server": maxServer,
+ "min_server": minServer,
+ "total_servers": len(serverVolumeCounts),
+ },
+ }
+
+ glog.V(1).Infof("🔄 Found balance task: %s", reason)
+ return []*types.TaskDetectionResult{task}, nil
+}
+
+// ScanInterval returns how often to scan
+func (d *BalanceDetector) ScanInterval() time.Duration {
+ return d.minCheckInterval
+}
+
+// IsEnabled returns whether the detector is enabled
+func (d *BalanceDetector) IsEnabled() bool {
+ return d.enabled
+}
+
+// SetEnabled sets whether the detector is enabled
+func (d *BalanceDetector) SetEnabled(enabled bool) {
+ d.enabled = enabled
+ glog.V(1).Infof("🔄 Balance detector enabled: %v", enabled)
+}
+
+// SetThreshold sets the imbalance threshold
+func (d *BalanceDetector) SetThreshold(threshold float64) {
+ d.threshold = threshold
+ glog.V(1).Infof("🔄 Balance threshold set to: %.1f%%", threshold*100)
+}
+
+// SetMinCheckInterval sets the minimum time between balance checks
+func (d *BalanceDetector) SetMinCheckInterval(interval time.Duration) {
+ d.minCheckInterval = interval
+ glog.V(1).Infof("🔄 Balance check interval set to: %v", interval)
+}
+
+// SetMinVolumeCount sets the minimum volume count for balance operations
+func (d *BalanceDetector) SetMinVolumeCount(count int) {
+ d.minVolumeCount = count
+ glog.V(1).Infof("🔄 Balance minimum volume count set to: %d", count)
+}
+
+// GetThreshold returns the current imbalance threshold
+func (d *BalanceDetector) GetThreshold() float64 {
+ return d.threshold
+}
+
+// GetMinCheckInterval returns the minimum check interval
+func (d *BalanceDetector) GetMinCheckInterval() time.Duration {
+ return d.minCheckInterval
+}
+
+// GetMinVolumeCount returns the minimum volume count
+func (d *BalanceDetector) GetMinVolumeCount() int {
+ return d.minVolumeCount
+}
diff --git a/weed/worker/tasks/balance/balance_register.go b/weed/worker/tasks/balance/balance_register.go
new file mode 100644
index 000000000..7c2d5a520
--- /dev/null
+++ b/weed/worker/tasks/balance/balance_register.go
@@ -0,0 +1,81 @@
+package balance
+
+import (
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Factory creates balance task instances
+type Factory struct {
+ *tasks.BaseTaskFactory
+}
+
+// NewFactory creates a new balance task factory
+func NewFactory() *Factory {
+ return &Factory{
+ BaseTaskFactory: tasks.NewBaseTaskFactory(
+ types.TaskTypeBalance,
+ []string{"balance", "storage", "optimization"},
+ "Balance data across volume servers for optimal performance",
+ ),
+ }
+}
+
+// Create creates a new balance task instance
+func (f *Factory) Create(params types.TaskParams) (types.TaskInterface, error) {
+ // Validate parameters
+ if params.VolumeID == 0 {
+ return nil, fmt.Errorf("volume_id is required")
+ }
+ if params.Server == "" {
+ return nil, fmt.Errorf("server is required")
+ }
+
+ task := NewTask(params.Server, params.VolumeID, params.Collection)
+ task.SetEstimatedDuration(task.EstimateTime(params))
+
+ return task, nil
+}
+
+// Shared detector and scheduler instances
+var (
+ sharedDetector *BalanceDetector
+ sharedScheduler *BalanceScheduler
+)
+
+// getSharedInstances returns the shared detector and scheduler instances
+func getSharedInstances() (*BalanceDetector, *BalanceScheduler) {
+ if sharedDetector == nil {
+ sharedDetector = NewBalanceDetector()
+ }
+ if sharedScheduler == nil {
+ sharedScheduler = NewBalanceScheduler()
+ }
+ return sharedDetector, sharedScheduler
+}
+
+// GetSharedInstances returns the shared detector and scheduler instances (public access)
+func GetSharedInstances() (*BalanceDetector, *BalanceScheduler) {
+ return getSharedInstances()
+}
+
+// Auto-register this task when the package is imported
+func init() {
+ factory := NewFactory()
+ tasks.AutoRegister(types.TaskTypeBalance, factory)
+
+ // Get shared instances for all registrations
+ detector, scheduler := getSharedInstances()
+
+ // Register with types registry
+ tasks.AutoRegisterTypes(func(registry *types.TaskRegistry) {
+ registry.RegisterTask(detector, scheduler)
+ })
+
+ // Register with UI registry using the same instances
+ tasks.AutoRegisterUI(func(uiRegistry *types.UIRegistry) {
+ RegisterUI(uiRegistry, detector, scheduler)
+ })
+}
diff --git a/weed/worker/tasks/balance/balance_scheduler.go b/weed/worker/tasks/balance/balance_scheduler.go
new file mode 100644
index 000000000..a8fefe465
--- /dev/null
+++ b/weed/worker/tasks/balance/balance_scheduler.go
@@ -0,0 +1,197 @@
+package balance
+
+import (
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// BalanceScheduler implements TaskScheduler for balance tasks
+type BalanceScheduler struct {
+ enabled bool
+ maxConcurrent int
+ minInterval time.Duration
+ lastScheduled map[string]time.Time // track when we last scheduled a balance for each task type
+ minServerCount int
+ moveDuringOffHours bool
+ offHoursStart string
+ offHoursEnd string
+}
+
+// Compile-time interface assertions
+var (
+ _ types.TaskScheduler = (*BalanceScheduler)(nil)
+)
+
+// NewBalanceScheduler creates a new balance scheduler
+func NewBalanceScheduler() *BalanceScheduler {
+ return &BalanceScheduler{
+ enabled: true,
+ maxConcurrent: 1, // Only run one balance at a time
+ minInterval: 6 * time.Hour,
+ lastScheduled: make(map[string]time.Time),
+ minServerCount: 3,
+ moveDuringOffHours: true,
+ offHoursStart: "23:00",
+ offHoursEnd: "06:00",
+ }
+}
+
+// GetTaskType returns the task type
+func (s *BalanceScheduler) GetTaskType() types.TaskType {
+ return types.TaskTypeBalance
+}
+
+// CanScheduleNow determines if a balance task can be scheduled
+func (s *BalanceScheduler) CanScheduleNow(task *types.Task, runningTasks []*types.Task, availableWorkers []*types.Worker) bool {
+ if !s.enabled {
+ return false
+ }
+
+ // Count running balance tasks
+ runningBalanceCount := 0
+ for _, runningTask := range runningTasks {
+ if runningTask.Type == types.TaskTypeBalance {
+ runningBalanceCount++
+ }
+ }
+
+ // Check concurrency limit
+ if runningBalanceCount >= s.maxConcurrent {
+ glog.V(3).Infof("⏸️ Balance task blocked: too many running (%d >= %d)", runningBalanceCount, s.maxConcurrent)
+ return false
+ }
+
+ // Check minimum interval between balance operations
+ if lastTime, exists := s.lastScheduled["balance"]; exists {
+ if time.Since(lastTime) < s.minInterval {
+ timeLeft := s.minInterval - time.Since(lastTime)
+ glog.V(3).Infof("⏸️ Balance task blocked: too soon (wait %v)", timeLeft)
+ return false
+ }
+ }
+
+ // Check if we have available workers
+ availableWorkerCount := 0
+ for _, worker := range availableWorkers {
+ for _, capability := range worker.Capabilities {
+ if capability == types.TaskTypeBalance {
+ availableWorkerCount++
+ break
+ }
+ }
+ }
+
+ if availableWorkerCount == 0 {
+ glog.V(3).Infof("⏸️ Balance task blocked: no available workers")
+ return false
+ }
+
+ // All checks passed - can schedule
+ s.lastScheduled["balance"] = time.Now()
+ glog.V(2).Infof("✅ Balance task can be scheduled (running: %d/%d, workers: %d)",
+ runningBalanceCount, s.maxConcurrent, availableWorkerCount)
+ return true
+}
+
+// GetPriority returns the priority for balance tasks
+func (s *BalanceScheduler) GetPriority(task *types.Task) types.TaskPriority {
+ // Balance is typically normal priority - not urgent but important for optimization
+ return types.TaskPriorityNormal
+}
+
+// GetMaxConcurrent returns the maximum concurrent balance tasks
+func (s *BalanceScheduler) GetMaxConcurrent() int {
+ return s.maxConcurrent
+}
+
+// GetDefaultRepeatInterval returns the default interval to wait before repeating balance tasks
+func (s *BalanceScheduler) GetDefaultRepeatInterval() time.Duration {
+ return s.minInterval
+}
+
+// IsEnabled returns whether the scheduler is enabled
+func (s *BalanceScheduler) IsEnabled() bool {
+ return s.enabled
+}
+
+// SetEnabled sets whether the scheduler is enabled
+func (s *BalanceScheduler) SetEnabled(enabled bool) {
+ s.enabled = enabled
+ glog.V(1).Infof("🔄 Balance scheduler enabled: %v", enabled)
+}
+
+// SetMaxConcurrent sets the maximum concurrent balance tasks
+func (s *BalanceScheduler) SetMaxConcurrent(max int) {
+ s.maxConcurrent = max
+ glog.V(1).Infof("🔄 Balance max concurrent set to: %d", max)
+}
+
+// SetMinInterval sets the minimum interval between balance operations
+func (s *BalanceScheduler) SetMinInterval(interval time.Duration) {
+ s.minInterval = interval
+ glog.V(1).Infof("🔄 Balance minimum interval set to: %v", interval)
+}
+
+// GetLastScheduled returns when we last scheduled this task type
+func (s *BalanceScheduler) GetLastScheduled(taskKey string) time.Time {
+ if lastTime, exists := s.lastScheduled[taskKey]; exists {
+ return lastTime
+ }
+ return time.Time{}
+}
+
+// SetLastScheduled updates when we last scheduled this task type
+func (s *BalanceScheduler) SetLastScheduled(taskKey string, when time.Time) {
+ s.lastScheduled[taskKey] = when
+}
+
+// GetMinServerCount returns the minimum server count
+func (s *BalanceScheduler) GetMinServerCount() int {
+ return s.minServerCount
+}
+
+// SetMinServerCount sets the minimum server count
+func (s *BalanceScheduler) SetMinServerCount(count int) {
+ s.minServerCount = count
+ glog.V(1).Infof("🔄 Balance minimum server count set to: %d", count)
+}
+
+// GetMoveDuringOffHours returns whether to move only during off-hours
+func (s *BalanceScheduler) GetMoveDuringOffHours() bool {
+ return s.moveDuringOffHours
+}
+
+// SetMoveDuringOffHours sets whether to move only during off-hours
+func (s *BalanceScheduler) SetMoveDuringOffHours(enabled bool) {
+ s.moveDuringOffHours = enabled
+ glog.V(1).Infof("🔄 Balance move during off-hours: %v", enabled)
+}
+
+// GetOffHoursStart returns the off-hours start time
+func (s *BalanceScheduler) GetOffHoursStart() string {
+ return s.offHoursStart
+}
+
+// SetOffHoursStart sets the off-hours start time
+func (s *BalanceScheduler) SetOffHoursStart(start string) {
+ s.offHoursStart = start
+ glog.V(1).Infof("🔄 Balance off-hours start time set to: %s", start)
+}
+
+// GetOffHoursEnd returns the off-hours end time
+func (s *BalanceScheduler) GetOffHoursEnd() string {
+ return s.offHoursEnd
+}
+
+// SetOffHoursEnd sets the off-hours end time
+func (s *BalanceScheduler) SetOffHoursEnd(end string) {
+ s.offHoursEnd = end
+ glog.V(1).Infof("🔄 Balance off-hours end time set to: %s", end)
+}
+
+// GetMinInterval returns the minimum interval
+func (s *BalanceScheduler) GetMinInterval() time.Duration {
+ return s.minInterval
+}
diff --git a/weed/worker/tasks/balance/ui.go b/weed/worker/tasks/balance/ui.go
new file mode 100644
index 000000000..88f7bb4a9
--- /dev/null
+++ b/weed/worker/tasks/balance/ui.go
@@ -0,0 +1,361 @@
+package balance
+
+import (
+ "fmt"
+ "html/template"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// UIProvider provides the UI for balance task configuration
+type UIProvider struct {
+ detector *BalanceDetector
+ scheduler *BalanceScheduler
+}
+
+// NewUIProvider creates a new balance UI provider
+func NewUIProvider(detector *BalanceDetector, scheduler *BalanceScheduler) *UIProvider {
+ return &UIProvider{
+ detector: detector,
+ scheduler: scheduler,
+ }
+}
+
+// GetTaskType returns the task type
+func (ui *UIProvider) GetTaskType() types.TaskType {
+ return types.TaskTypeBalance
+}
+
+// GetDisplayName returns the human-readable name
+func (ui *UIProvider) GetDisplayName() string {
+ return "Volume Balance"
+}
+
+// GetDescription returns a description of what this task does
+func (ui *UIProvider) GetDescription() string {
+ return "Redistributes volumes across volume servers to optimize storage utilization and performance"
+}
+
+// GetIcon returns the icon CSS class for this task type
+func (ui *UIProvider) GetIcon() string {
+ return "fas fa-balance-scale text-secondary"
+}
+
+// BalanceConfig represents the balance configuration
+type BalanceConfig struct {
+ Enabled bool `json:"enabled"`
+ ImbalanceThreshold float64 `json:"imbalance_threshold"`
+ ScanIntervalSeconds int `json:"scan_interval_seconds"`
+ MaxConcurrent int `json:"max_concurrent"`
+ MinServerCount int `json:"min_server_count"`
+ MoveDuringOffHours bool `json:"move_during_off_hours"`
+ OffHoursStart string `json:"off_hours_start"`
+ OffHoursEnd string `json:"off_hours_end"`
+ MinIntervalSeconds int `json:"min_interval_seconds"`
+}
+
+// Helper functions for duration conversion
+func secondsToDuration(seconds int) time.Duration {
+ return time.Duration(seconds) * time.Second
+}
+
+func durationToSeconds(d time.Duration) int {
+ return int(d.Seconds())
+}
+
+// formatDurationForUser formats seconds as a user-friendly duration string
+func formatDurationForUser(seconds int) string {
+ d := secondsToDuration(seconds)
+ if d < time.Minute {
+ return fmt.Sprintf("%ds", seconds)
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%.0fm", d.Minutes())
+ }
+ if d < 24*time.Hour {
+ return fmt.Sprintf("%.1fh", d.Hours())
+ }
+ return fmt.Sprintf("%.1fd", d.Hours()/24)
+}
+
+// RenderConfigForm renders the configuration form HTML
+func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML, error) {
+ config := ui.getCurrentBalanceConfig()
+
+ // Build form using the FormBuilder helper
+ form := types.NewFormBuilder()
+
+ // Detection Settings
+ form.AddCheckboxField(
+ "enabled",
+ "Enable Balance Tasks",
+ "Whether balance tasks should be automatically created",
+ config.Enabled,
+ )
+
+ form.AddNumberField(
+ "imbalance_threshold",
+ "Imbalance Threshold (%)",
+ "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)",
+ config.ImbalanceThreshold,
+ true,
+ )
+
+ form.AddDurationField("scan_interval", "Scan Interval", "How often to scan for imbalanced volumes", secondsToDuration(config.ScanIntervalSeconds), true)
+
+ // Scheduling Settings
+ form.AddNumberField(
+ "max_concurrent",
+ "Max Concurrent Tasks",
+ "Maximum number of balance tasks that can run simultaneously",
+ float64(config.MaxConcurrent),
+ true,
+ )
+
+ form.AddNumberField(
+ "min_server_count",
+ "Minimum Server Count",
+ "Only balance when at least this many servers are available",
+ float64(config.MinServerCount),
+ true,
+ )
+
+ // Timing Settings
+ form.AddCheckboxField(
+ "move_during_off_hours",
+ "Restrict to Off-Hours",
+ "Only perform balance operations during off-peak hours",
+ config.MoveDuringOffHours,
+ )
+
+ form.AddTextField(
+ "off_hours_start",
+ "Off-Hours Start Time",
+ "Start time for off-hours window (e.g., 23:00)",
+ config.OffHoursStart,
+ false,
+ )
+
+ form.AddTextField(
+ "off_hours_end",
+ "Off-Hours End Time",
+ "End time for off-hours window (e.g., 06:00)",
+ config.OffHoursEnd,
+ false,
+ )
+
+ // Timing constraints
+ form.AddDurationField("min_interval", "Min Interval", "Minimum time between balance operations", secondsToDuration(config.MinIntervalSeconds), true)
+
+ // Generate organized form sections using Bootstrap components
+ html := `
+<div class="row">
+ <div class="col-12">
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-balance-scale me-2"></i>
+ Balance Configuration
+ </h5>
+ </div>
+ <div class="card-body">
+` + string(form.Build()) + `
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-12">
+ <div class="card mb-3">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-exclamation-triangle me-2"></i>
+ Performance Considerations
+ </h5>
+ </div>
+ <div class="card-body">
+ <div class="alert alert-warning" role="alert">
+ <h6 class="alert-heading">Important Considerations:</h6>
+ <p class="mb-2"><strong>Performance:</strong> Volume balancing involves data movement and can impact cluster performance.</p>
+ <p class="mb-2"><strong>Recommendation:</strong> Enable off-hours restriction to minimize impact on production workloads.</p>
+ <p class="mb-0"><strong>Safety:</strong> Requires at least ` + fmt.Sprintf("%d", config.MinServerCount) + ` servers to ensure data safety during moves.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>`
+
+ return template.HTML(html), nil
+}
+
+// ParseConfigForm parses form data into configuration
+func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
+ config := &BalanceConfig{}
+
+ // Parse enabled
+ config.Enabled = len(formData["enabled"]) > 0
+
+ // Parse imbalance threshold
+ if values, ok := formData["imbalance_threshold"]; ok && len(values) > 0 {
+ threshold, err := strconv.ParseFloat(values[0], 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid imbalance threshold: %v", err)
+ }
+ if threshold < 0 || threshold > 1 {
+ return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0")
+ }
+ config.ImbalanceThreshold = threshold
+ }
+
+ // Parse scan interval
+ if values, ok := formData["scan_interval"]; ok && len(values) > 0 {
+ duration, err := time.ParseDuration(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid scan interval: %v", err)
+ }
+ config.ScanIntervalSeconds = int(duration.Seconds())
+ }
+
+ // Parse max concurrent
+ if values, ok := formData["max_concurrent"]; ok && len(values) > 0 {
+ maxConcurrent, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid max concurrent: %v", err)
+ }
+ if maxConcurrent < 1 {
+ return nil, fmt.Errorf("max concurrent must be at least 1")
+ }
+ config.MaxConcurrent = maxConcurrent
+ }
+
+ // Parse min server count
+ if values, ok := formData["min_server_count"]; ok && len(values) > 0 {
+ minServerCount, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid min server count: %v", err)
+ }
+ if minServerCount < 2 {
+ return nil, fmt.Errorf("min server count must be at least 2")
+ }
+ config.MinServerCount = minServerCount
+ }
+
+ // Parse off-hours settings
+ config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0
+
+ if values, ok := formData["off_hours_start"]; ok && len(values) > 0 {
+ config.OffHoursStart = values[0]
+ }
+
+ if values, ok := formData["off_hours_end"]; ok && len(values) > 0 {
+ config.OffHoursEnd = values[0]
+ }
+
+ // Parse min interval
+ if values, ok := formData["min_interval"]; ok && len(values) > 0 {
+ duration, err := time.ParseDuration(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid min interval: %v", err)
+ }
+ config.MinIntervalSeconds = int(duration.Seconds())
+ }
+
+ return config, nil
+}
+
+// GetCurrentConfig returns the current configuration
+func (ui *UIProvider) GetCurrentConfig() interface{} {
+ return ui.getCurrentBalanceConfig()
+}
+
+// ApplyConfig applies the new configuration
+func (ui *UIProvider) ApplyConfig(config interface{}) error {
+ balanceConfig, ok := config.(*BalanceConfig)
+ if !ok {
+ return fmt.Errorf("invalid config type, expected *BalanceConfig")
+ }
+
+ // Apply to detector
+ if ui.detector != nil {
+ ui.detector.SetEnabled(balanceConfig.Enabled)
+ ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold)
+ ui.detector.SetMinCheckInterval(secondsToDuration(balanceConfig.ScanIntervalSeconds))
+ }
+
+ // Apply to scheduler
+ if ui.scheduler != nil {
+ ui.scheduler.SetEnabled(balanceConfig.Enabled)
+ ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent)
+ ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount)
+ ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours)
+ ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart)
+ ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd)
+ }
+
+ glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v",
+ balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent,
+ balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours)
+
+ return nil
+}
+
+// getCurrentBalanceConfig gets the current configuration from detector and scheduler
+func (ui *UIProvider) getCurrentBalanceConfig() *BalanceConfig {
+ config := &BalanceConfig{
+ // Default values (fallback if detectors/schedulers are nil)
+ Enabled: true,
+ ImbalanceThreshold: 0.1, // 10% imbalance
+ ScanIntervalSeconds: durationToSeconds(4 * time.Hour),
+ MaxConcurrent: 1,
+ MinServerCount: 3,
+ MoveDuringOffHours: true,
+ OffHoursStart: "23:00",
+ OffHoursEnd: "06:00",
+ MinIntervalSeconds: durationToSeconds(1 * time.Hour),
+ }
+
+ // Get current values from detector
+ if ui.detector != nil {
+ config.Enabled = ui.detector.IsEnabled()
+ config.ImbalanceThreshold = ui.detector.GetThreshold()
+ config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
+ }
+
+ // Get current values from scheduler
+ if ui.scheduler != nil {
+ config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
+ config.MinServerCount = ui.scheduler.GetMinServerCount()
+ config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours()
+ config.OffHoursStart = ui.scheduler.GetOffHoursStart()
+ config.OffHoursEnd = ui.scheduler.GetOffHoursEnd()
+ }
+
+ return config
+}
+
+// RegisterUI registers the balance UI provider with the UI registry
+func RegisterUI(uiRegistry *types.UIRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) {
+ uiProvider := NewUIProvider(detector, scheduler)
+ uiRegistry.RegisterUI(uiProvider)
+
+ glog.V(1).Infof("✅ Registered balance task UI provider")
+}
+
+// DefaultBalanceConfig returns default balance configuration
+func DefaultBalanceConfig() *BalanceConfig {
+ return &BalanceConfig{
+ Enabled: false,
+ ImbalanceThreshold: 0.3,
+ ScanIntervalSeconds: durationToSeconds(4 * time.Hour),
+ MaxConcurrent: 1,
+ MinServerCount: 3,
+ MoveDuringOffHours: false,
+ OffHoursStart: "22:00",
+ OffHoursEnd: "06:00",
+ MinIntervalSeconds: durationToSeconds(1 * time.Hour),
+ }
+}
diff --git a/weed/worker/tasks/balance/ui_templ.go b/weed/worker/tasks/balance/ui_templ.go
new file mode 100644
index 000000000..54998af4c
--- /dev/null
+++ b/weed/worker/tasks/balance/ui_templ.go
@@ -0,0 +1,369 @@
+package balance
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Helper function to format seconds as duration string
+func formatDurationFromSeconds(seconds int) string {
+ d := time.Duration(seconds) * time.Second
+ return d.String()
+}
+
+// Helper functions to convert between seconds and value+unit format
+func secondsToValueAndUnit(seconds int) (float64, string) {
+ if seconds == 0 {
+ return 0, "minutes"
+ }
+
+ // Try days first
+ if seconds%(24*3600) == 0 && seconds >= 24*3600 {
+ return float64(seconds / (24 * 3600)), "days"
+ }
+
+ // Try hours
+ if seconds%3600 == 0 && seconds >= 3600 {
+ return float64(seconds / 3600), "hours"
+ }
+
+ // Default to minutes
+ return float64(seconds / 60), "minutes"
+}
+
+func valueAndUnitToSeconds(value float64, unit string) int {
+ switch unit {
+ case "days":
+ return int(value * 24 * 3600)
+ case "hours":
+ return int(value * 3600)
+ case "minutes":
+ return int(value * 60)
+ default:
+ return int(value * 60) // Default to minutes
+ }
+}
+
+// UITemplProvider provides the templ-based UI for balance task configuration
+type UITemplProvider struct {
+ detector *BalanceDetector
+ scheduler *BalanceScheduler
+}
+
+// NewUITemplProvider creates a new balance templ UI provider
+func NewUITemplProvider(detector *BalanceDetector, scheduler *BalanceScheduler) *UITemplProvider {
+ return &UITemplProvider{
+ detector: detector,
+ scheduler: scheduler,
+ }
+}
+
+// GetTaskType returns the task type
+func (ui *UITemplProvider) GetTaskType() types.TaskType {
+ return types.TaskTypeBalance
+}
+
+// GetDisplayName returns the human-readable name
+func (ui *UITemplProvider) GetDisplayName() string {
+ return "Volume Balance"
+}
+
+// GetDescription returns a description of what this task does
+func (ui *UITemplProvider) GetDescription() string {
+ return "Redistributes volumes across volume servers to optimize storage utilization and performance"
+}
+
+// GetIcon returns the icon CSS class for this task type
+func (ui *UITemplProvider) GetIcon() string {
+ return "fas fa-balance-scale text-secondary"
+}
+
+// RenderConfigSections renders the configuration as templ section data
+func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
+ config := ui.getCurrentBalanceConfig()
+
+ // Detection settings section
+ detectionSection := components.ConfigSectionData{
+ Title: "Detection Settings",
+ Icon: "fas fa-search",
+ Description: "Configure when balance tasks should be triggered",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "enabled",
+ Label: "Enable Balance Tasks",
+ Description: "Whether balance tasks should be automatically created",
+ },
+ Checked: config.Enabled,
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "imbalance_threshold",
+ Label: "Imbalance Threshold",
+ Description: "Trigger balance when storage imbalance exceeds this percentage (0.0-1.0)",
+ Required: true,
+ },
+ Value: config.ImbalanceThreshold,
+ Step: "0.01",
+ Min: floatPtr(0.0),
+ Max: floatPtr(1.0),
+ },
+ components.DurationInputFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "scan_interval",
+ Label: "Scan Interval",
+ Description: "How often to scan for imbalanced volumes",
+ Required: true,
+ },
+ Seconds: config.ScanIntervalSeconds,
+ },
+ },
+ }
+
+ // Scheduling settings section
+ schedulingSection := components.ConfigSectionData{
+ Title: "Scheduling Settings",
+ Icon: "fas fa-clock",
+ Description: "Configure task scheduling and concurrency",
+ Fields: []interface{}{
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "max_concurrent",
+ Label: "Max Concurrent Tasks",
+ Description: "Maximum number of balance tasks that can run simultaneously",
+ Required: true,
+ },
+ Value: float64(config.MaxConcurrent),
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "min_server_count",
+ Label: "Minimum Server Count",
+ Description: "Only balance when at least this many servers are available",
+ Required: true,
+ },
+ Value: float64(config.MinServerCount),
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ },
+ }
+
+ // Timing constraints section
+ timingSection := components.ConfigSectionData{
+ Title: "Timing Constraints",
+ Icon: "fas fa-calendar-clock",
+ Description: "Configure when balance operations are allowed",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "move_during_off_hours",
+ Label: "Restrict to Off-Hours",
+ Description: "Only perform balance operations during off-peak hours",
+ },
+ Checked: config.MoveDuringOffHours,
+ },
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "off_hours_start",
+ Label: "Off-Hours Start Time",
+ Description: "Start time for off-hours window (e.g., 23:00)",
+ },
+ Value: config.OffHoursStart,
+ },
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "off_hours_end",
+ Label: "Off-Hours End Time",
+ Description: "End time for off-hours window (e.g., 06:00)",
+ },
+ Value: config.OffHoursEnd,
+ },
+ },
+ }
+
+ // Performance impact info section
+ performanceSection := components.ConfigSectionData{
+ Title: "Performance Considerations",
+ Icon: "fas fa-exclamation-triangle",
+ Description: "Important information about balance operations",
+ Fields: []interface{}{
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "performance_info",
+ Label: "Performance Impact",
+ Description: "Volume balancing involves data movement and can impact cluster performance",
+ },
+ Value: "Enable off-hours restriction to minimize impact on production workloads",
+ },
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "safety_info",
+ Label: "Safety Requirements",
+ Description: fmt.Sprintf("Requires at least %d servers to ensure data safety during moves", config.MinServerCount),
+ },
+ Value: "Maintains data safety during volume moves between servers",
+ },
+ },
+ }
+
+ return []components.ConfigSectionData{detectionSection, schedulingSection, timingSection, performanceSection}, nil
+}
+
+// ParseConfigForm parses form data into configuration
+func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
+ config := &BalanceConfig{}
+
+ // Parse enabled checkbox
+ config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
+
+ // Parse imbalance threshold
+ if thresholdStr := formData["imbalance_threshold"]; len(thresholdStr) > 0 {
+ if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid imbalance threshold: %v", err)
+ } else if threshold < 0 || threshold > 1 {
+ return nil, fmt.Errorf("imbalance threshold must be between 0.0 and 1.0")
+ } else {
+ config.ImbalanceThreshold = threshold
+ }
+ }
+
+ // Parse scan interval
+ if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
+ if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid scan interval value: %v", err)
+ } else {
+ unit := "minutes" // default
+ if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
+ unit = unitStr[0]
+ }
+ config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
+ }
+ }
+
+ // Parse max concurrent
+ if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
+ if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid max concurrent: %v", err)
+ } else if concurrent < 1 {
+ return nil, fmt.Errorf("max concurrent must be at least 1")
+ } else {
+ config.MaxConcurrent = concurrent
+ }
+ }
+
+ // Parse min server count
+ if serverCountStr := formData["min_server_count"]; len(serverCountStr) > 0 {
+ if serverCount, err := strconv.Atoi(serverCountStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid min server count: %v", err)
+ } else if serverCount < 1 {
+ return nil, fmt.Errorf("min server count must be at least 1")
+ } else {
+ config.MinServerCount = serverCount
+ }
+ }
+
+ // Parse move during off hours
+ config.MoveDuringOffHours = len(formData["move_during_off_hours"]) > 0 && formData["move_during_off_hours"][0] == "on"
+
+ // Parse off hours start time
+ if startStr := formData["off_hours_start"]; len(startStr) > 0 {
+ config.OffHoursStart = startStr[0]
+ }
+
+ // Parse off hours end time
+ if endStr := formData["off_hours_end"]; len(endStr) > 0 {
+ config.OffHoursEnd = endStr[0]
+ }
+
+ return config, nil
+}
+
+// GetCurrentConfig returns the current configuration
+func (ui *UITemplProvider) GetCurrentConfig() interface{} {
+ return ui.getCurrentBalanceConfig()
+}
+
+// ApplyConfig applies the new configuration
+func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
+ balanceConfig, ok := config.(*BalanceConfig)
+ if !ok {
+ return fmt.Errorf("invalid config type, expected *BalanceConfig")
+ }
+
+ // Apply to detector
+ if ui.detector != nil {
+ ui.detector.SetEnabled(balanceConfig.Enabled)
+ ui.detector.SetThreshold(balanceConfig.ImbalanceThreshold)
+ ui.detector.SetMinCheckInterval(time.Duration(balanceConfig.ScanIntervalSeconds) * time.Second)
+ }
+
+ // Apply to scheduler
+ if ui.scheduler != nil {
+ ui.scheduler.SetEnabled(balanceConfig.Enabled)
+ ui.scheduler.SetMaxConcurrent(balanceConfig.MaxConcurrent)
+ ui.scheduler.SetMinServerCount(balanceConfig.MinServerCount)
+ ui.scheduler.SetMoveDuringOffHours(balanceConfig.MoveDuringOffHours)
+ ui.scheduler.SetOffHoursStart(balanceConfig.OffHoursStart)
+ ui.scheduler.SetOffHoursEnd(balanceConfig.OffHoursEnd)
+ }
+
+ glog.V(1).Infof("Applied balance configuration: enabled=%v, threshold=%.1f%%, max_concurrent=%d, min_servers=%d, off_hours=%v",
+ balanceConfig.Enabled, balanceConfig.ImbalanceThreshold*100, balanceConfig.MaxConcurrent,
+ balanceConfig.MinServerCount, balanceConfig.MoveDuringOffHours)
+
+ return nil
+}
+
+// getCurrentBalanceConfig gets the current configuration from detector and scheduler
+func (ui *UITemplProvider) getCurrentBalanceConfig() *BalanceConfig {
+ config := &BalanceConfig{
+ // Default values (fallback if detectors/schedulers are nil)
+ Enabled: true,
+ ImbalanceThreshold: 0.1, // 10% imbalance
+ ScanIntervalSeconds: int((4 * time.Hour).Seconds()),
+ MaxConcurrent: 1,
+ MinServerCount: 3,
+ MoveDuringOffHours: true,
+ OffHoursStart: "23:00",
+ OffHoursEnd: "06:00",
+ }
+
+ // Get current values from detector
+ if ui.detector != nil {
+ config.Enabled = ui.detector.IsEnabled()
+ config.ImbalanceThreshold = ui.detector.GetThreshold()
+ config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
+ }
+
+ // Get current values from scheduler
+ if ui.scheduler != nil {
+ config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
+ config.MinServerCount = ui.scheduler.GetMinServerCount()
+ config.MoveDuringOffHours = ui.scheduler.GetMoveDuringOffHours()
+ config.OffHoursStart = ui.scheduler.GetOffHoursStart()
+ config.OffHoursEnd = ui.scheduler.GetOffHoursEnd()
+ }
+
+ return config
+}
+
+// floatPtr is a helper function to create float64 pointers
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+// RegisterUITempl registers the balance templ UI provider with the UI registry
+func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *BalanceDetector, scheduler *BalanceScheduler) {
+ uiProvider := NewUITemplProvider(detector, scheduler)
+ uiRegistry.RegisterUI(uiProvider)
+
+ glog.V(1).Infof("✅ Registered balance task templ UI provider")
+}
diff --git a/weed/worker/tasks/erasure_coding/ec.go b/weed/worker/tasks/erasure_coding/ec.go
new file mode 100644
index 000000000..641dfc6b5
--- /dev/null
+++ b/weed/worker/tasks/erasure_coding/ec.go
@@ -0,0 +1,79 @@
+package erasure_coding
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Task implements erasure coding operation to convert volumes to EC format
+type Task struct {
+ *tasks.BaseTask
+ server string
+ volumeID uint32
+}
+
+// NewTask creates a new erasure coding task instance
+func NewTask(server string, volumeID uint32) *Task {
+ task := &Task{
+ BaseTask: tasks.NewBaseTask(types.TaskTypeErasureCoding),
+ server: server,
+ volumeID: volumeID,
+ }
+ return task
+}
+
+// Execute executes the erasure coding task
+func (t *Task) Execute(params types.TaskParams) error {
+ glog.Infof("Starting erasure coding task for volume %d on server %s", t.volumeID, t.server)
+
+ // Simulate erasure coding operation with progress updates
+ steps := []struct {
+ name string
+ duration time.Duration
+ progress float64
+ }{
+ {"Analyzing volume", 2 * time.Second, 15},
+ {"Creating EC shards", 5 * time.Second, 50},
+ {"Verifying shards", 2 * time.Second, 75},
+ {"Finalizing EC volume", 1 * time.Second, 100},
+ }
+
+ for _, step := range steps {
+ if t.IsCancelled() {
+ return fmt.Errorf("erasure coding task cancelled")
+ }
+
+ glog.V(1).Infof("Erasure coding task step: %s", step.name)
+ t.SetProgress(step.progress)
+
+ // Simulate work
+ time.Sleep(step.duration)
+ }
+
+ glog.Infof("Erasure coding task completed for volume %d on server %s", t.volumeID, t.server)
+ return nil
+}
+
+// Validate validates the task parameters
+func (t *Task) Validate(params types.TaskParams) error {
+ if params.VolumeID == 0 {
+ return fmt.Errorf("volume_id is required")
+ }
+ if params.Server == "" {
+ return fmt.Errorf("server is required")
+ }
+ return nil
+}
+
+// EstimateTime estimates the time needed for the task
+func (t *Task) EstimateTime(params types.TaskParams) time.Duration {
+ // Base time for erasure coding operation
+ baseTime := 30 * time.Second
+
+ // Could adjust based on volume size or other factors
+ return baseTime
+}
diff --git a/weed/worker/tasks/erasure_coding/ec_detector.go b/weed/worker/tasks/erasure_coding/ec_detector.go
new file mode 100644
index 000000000..0f8b5e376
--- /dev/null
+++ b/weed/worker/tasks/erasure_coding/ec_detector.go
@@ -0,0 +1,139 @@
+package erasure_coding
+
+import (
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// EcDetector implements erasure coding task detection
+type EcDetector struct {
+ enabled bool
+ volumeAgeHours int
+ fullnessRatio float64
+ scanInterval time.Duration
+}
+
+// Compile-time interface assertions
+var (
+ _ types.TaskDetector = (*EcDetector)(nil)
+)
+
+// NewEcDetector creates a new erasure coding detector
+func NewEcDetector() *EcDetector {
+ return &EcDetector{
+ enabled: false, // Conservative default
+ volumeAgeHours: 24 * 7, // 1 week
+ fullnessRatio: 0.9, // 90% full
+ scanInterval: 2 * time.Hour,
+ }
+}
+
+// GetTaskType returns the task type
+func (d *EcDetector) GetTaskType() types.TaskType {
+ return types.TaskTypeErasureCoding
+}
+
+// ScanForTasks scans for volumes that should be converted to erasure coding
+func (d *EcDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo) ([]*types.TaskDetectionResult, error) {
+ if !d.enabled {
+ return nil, nil
+ }
+
+ var results []*types.TaskDetectionResult
+ now := time.Now()
+ ageThreshold := time.Duration(d.volumeAgeHours) * time.Hour
+
+ for _, metric := range volumeMetrics {
+ // Skip if already EC volume
+ if metric.IsECVolume {
+ continue
+ }
+
+ // Check age and fullness criteria
+ if metric.Age >= ageThreshold && metric.FullnessRatio >= d.fullnessRatio {
+ // Check if volume is read-only (safe for EC conversion)
+ if !metric.IsReadOnly {
+ continue
+ }
+
+ result := &types.TaskDetectionResult{
+ TaskType: types.TaskTypeErasureCoding,
+ VolumeID: metric.VolumeID,
+ Server: metric.Server,
+ Collection: metric.Collection,
+ Priority: types.TaskPriorityLow, // EC is not urgent
+ Reason: "Volume is old and full enough for EC conversion",
+ Parameters: map[string]interface{}{
+ "age_hours": int(metric.Age.Hours()),
+ "fullness_ratio": metric.FullnessRatio,
+ },
+ ScheduleAt: now,
+ }
+ results = append(results, result)
+ }
+ }
+
+ glog.V(2).Infof("EC detector found %d tasks to schedule", len(results))
+ return results, nil
+}
+
+// ScanInterval returns how often this task type should be scanned
+func (d *EcDetector) ScanInterval() time.Duration {
+ return d.scanInterval
+}
+
+// IsEnabled returns whether this task type is enabled
+func (d *EcDetector) IsEnabled() bool {
+ return d.enabled
+}
+
+// Configuration setters
+
+func (d *EcDetector) SetEnabled(enabled bool) {
+ d.enabled = enabled
+}
+
+func (d *EcDetector) SetVolumeAgeHours(hours int) {
+ d.volumeAgeHours = hours
+}
+
+func (d *EcDetector) SetFullnessRatio(ratio float64) {
+ d.fullnessRatio = ratio
+}
+
+func (d *EcDetector) SetScanInterval(interval time.Duration) {
+ d.scanInterval = interval
+}
+
+// GetVolumeAgeHours returns the current volume age threshold in hours
+func (d *EcDetector) GetVolumeAgeHours() int {
+ return d.volumeAgeHours
+}
+
+// GetFullnessRatio returns the current fullness ratio threshold
+func (d *EcDetector) GetFullnessRatio() float64 {
+ return d.fullnessRatio
+}
+
+// GetScanInterval returns the scan interval
+func (d *EcDetector) GetScanInterval() time.Duration {
+ return d.scanInterval
+}
+
+// ConfigureFromPolicy configures the detector based on the maintenance policy
+func (d *EcDetector) ConfigureFromPolicy(policy interface{}) {
+ // Type assert to the maintenance policy type we expect
+ if maintenancePolicy, ok := policy.(interface {
+ GetECEnabled() bool
+ GetECVolumeAgeHours() int
+ GetECFullnessRatio() float64
+ }); ok {
+ d.SetEnabled(maintenancePolicy.GetECEnabled())
+ d.SetVolumeAgeHours(maintenancePolicy.GetECVolumeAgeHours())
+ d.SetFullnessRatio(maintenancePolicy.GetECFullnessRatio())
+ } else {
+ glog.V(1).Infof("Could not configure EC detector from policy: unsupported policy type")
+ }
+}
diff --git a/weed/worker/tasks/erasure_coding/ec_register.go b/weed/worker/tasks/erasure_coding/ec_register.go
new file mode 100644
index 000000000..6c4b5bf7f
--- /dev/null
+++ b/weed/worker/tasks/erasure_coding/ec_register.go
@@ -0,0 +1,81 @@
+package erasure_coding
+
+import (
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Factory creates erasure coding task instances
+type Factory struct {
+ *tasks.BaseTaskFactory
+}
+
+// NewFactory creates a new erasure coding task factory
+func NewFactory() *Factory {
+ return &Factory{
+ BaseTaskFactory: tasks.NewBaseTaskFactory(
+ types.TaskTypeErasureCoding,
+ []string{"erasure_coding", "storage", "durability"},
+ "Convert volumes to erasure coded format for improved durability",
+ ),
+ }
+}
+
+// Create creates a new erasure coding task instance
+func (f *Factory) Create(params types.TaskParams) (types.TaskInterface, error) {
+ // Validate parameters
+ if params.VolumeID == 0 {
+ return nil, fmt.Errorf("volume_id is required")
+ }
+ if params.Server == "" {
+ return nil, fmt.Errorf("server is required")
+ }
+
+ task := NewTask(params.Server, params.VolumeID)
+ task.SetEstimatedDuration(task.EstimateTime(params))
+
+ return task, nil
+}
+
+// Shared detector and scheduler instances
+var (
+ sharedDetector *EcDetector
+ sharedScheduler *Scheduler
+)
+
+// getSharedInstances returns the shared detector and scheduler instances
+func getSharedInstances() (*EcDetector, *Scheduler) {
+ if sharedDetector == nil {
+ sharedDetector = NewEcDetector()
+ }
+ if sharedScheduler == nil {
+ sharedScheduler = NewScheduler()
+ }
+ return sharedDetector, sharedScheduler
+}
+
+// GetSharedInstances returns the shared detector and scheduler instances (public access)
+func GetSharedInstances() (*EcDetector, *Scheduler) {
+ return getSharedInstances()
+}
+
+// Auto-register this task when the package is imported
+func init() {
+ factory := NewFactory()
+ tasks.AutoRegister(types.TaskTypeErasureCoding, factory)
+
+ // Get shared instances for all registrations
+ detector, scheduler := getSharedInstances()
+
+ // Register with types registry
+ tasks.AutoRegisterTypes(func(registry *types.TaskRegistry) {
+ registry.RegisterTask(detector, scheduler)
+ })
+
+ // Register with UI registry using the same instances
+ tasks.AutoRegisterUI(func(uiRegistry *types.UIRegistry) {
+ RegisterUI(uiRegistry, detector, scheduler)
+ })
+}
diff --git a/weed/worker/tasks/erasure_coding/ec_scheduler.go b/weed/worker/tasks/erasure_coding/ec_scheduler.go
new file mode 100644
index 000000000..b2366bb06
--- /dev/null
+++ b/weed/worker/tasks/erasure_coding/ec_scheduler.go
@@ -0,0 +1,114 @@
+package erasure_coding
+
+import (
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Scheduler implements erasure coding task scheduling
+type Scheduler struct {
+ maxConcurrent int
+ enabled bool
+}
+
+// NewScheduler creates a new erasure coding scheduler
+func NewScheduler() *Scheduler {
+ return &Scheduler{
+ maxConcurrent: 1, // Conservative default
+ enabled: false, // Conservative default
+ }
+}
+
+// GetTaskType returns the task type
+func (s *Scheduler) GetTaskType() types.TaskType {
+ return types.TaskTypeErasureCoding
+}
+
+// CanScheduleNow determines if an erasure coding task can be scheduled now
+func (s *Scheduler) CanScheduleNow(task *types.Task, runningTasks []*types.Task, availableWorkers []*types.Worker) bool {
+ if !s.enabled {
+ return false
+ }
+
+ // Check if we have available workers
+ if len(availableWorkers) == 0 {
+ return false
+ }
+
+ // Count running EC tasks
+ runningCount := 0
+ for _, runningTask := range runningTasks {
+ if runningTask.Type == types.TaskTypeErasureCoding {
+ runningCount++
+ }
+ }
+
+ // Check concurrency limit
+ if runningCount >= s.maxConcurrent {
+ glog.V(3).Infof("EC scheduler: at concurrency limit (%d/%d)", runningCount, s.maxConcurrent)
+ return false
+ }
+
+ // Check if any worker can handle EC tasks
+ for _, worker := range availableWorkers {
+ for _, capability := range worker.Capabilities {
+ if capability == types.TaskTypeErasureCoding {
+ glog.V(3).Infof("EC scheduler: can schedule task for volume %d", task.VolumeID)
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// GetMaxConcurrent returns the maximum number of concurrent tasks
+func (s *Scheduler) GetMaxConcurrent() int {
+ return s.maxConcurrent
+}
+
+// GetDefaultRepeatInterval returns the default interval to wait before repeating EC tasks
+func (s *Scheduler) GetDefaultRepeatInterval() time.Duration {
+ return 24 * time.Hour // Don't repeat EC for 24 hours
+}
+
+// GetPriority returns the priority for this task
+func (s *Scheduler) GetPriority(task *types.Task) types.TaskPriority {
+ return types.TaskPriorityLow // EC is not urgent
+}
+
+// WasTaskRecentlyCompleted checks if a similar task was recently completed
+func (s *Scheduler) WasTaskRecentlyCompleted(task *types.Task, completedTasks []*types.Task, now time.Time) bool {
+ // Don't repeat EC for 24 hours
+ interval := 24 * time.Hour
+ cutoff := now.Add(-interval)
+
+ for _, completedTask := range completedTasks {
+ if completedTask.Type == types.TaskTypeErasureCoding &&
+ completedTask.VolumeID == task.VolumeID &&
+ completedTask.Server == task.Server &&
+ completedTask.Status == types.TaskStatusCompleted &&
+ completedTask.CompletedAt != nil &&
+ completedTask.CompletedAt.After(cutoff) {
+ return true
+ }
+ }
+ return false
+}
+
+// IsEnabled returns whether this task type is enabled
+func (s *Scheduler) IsEnabled() bool {
+ return s.enabled
+}
+
+// Configuration setters
+
+func (s *Scheduler) SetEnabled(enabled bool) {
+ s.enabled = enabled
+}
+
+func (s *Scheduler) SetMaxConcurrent(max int) {
+ s.maxConcurrent = max
+}
diff --git a/weed/worker/tasks/erasure_coding/ui.go b/weed/worker/tasks/erasure_coding/ui.go
new file mode 100644
index 000000000..8a4640cf8
--- /dev/null
+++ b/weed/worker/tasks/erasure_coding/ui.go
@@ -0,0 +1,309 @@
+package erasure_coding
+
+import (
+ "fmt"
+ "html/template"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// UIProvider provides the UI for erasure coding task configuration
+type UIProvider struct {
+ detector *EcDetector
+ scheduler *Scheduler
+}
+
+// NewUIProvider creates a new erasure coding UI provider
+func NewUIProvider(detector *EcDetector, scheduler *Scheduler) *UIProvider {
+ return &UIProvider{
+ detector: detector,
+ scheduler: scheduler,
+ }
+}
+
+// GetTaskType returns the task type
+func (ui *UIProvider) GetTaskType() types.TaskType {
+ return types.TaskTypeErasureCoding
+}
+
+// GetDisplayName returns the human-readable name
+func (ui *UIProvider) GetDisplayName() string {
+ return "Erasure Coding"
+}
+
+// GetDescription returns a description of what this task does
+func (ui *UIProvider) GetDescription() string {
+ return "Converts volumes to erasure coded format for improved data durability and fault tolerance"
+}
+
+// GetIcon returns the icon CSS class for this task type
+func (ui *UIProvider) GetIcon() string {
+ return "fas fa-shield-alt text-info"
+}
+
+// ErasureCodingConfig represents the erasure coding configuration
+type ErasureCodingConfig struct {
+ Enabled bool `json:"enabled"`
+ VolumeAgeHoursSeconds int `json:"volume_age_hours_seconds"`
+ FullnessRatio float64 `json:"fullness_ratio"`
+ ScanIntervalSeconds int `json:"scan_interval_seconds"`
+ MaxConcurrent int `json:"max_concurrent"`
+ ShardCount int `json:"shard_count"`
+ ParityCount int `json:"parity_count"`
+ CollectionFilter string `json:"collection_filter"`
+}
+
+// Helper functions for duration conversion
+func secondsToDuration(seconds int) time.Duration {
+ return time.Duration(seconds) * time.Second
+}
+
+func durationToSeconds(d time.Duration) int {
+ return int(d.Seconds())
+}
+
+// formatDurationForUser formats seconds as a user-friendly duration string
+func formatDurationForUser(seconds int) string {
+ d := secondsToDuration(seconds)
+ if d < time.Minute {
+ return fmt.Sprintf("%ds", seconds)
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%.0fm", d.Minutes())
+ }
+ if d < 24*time.Hour {
+ return fmt.Sprintf("%.1fh", d.Hours())
+ }
+ return fmt.Sprintf("%.1fd", d.Hours()/24)
+}
+
+// RenderConfigForm renders the configuration form HTML
+func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML, error) {
+ config := ui.getCurrentECConfig()
+
+ // Build form using the FormBuilder helper
+ form := types.NewFormBuilder()
+
+ // Detection Settings
+ form.AddCheckboxField(
+ "enabled",
+ "Enable Erasure Coding Tasks",
+ "Whether erasure coding tasks should be automatically created",
+ config.Enabled,
+ )
+
+ form.AddNumberField(
+ "volume_age_hours_seconds",
+ "Volume Age Threshold",
+ "Only apply erasure coding to volumes older than this duration",
+ float64(config.VolumeAgeHoursSeconds),
+ true,
+ )
+
+ form.AddNumberField(
+ "scan_interval_seconds",
+ "Scan Interval",
+ "How often to scan for volumes needing erasure coding",
+ float64(config.ScanIntervalSeconds),
+ true,
+ )
+
+ // Scheduling Settings
+ form.AddNumberField(
+ "max_concurrent",
+ "Max Concurrent Tasks",
+ "Maximum number of erasure coding tasks that can run simultaneously",
+ float64(config.MaxConcurrent),
+ true,
+ )
+
+ // Erasure Coding Parameters
+ form.AddNumberField(
+ "shard_count",
+ "Data Shards",
+ "Number of data shards for erasure coding (recommended: 10)",
+ float64(config.ShardCount),
+ true,
+ )
+
+ form.AddNumberField(
+ "parity_count",
+ "Parity Shards",
+ "Number of parity shards for erasure coding (recommended: 4)",
+ float64(config.ParityCount),
+ true,
+ )
+
+ // Generate organized form sections using Bootstrap components
+ html := `
+<div class="row">
+ <div class="col-12">
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-shield-alt me-2"></i>
+ Erasure Coding Configuration
+ </h5>
+ </div>
+ <div class="card-body">
+` + string(form.Build()) + `
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-12">
+ <div class="card mb-3">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-info-circle me-2"></i>
+ Performance Impact
+ </h5>
+ </div>
+ <div class="card-body">
+ <div class="alert alert-info" role="alert">
+ <h6 class="alert-heading">Important Notes:</h6>
+ <p class="mb-2"><strong>Performance:</strong> Erasure coding is CPU and I/O intensive. Consider running during off-peak hours.</p>
+ <p class="mb-0"><strong>Durability:</strong> With ` + fmt.Sprintf("%d+%d", config.ShardCount, config.ParityCount) + ` configuration, can tolerate up to ` + fmt.Sprintf("%d", config.ParityCount) + ` shard failures.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>`
+
+ return template.HTML(html), nil
+}
+
+// ParseConfigForm parses form data into configuration
+func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
+ config := &ErasureCodingConfig{}
+
+ // Parse enabled
+ config.Enabled = len(formData["enabled"]) > 0
+
+ // Parse volume age hours
+ if values, ok := formData["volume_age_hours_seconds"]; ok && len(values) > 0 {
+ hours, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid volume age hours: %v", err)
+ }
+ config.VolumeAgeHoursSeconds = hours
+ }
+
+ // Parse scan interval
+ if values, ok := formData["scan_interval_seconds"]; ok && len(values) > 0 {
+ interval, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid scan interval: %v", err)
+ }
+ config.ScanIntervalSeconds = interval
+ }
+
+ // Parse max concurrent
+ if values, ok := formData["max_concurrent"]; ok && len(values) > 0 {
+ maxConcurrent, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid max concurrent: %v", err)
+ }
+ if maxConcurrent < 1 {
+ return nil, fmt.Errorf("max concurrent must be at least 1")
+ }
+ config.MaxConcurrent = maxConcurrent
+ }
+
+ // Parse shard count
+ if values, ok := formData["shard_count"]; ok && len(values) > 0 {
+ shardCount, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid shard count: %v", err)
+ }
+ if shardCount < 1 {
+ return nil, fmt.Errorf("shard count must be at least 1")
+ }
+ config.ShardCount = shardCount
+ }
+
+ // Parse parity count
+ if values, ok := formData["parity_count"]; ok && len(values) > 0 {
+ parityCount, err := strconv.Atoi(values[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid parity count: %v", err)
+ }
+ if parityCount < 1 {
+ return nil, fmt.Errorf("parity count must be at least 1")
+ }
+ config.ParityCount = parityCount
+ }
+
+ return config, nil
+}
+
+// GetCurrentConfig returns the current configuration
+func (ui *UIProvider) GetCurrentConfig() interface{} {
+ return ui.getCurrentECConfig()
+}
+
+// ApplyConfig applies the new configuration
+func (ui *UIProvider) ApplyConfig(config interface{}) error {
+ ecConfig, ok := config.(ErasureCodingConfig)
+ if !ok {
+ return fmt.Errorf("invalid config type, expected ErasureCodingConfig")
+ }
+
+ // Apply to detector
+ if ui.detector != nil {
+ ui.detector.SetEnabled(ecConfig.Enabled)
+ ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds)
+ ui.detector.SetScanInterval(secondsToDuration(ecConfig.ScanIntervalSeconds))
+ }
+
+ // Apply to scheduler
+ if ui.scheduler != nil {
+ ui.scheduler.SetEnabled(ecConfig.Enabled)
+ ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent)
+ }
+
+ glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%v, max_concurrent=%d, shards=%d+%d",
+ ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent, ecConfig.ShardCount, ecConfig.ParityCount)
+
+ return nil
+}
+
+// getCurrentECConfig gets the current configuration from detector and scheduler
+func (ui *UIProvider) getCurrentECConfig() ErasureCodingConfig {
+ config := ErasureCodingConfig{
+ // Default values (fallback if detectors/schedulers are nil)
+ Enabled: true,
+ VolumeAgeHoursSeconds: 24 * 3600, // 24 hours in seconds
+ ScanIntervalSeconds: 2 * 3600, // 2 hours in seconds
+ MaxConcurrent: 1,
+ ShardCount: 10,
+ ParityCount: 4,
+ }
+
+ // Get current values from detector
+ if ui.detector != nil {
+ config.Enabled = ui.detector.IsEnabled()
+ config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours()
+ config.ScanIntervalSeconds = durationToSeconds(ui.detector.ScanInterval())
+ }
+
+ // Get current values from scheduler
+ if ui.scheduler != nil {
+ config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
+ }
+
+ return config
+}
+
+// RegisterUI registers the erasure coding UI provider with the UI registry
+func RegisterUI(uiRegistry *types.UIRegistry, detector *EcDetector, scheduler *Scheduler) {
+ uiProvider := NewUIProvider(detector, scheduler)
+ uiRegistry.RegisterUI(uiProvider)
+
+ glog.V(1).Infof("✅ Registered erasure coding task UI provider")
+}
diff --git a/weed/worker/tasks/erasure_coding/ui_templ.go b/weed/worker/tasks/erasure_coding/ui_templ.go
new file mode 100644
index 000000000..12c3d199e
--- /dev/null
+++ b/weed/worker/tasks/erasure_coding/ui_templ.go
@@ -0,0 +1,319 @@
+package erasure_coding
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Helper function to format seconds as duration string
+func formatDurationFromSeconds(seconds int) string {
+ d := time.Duration(seconds) * time.Second
+ return d.String()
+}
+
+// Helper function to convert value and unit to seconds
+func valueAndUnitToSeconds(value float64, unit string) int {
+ switch unit {
+ case "days":
+ return int(value * 24 * 60 * 60)
+ case "hours":
+ return int(value * 60 * 60)
+ case "minutes":
+ return int(value * 60)
+ default:
+ return int(value * 60) // Default to minutes
+ }
+}
+
+// UITemplProvider provides the templ-based UI for erasure coding task configuration
+type UITemplProvider struct {
+ detector *EcDetector
+ scheduler *Scheduler
+}
+
+// NewUITemplProvider creates a new erasure coding templ UI provider
+func NewUITemplProvider(detector *EcDetector, scheduler *Scheduler) *UITemplProvider {
+ return &UITemplProvider{
+ detector: detector,
+ scheduler: scheduler,
+ }
+}
+
+// ErasureCodingConfig is defined in ui.go - we reuse it
+
+// GetTaskType returns the task type
+func (ui *UITemplProvider) GetTaskType() types.TaskType {
+ return types.TaskTypeErasureCoding
+}
+
+// GetDisplayName returns the human-readable name
+func (ui *UITemplProvider) GetDisplayName() string {
+ return "Erasure Coding"
+}
+
+// GetDescription returns a description of what this task does
+func (ui *UITemplProvider) GetDescription() string {
+ return "Converts replicated volumes to erasure-coded format for efficient storage"
+}
+
+// GetIcon returns the icon CSS class for this task type
+func (ui *UITemplProvider) GetIcon() string {
+ return "fas fa-shield-alt text-info"
+}
+
+// RenderConfigSections renders the configuration as templ section data
+func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
+ config := ui.getCurrentECConfig()
+
+ // Detection settings section
+ detectionSection := components.ConfigSectionData{
+ Title: "Detection Settings",
+ Icon: "fas fa-search",
+ Description: "Configure when erasure coding tasks should be triggered",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "enabled",
+ Label: "Enable Erasure Coding Tasks",
+ Description: "Whether erasure coding tasks should be automatically created",
+ },
+ Checked: config.Enabled,
+ },
+ components.DurationInputFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "scan_interval",
+ Label: "Scan Interval",
+ Description: "How often to scan for volumes needing erasure coding",
+ Required: true,
+ },
+ Seconds: config.ScanIntervalSeconds,
+ },
+ components.DurationInputFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "volume_age_threshold",
+ Label: "Volume Age Threshold",
+ Description: "Only apply erasure coding to volumes older than this age",
+ Required: true,
+ },
+ Seconds: config.VolumeAgeHoursSeconds,
+ },
+ },
+ }
+
+ // Erasure coding parameters section
+ paramsSection := components.ConfigSectionData{
+ Title: "Erasure Coding Parameters",
+ Icon: "fas fa-cogs",
+ Description: "Configure erasure coding scheme and performance",
+ Fields: []interface{}{
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "data_shards",
+ Label: "Data Shards",
+ Description: "Number of data shards in the erasure coding scheme",
+ Required: true,
+ },
+ Value: float64(config.ShardCount),
+ Step: "1",
+ Min: floatPtr(1),
+ Max: floatPtr(16),
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "parity_shards",
+ Label: "Parity Shards",
+ Description: "Number of parity shards (determines fault tolerance)",
+ Required: true,
+ },
+ Value: float64(config.ParityCount),
+ Step: "1",
+ Min: floatPtr(1),
+ Max: floatPtr(16),
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "max_concurrent",
+ Label: "Max Concurrent Tasks",
+ Description: "Maximum number of erasure coding tasks that can run simultaneously",
+ Required: true,
+ },
+ Value: float64(config.MaxConcurrent),
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ },
+ }
+
+ // Performance impact info section
+ infoSection := components.ConfigSectionData{
+ Title: "Performance Impact",
+ Icon: "fas fa-info-circle",
+ Description: "Important information about erasure coding operations",
+ Fields: []interface{}{
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "durability_info",
+ Label: "Durability",
+ Description: fmt.Sprintf("With %d+%d configuration, can tolerate up to %d shard failures",
+ config.ShardCount, config.ParityCount, config.ParityCount),
+ },
+ Value: "High durability with space efficiency",
+ },
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "performance_info",
+ Label: "Performance Note",
+ Description: "Erasure coding is CPU and I/O intensive. Consider running during off-peak hours",
+ },
+ Value: "Schedule during low-traffic periods",
+ },
+ },
+ }
+
+ return []components.ConfigSectionData{detectionSection, paramsSection, infoSection}, nil
+}
+
+// ParseConfigForm parses form data into configuration
+func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
+ config := &ErasureCodingConfig{}
+
+ // Parse enabled checkbox
+ config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
+
+ // Parse volume age threshold
+ if valueStr := formData["volume_age_threshold"]; len(valueStr) > 0 {
+ if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid volume age threshold value: %v", err)
+ } else {
+ unit := "hours" // default
+ if unitStr := formData["volume_age_threshold_unit"]; len(unitStr) > 0 {
+ unit = unitStr[0]
+ }
+ config.VolumeAgeHoursSeconds = valueAndUnitToSeconds(value, unit)
+ }
+ }
+
+ // Parse scan interval
+ if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
+ if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid scan interval value: %v", err)
+ } else {
+ unit := "hours" // default
+ if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
+ unit = unitStr[0]
+ }
+ config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
+ }
+ }
+
+ // Parse data shards
+ if shardsStr := formData["data_shards"]; len(shardsStr) > 0 {
+ if shards, err := strconv.Atoi(shardsStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid data shards: %v", err)
+ } else if shards < 1 || shards > 16 {
+ return nil, fmt.Errorf("data shards must be between 1 and 16")
+ } else {
+ config.ShardCount = shards
+ }
+ }
+
+ // Parse parity shards
+ if shardsStr := formData["parity_shards"]; len(shardsStr) > 0 {
+ if shards, err := strconv.Atoi(shardsStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid parity shards: %v", err)
+ } else if shards < 1 || shards > 16 {
+ return nil, fmt.Errorf("parity shards must be between 1 and 16")
+ } else {
+ config.ParityCount = shards
+ }
+ }
+
+ // Parse max concurrent
+ if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
+ if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid max concurrent: %v", err)
+ } else if concurrent < 1 {
+ return nil, fmt.Errorf("max concurrent must be at least 1")
+ } else {
+ config.MaxConcurrent = concurrent
+ }
+ }
+
+ return config, nil
+}
+
+// GetCurrentConfig returns the current configuration
+func (ui *UITemplProvider) GetCurrentConfig() interface{} {
+ return ui.getCurrentECConfig()
+}
+
+// ApplyConfig applies the new configuration
+func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
+ ecConfig, ok := config.(*ErasureCodingConfig)
+ if !ok {
+ return fmt.Errorf("invalid config type, expected *ErasureCodingConfig")
+ }
+
+ // Apply to detector
+ if ui.detector != nil {
+ ui.detector.SetEnabled(ecConfig.Enabled)
+ ui.detector.SetVolumeAgeHours(ecConfig.VolumeAgeHoursSeconds)
+ ui.detector.SetScanInterval(time.Duration(ecConfig.ScanIntervalSeconds) * time.Second)
+ }
+
+ // Apply to scheduler
+ if ui.scheduler != nil {
+ ui.scheduler.SetMaxConcurrent(ecConfig.MaxConcurrent)
+ ui.scheduler.SetEnabled(ecConfig.Enabled)
+ }
+
+ glog.V(1).Infof("Applied erasure coding configuration: enabled=%v, age_threshold=%ds, max_concurrent=%d",
+ ecConfig.Enabled, ecConfig.VolumeAgeHoursSeconds, ecConfig.MaxConcurrent)
+
+ return nil
+}
+
+// getCurrentECConfig gets the current configuration from detector and scheduler
+func (ui *UITemplProvider) getCurrentECConfig() *ErasureCodingConfig {
+ config := &ErasureCodingConfig{
+ // Default values (fallback if detectors/schedulers are nil)
+ Enabled: true,
+ VolumeAgeHoursSeconds: int((24 * time.Hour).Seconds()),
+ ScanIntervalSeconds: int((2 * time.Hour).Seconds()),
+ MaxConcurrent: 1,
+ ShardCount: 10,
+ ParityCount: 4,
+ }
+
+ // Get current values from detector
+ if ui.detector != nil {
+ config.Enabled = ui.detector.IsEnabled()
+ config.VolumeAgeHoursSeconds = ui.detector.GetVolumeAgeHours()
+ config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
+ }
+
+ // Get current values from scheduler
+ if ui.scheduler != nil {
+ config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
+ }
+
+ return config
+}
+
+// floatPtr is a helper function to create float64 pointers
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+// RegisterUITempl registers the erasure coding templ UI provider with the UI registry
+func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *EcDetector, scheduler *Scheduler) {
+ uiProvider := NewUITemplProvider(detector, scheduler)
+ uiRegistry.RegisterUI(uiProvider)
+
+ glog.V(1).Infof("✅ Registered erasure coding task templ UI provider")
+}
diff --git a/weed/worker/tasks/registry.go b/weed/worker/tasks/registry.go
new file mode 100644
index 000000000..105055128
--- /dev/null
+++ b/weed/worker/tasks/registry.go
@@ -0,0 +1,110 @@
+package tasks
+
+import (
+ "sync"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+var (
+ globalRegistry *TaskRegistry
+ globalTypesRegistry *types.TaskRegistry
+ globalUIRegistry *types.UIRegistry
+ registryOnce sync.Once
+ typesRegistryOnce sync.Once
+ uiRegistryOnce sync.Once
+)
+
+// GetGlobalRegistry returns the global task registry (singleton)
+func GetGlobalRegistry() *TaskRegistry {
+ registryOnce.Do(func() {
+ globalRegistry = NewTaskRegistry()
+ glog.V(1).Infof("Created global task registry")
+ })
+ return globalRegistry
+}
+
+// GetGlobalTypesRegistry returns the global types registry (singleton)
+func GetGlobalTypesRegistry() *types.TaskRegistry {
+ typesRegistryOnce.Do(func() {
+ globalTypesRegistry = types.NewTaskRegistry()
+ glog.V(1).Infof("Created global types registry")
+ })
+ return globalTypesRegistry
+}
+
+// GetGlobalUIRegistry returns the global UI registry (singleton)
+func GetGlobalUIRegistry() *types.UIRegistry {
+ uiRegistryOnce.Do(func() {
+ globalUIRegistry = types.NewUIRegistry()
+ glog.V(1).Infof("Created global UI registry")
+ })
+ return globalUIRegistry
+}
+
+// AutoRegister registers a task directly with the global registry
+func AutoRegister(taskType types.TaskType, factory types.TaskFactory) {
+ registry := GetGlobalRegistry()
+ registry.Register(taskType, factory)
+ glog.V(1).Infof("Auto-registered task type: %s", taskType)
+}
+
+// AutoRegisterTypes registers a task with the global types registry
+func AutoRegisterTypes(registerFunc func(*types.TaskRegistry)) {
+ registry := GetGlobalTypesRegistry()
+ registerFunc(registry)
+ glog.V(1).Infof("Auto-registered task with types registry")
+}
+
+// AutoRegisterUI registers a UI provider with the global UI registry
+func AutoRegisterUI(registerFunc func(*types.UIRegistry)) {
+ registry := GetGlobalUIRegistry()
+ registerFunc(registry)
+ glog.V(1).Infof("Auto-registered task UI provider")
+}
+
+// SetDefaultCapabilitiesFromRegistry sets the default worker capabilities
+// based on all registered task types
+func SetDefaultCapabilitiesFromRegistry() {
+ typesRegistry := GetGlobalTypesRegistry()
+
+ var capabilities []types.TaskType
+ for taskType := range typesRegistry.GetAllDetectors() {
+ capabilities = append(capabilities, taskType)
+ }
+
+ // Set the default capabilities in the types package
+ types.SetDefaultCapabilities(capabilities)
+
+ glog.V(1).Infof("Set default worker capabilities from registry: %v", capabilities)
+}
+
+// BuildMaintenancePolicyFromTasks creates a maintenance policy with default configurations
+// from all registered tasks using their UI providers
+func BuildMaintenancePolicyFromTasks() *types.MaintenancePolicy {
+ policy := types.NewMaintenancePolicy()
+
+ // Get all registered task types from the UI registry
+ uiRegistry := GetGlobalUIRegistry()
+
+ for taskType, provider := range uiRegistry.GetAllProviders() {
+ // Get the default configuration from the UI provider
+ defaultConfig := provider.GetCurrentConfig()
+
+ // Set the configuration in the policy
+ policy.SetTaskConfig(taskType, defaultConfig)
+
+ glog.V(3).Infof("Added default config for task type %s to policy", taskType)
+ }
+
+ glog.V(2).Infof("Built maintenance policy with %d task configurations", len(policy.TaskConfigs))
+ return policy
+}
+
+// SetMaintenancePolicyFromTasks sets the default maintenance policy from registered tasks
+func SetMaintenancePolicyFromTasks() {
+ // This function can be called to initialize the policy from registered tasks
+ // For now, we'll just log that this should be called by the integration layer
+ glog.V(1).Infof("SetMaintenancePolicyFromTasks called - policy should be built by the integration layer")
+}
diff --git a/weed/worker/tasks/task.go b/weed/worker/tasks/task.go
new file mode 100644
index 000000000..482233f60
--- /dev/null
+++ b/weed/worker/tasks/task.go
@@ -0,0 +1,252 @@
+package tasks
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// BaseTask provides common functionality for all tasks
+type BaseTask struct {
+ taskType types.TaskType
+ progress float64
+ cancelled bool
+ mutex sync.RWMutex
+ startTime time.Time
+ estimatedDuration time.Duration
+}
+
+// NewBaseTask creates a new base task
+func NewBaseTask(taskType types.TaskType) *BaseTask {
+ return &BaseTask{
+ taskType: taskType,
+ progress: 0.0,
+ cancelled: false,
+ }
+}
+
+// Type returns the task type
+func (t *BaseTask) Type() types.TaskType {
+ return t.taskType
+}
+
+// GetProgress returns the current progress (0.0 to 100.0)
+func (t *BaseTask) GetProgress() float64 {
+ t.mutex.RLock()
+ defer t.mutex.RUnlock()
+ return t.progress
+}
+
+// SetProgress sets the current progress
+func (t *BaseTask) SetProgress(progress float64) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+ if progress < 0 {
+ progress = 0
+ }
+ if progress > 100 {
+ progress = 100
+ }
+ t.progress = progress
+}
+
+// Cancel cancels the task
+func (t *BaseTask) Cancel() error {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+ t.cancelled = true
+ return nil
+}
+
+// IsCancelled returns whether the task is cancelled
+func (t *BaseTask) IsCancelled() bool {
+ t.mutex.RLock()
+ defer t.mutex.RUnlock()
+ return t.cancelled
+}
+
+// SetStartTime sets the task start time
+func (t *BaseTask) SetStartTime(startTime time.Time) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+ t.startTime = startTime
+}
+
+// GetStartTime returns the task start time
+func (t *BaseTask) GetStartTime() time.Time {
+ t.mutex.RLock()
+ defer t.mutex.RUnlock()
+ return t.startTime
+}
+
+// SetEstimatedDuration sets the estimated duration
+func (t *BaseTask) SetEstimatedDuration(duration time.Duration) {
+ t.mutex.Lock()
+ defer t.mutex.Unlock()
+ t.estimatedDuration = duration
+}
+
+// GetEstimatedDuration returns the estimated duration
+func (t *BaseTask) GetEstimatedDuration() time.Duration {
+ t.mutex.RLock()
+ defer t.mutex.RUnlock()
+ return t.estimatedDuration
+}
+
+// ExecuteTask is a wrapper that handles common task execution logic
+func (t *BaseTask) ExecuteTask(ctx context.Context, params types.TaskParams, executor func(context.Context, types.TaskParams) error) error {
+ t.SetStartTime(time.Now())
+ t.SetProgress(0)
+
+ // Create a context that can be cancelled
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Monitor for cancellation
+ go func() {
+ for !t.IsCancelled() {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(time.Second):
+ // Check cancellation every second
+ }
+ }
+ cancel()
+ }()
+
+ // Execute the actual task
+ err := executor(ctx, params)
+
+ if err != nil {
+ return err
+ }
+
+ if t.IsCancelled() {
+ return context.Canceled
+ }
+
+ t.SetProgress(100)
+ return nil
+}
+
+// TaskRegistry manages task factories
+type TaskRegistry struct {
+ factories map[types.TaskType]types.TaskFactory
+ mutex sync.RWMutex
+}
+
+// NewTaskRegistry creates a new task registry
+func NewTaskRegistry() *TaskRegistry {
+ return &TaskRegistry{
+ factories: make(map[types.TaskType]types.TaskFactory),
+ }
+}
+
+// Register registers a task factory
+func (r *TaskRegistry) Register(taskType types.TaskType, factory types.TaskFactory) {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+ r.factories[taskType] = factory
+}
+
+// CreateTask creates a task instance
+func (r *TaskRegistry) CreateTask(taskType types.TaskType, params types.TaskParams) (types.TaskInterface, error) {
+ r.mutex.RLock()
+ factory, exists := r.factories[taskType]
+ r.mutex.RUnlock()
+
+ if !exists {
+ return nil, &UnsupportedTaskTypeError{TaskType: taskType}
+ }
+
+ return factory.Create(params)
+}
+
+// GetSupportedTypes returns all supported task types
+func (r *TaskRegistry) GetSupportedTypes() []types.TaskType {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+
+ types := make([]types.TaskType, 0, len(r.factories))
+ for taskType := range r.factories {
+ types = append(types, taskType)
+ }
+ return types
+}
+
+// GetFactory returns the factory for a task type
+func (r *TaskRegistry) GetFactory(taskType types.TaskType) (types.TaskFactory, bool) {
+ r.mutex.RLock()
+ defer r.mutex.RUnlock()
+ factory, exists := r.factories[taskType]
+ return factory, exists
+}
+
+// UnsupportedTaskTypeError represents an error for unsupported task types
+type UnsupportedTaskTypeError struct {
+ TaskType types.TaskType
+}
+
+func (e *UnsupportedTaskTypeError) Error() string {
+ return "unsupported task type: " + string(e.TaskType)
+}
+
+// BaseTaskFactory provides common functionality for task factories
+type BaseTaskFactory struct {
+ taskType types.TaskType
+ capabilities []string
+ description string
+}
+
+// NewBaseTaskFactory creates a new base task factory
+func NewBaseTaskFactory(taskType types.TaskType, capabilities []string, description string) *BaseTaskFactory {
+ return &BaseTaskFactory{
+ taskType: taskType,
+ capabilities: capabilities,
+ description: description,
+ }
+}
+
+// Capabilities returns the capabilities required for this task type
+func (f *BaseTaskFactory) Capabilities() []string {
+ return f.capabilities
+}
+
+// Description returns the description of this task type
+func (f *BaseTaskFactory) Description() string {
+ return f.description
+}
+
+// ValidateParams validates task parameters
+func ValidateParams(params types.TaskParams, requiredFields ...string) error {
+ for _, field := range requiredFields {
+ switch field {
+ case "volume_id":
+ if params.VolumeID == 0 {
+ return &ValidationError{Field: field, Message: "volume_id is required"}
+ }
+ case "server":
+ if params.Server == "" {
+ return &ValidationError{Field: field, Message: "server is required"}
+ }
+ case "collection":
+ if params.Collection == "" {
+ return &ValidationError{Field: field, Message: "collection is required"}
+ }
+ }
+ }
+ return nil
+}
+
+// ValidationError represents a parameter validation error
+type ValidationError struct {
+ Field string
+ Message string
+}
+
+func (e *ValidationError) Error() string {
+ return e.Field + ": " + e.Message
+}
diff --git a/weed/worker/tasks/vacuum/ui.go b/weed/worker/tasks/vacuum/ui.go
new file mode 100644
index 000000000..a315dde88
--- /dev/null
+++ b/weed/worker/tasks/vacuum/ui.go
@@ -0,0 +1,314 @@
+package vacuum
+
+import (
+ "fmt"
+ "html/template"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// UIProvider provides the UI for vacuum task configuration
+type UIProvider struct {
+ detector *VacuumDetector
+ scheduler *VacuumScheduler
+}
+
+// NewUIProvider creates a new vacuum UI provider
+func NewUIProvider(detector *VacuumDetector, scheduler *VacuumScheduler) *UIProvider {
+ return &UIProvider{
+ detector: detector,
+ scheduler: scheduler,
+ }
+}
+
+// GetTaskType returns the task type
+func (ui *UIProvider) GetTaskType() types.TaskType {
+ return types.TaskTypeVacuum
+}
+
+// GetDisplayName returns the human-readable name
+func (ui *UIProvider) GetDisplayName() string {
+ return "Volume Vacuum"
+}
+
+// GetDescription returns a description of what this task does
+func (ui *UIProvider) GetDescription() string {
+ return "Reclaims disk space by removing deleted files from volumes"
+}
+
+// GetIcon returns the icon CSS class for this task type
+func (ui *UIProvider) GetIcon() string {
+ return "fas fa-broom text-primary"
+}
+
+// VacuumConfig represents the vacuum configuration
+type VacuumConfig struct {
+ Enabled bool `json:"enabled"`
+ GarbageThreshold float64 `json:"garbage_threshold"`
+ ScanIntervalSeconds int `json:"scan_interval_seconds"`
+ MaxConcurrent int `json:"max_concurrent"`
+ MinVolumeAgeSeconds int `json:"min_volume_age_seconds"`
+ MinIntervalSeconds int `json:"min_interval_seconds"`
+}
+
+// Helper functions for duration conversion
+func secondsToDuration(seconds int) time.Duration {
+ return time.Duration(seconds) * time.Second
+}
+
+func durationToSeconds(d time.Duration) int {
+ return int(d.Seconds())
+}
+
+// formatDurationForUser formats seconds as a user-friendly duration string
+func formatDurationForUser(seconds int) string {
+ d := secondsToDuration(seconds)
+ if d < time.Minute {
+ return fmt.Sprintf("%ds", seconds)
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%.0fm", d.Minutes())
+ }
+ if d < 24*time.Hour {
+ return fmt.Sprintf("%.1fh", d.Hours())
+ }
+ return fmt.Sprintf("%.1fd", d.Hours()/24)
+}
+
+// RenderConfigForm renders the configuration form HTML
+func (ui *UIProvider) RenderConfigForm(currentConfig interface{}) (template.HTML, error) {
+ config := ui.getCurrentVacuumConfig()
+
+ // Build form using the FormBuilder helper
+ form := types.NewFormBuilder()
+
+ // Detection Settings
+ form.AddCheckboxField(
+ "enabled",
+ "Enable Vacuum Tasks",
+ "Whether vacuum tasks should be automatically created",
+ config.Enabled,
+ )
+
+ form.AddNumberField(
+ "garbage_threshold",
+ "Garbage Threshold (%)",
+ "Trigger vacuum when garbage ratio exceeds this percentage (0.0-1.0)",
+ config.GarbageThreshold,
+ true,
+ )
+
+ form.AddDurationField(
+ "scan_interval",
+ "Scan Interval",
+ "How often to scan for volumes needing vacuum",
+ secondsToDuration(config.ScanIntervalSeconds),
+ true,
+ )
+
+ form.AddDurationField(
+ "min_volume_age",
+ "Minimum Volume Age",
+ "Only vacuum volumes older than this duration",
+ secondsToDuration(config.MinVolumeAgeSeconds),
+ true,
+ )
+
+ // Scheduling Settings
+ form.AddNumberField(
+ "max_concurrent",
+ "Max Concurrent Tasks",
+ "Maximum number of vacuum tasks that can run simultaneously",
+ float64(config.MaxConcurrent),
+ true,
+ )
+
+ form.AddDurationField(
+ "min_interval",
+ "Minimum Interval",
+ "Minimum time between vacuum operations on the same volume",
+ secondsToDuration(config.MinIntervalSeconds),
+ true,
+ )
+
+ // Generate organized form sections using Bootstrap components
+ html := `
+<div class="row">
+ <div class="col-12">
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">
+ <i class="fas fa-search me-2"></i>
+ Detection Settings
+ </h5>
+ </div>
+ <div class="card-body">
+` + string(form.Build()) + `
+ </div>
+ </div>
+ </div>
+</div>
+
+<script>
+function resetForm() {
+ if (confirm('Reset all vacuum settings to defaults?')) {
+ // Reset to default values
+ document.querySelector('input[name="enabled"]').checked = true;
+ document.querySelector('input[name="garbage_threshold"]').value = '0.3';
+ document.querySelector('input[name="scan_interval"]').value = '30m';
+ document.querySelector('input[name="min_volume_age"]').value = '1h';
+ document.querySelector('input[name="max_concurrent"]').value = '2';
+ document.querySelector('input[name="min_interval"]').value = '6h';
+ }
+}
+</script>
+`
+
+ return template.HTML(html), nil
+}
+
+// ParseConfigForm parses form data into configuration
+func (ui *UIProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
+ config := &VacuumConfig{}
+
+ // Parse enabled checkbox
+ config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
+
+ // Parse garbage threshold
+ if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 {
+ if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid garbage threshold: %v", err)
+ } else if threshold < 0 || threshold > 1 {
+ return nil, fmt.Errorf("garbage threshold must be between 0.0 and 1.0")
+ } else {
+ config.GarbageThreshold = threshold
+ }
+ }
+
+ // Parse scan interval
+ if intervalStr := formData["scan_interval"]; len(intervalStr) > 0 {
+ if interval, err := time.ParseDuration(intervalStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid scan interval: %v", err)
+ } else {
+ config.ScanIntervalSeconds = durationToSeconds(interval)
+ }
+ }
+
+ // Parse min volume age
+ if ageStr := formData["min_volume_age"]; len(ageStr) > 0 {
+ if age, err := time.ParseDuration(ageStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid min volume age: %v", err)
+ } else {
+ config.MinVolumeAgeSeconds = durationToSeconds(age)
+ }
+ }
+
+ // Parse max concurrent
+ if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
+ if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid max concurrent: %v", err)
+ } else if concurrent < 1 {
+ return nil, fmt.Errorf("max concurrent must be at least 1")
+ } else {
+ config.MaxConcurrent = concurrent
+ }
+ }
+
+ // Parse min interval
+ if intervalStr := formData["min_interval"]; len(intervalStr) > 0 {
+ if interval, err := time.ParseDuration(intervalStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid min interval: %v", err)
+ } else {
+ config.MinIntervalSeconds = durationToSeconds(interval)
+ }
+ }
+
+ return config, nil
+}
+
+// GetCurrentConfig returns the current configuration
+func (ui *UIProvider) GetCurrentConfig() interface{} {
+ return ui.getCurrentVacuumConfig()
+}
+
+// ApplyConfig applies the new configuration
+func (ui *UIProvider) ApplyConfig(config interface{}) error {
+ vacuumConfig, ok := config.(*VacuumConfig)
+ if !ok {
+ return fmt.Errorf("invalid config type, expected *VacuumConfig")
+ }
+
+ // Apply to detector
+ if ui.detector != nil {
+ ui.detector.SetEnabled(vacuumConfig.Enabled)
+ ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold)
+ ui.detector.SetScanInterval(secondsToDuration(vacuumConfig.ScanIntervalSeconds))
+ ui.detector.SetMinVolumeAge(secondsToDuration(vacuumConfig.MinVolumeAgeSeconds))
+ }
+
+ // Apply to scheduler
+ if ui.scheduler != nil {
+ ui.scheduler.SetEnabled(vacuumConfig.Enabled)
+ ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent)
+ ui.scheduler.SetMinInterval(secondsToDuration(vacuumConfig.MinIntervalSeconds))
+ }
+
+ glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d",
+ vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationForUser(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent)
+
+ return nil
+}
+
+// getCurrentVacuumConfig gets the current configuration from detector and scheduler
+func (ui *UIProvider) getCurrentVacuumConfig() *VacuumConfig {
+ config := &VacuumConfig{
+ // Default values (fallback if detectors/schedulers are nil)
+ Enabled: true,
+ GarbageThreshold: 0.3,
+ ScanIntervalSeconds: 30 * 60,
+ MinVolumeAgeSeconds: 1 * 60 * 60,
+ MaxConcurrent: 2,
+ MinIntervalSeconds: 6 * 60 * 60,
+ }
+
+ // Get current values from detector
+ if ui.detector != nil {
+ config.Enabled = ui.detector.IsEnabled()
+ config.GarbageThreshold = ui.detector.GetGarbageThreshold()
+ config.ScanIntervalSeconds = durationToSeconds(ui.detector.ScanInterval())
+ config.MinVolumeAgeSeconds = durationToSeconds(ui.detector.GetMinVolumeAge())
+ }
+
+ // Get current values from scheduler
+ if ui.scheduler != nil {
+ config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
+ config.MinIntervalSeconds = durationToSeconds(ui.scheduler.GetMinInterval())
+ }
+
+ return config
+}
+
+// RegisterUI registers the vacuum UI provider with the UI registry
+func RegisterUI(uiRegistry *types.UIRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) {
+ uiProvider := NewUIProvider(detector, scheduler)
+ uiRegistry.RegisterUI(uiProvider)
+
+ glog.V(1).Infof("✅ Registered vacuum task UI provider")
+}
+
+// Example: How to get the UI provider for external use
+func GetUIProvider(uiRegistry *types.UIRegistry) *UIProvider {
+ provider := uiRegistry.GetProvider(types.TaskTypeVacuum)
+ if provider == nil {
+ return nil
+ }
+
+ if vacuumProvider, ok := provider.(*UIProvider); ok {
+ return vacuumProvider
+ }
+
+ return nil
+}
diff --git a/weed/worker/tasks/vacuum/ui_templ.go b/weed/worker/tasks/vacuum/ui_templ.go
new file mode 100644
index 000000000..15558b832
--- /dev/null
+++ b/weed/worker/tasks/vacuum/ui_templ.go
@@ -0,0 +1,330 @@
+package vacuum
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Helper function to format seconds as duration string
+func formatDurationFromSeconds(seconds int) string {
+ d := time.Duration(seconds) * time.Second
+ return d.String()
+}
+
+// Helper functions to convert between seconds and value+unit format
+func secondsToValueAndUnit(seconds int) (float64, string) {
+ if seconds == 0 {
+ return 0, "minutes"
+ }
+
+ // Try days first
+ if seconds%(24*3600) == 0 && seconds >= 24*3600 {
+ return float64(seconds / (24 * 3600)), "days"
+ }
+
+ // Try hours
+ if seconds%3600 == 0 && seconds >= 3600 {
+ return float64(seconds / 3600), "hours"
+ }
+
+ // Default to minutes
+ return float64(seconds / 60), "minutes"
+}
+
+func valueAndUnitToSeconds(value float64, unit string) int {
+ switch unit {
+ case "days":
+ return int(value * 24 * 3600)
+ case "hours":
+ return int(value * 3600)
+ case "minutes":
+ return int(value * 60)
+ default:
+ return int(value * 60) // Default to minutes
+ }
+}
+
+// UITemplProvider provides the templ-based UI for vacuum task configuration
+type UITemplProvider struct {
+ detector *VacuumDetector
+ scheduler *VacuumScheduler
+}
+
+// NewUITemplProvider creates a new vacuum templ UI provider
+func NewUITemplProvider(detector *VacuumDetector, scheduler *VacuumScheduler) *UITemplProvider {
+ return &UITemplProvider{
+ detector: detector,
+ scheduler: scheduler,
+ }
+}
+
+// GetTaskType returns the task type
+func (ui *UITemplProvider) GetTaskType() types.TaskType {
+ return types.TaskTypeVacuum
+}
+
+// GetDisplayName returns the human-readable name
+func (ui *UITemplProvider) GetDisplayName() string {
+ return "Volume Vacuum"
+}
+
+// GetDescription returns a description of what this task does
+func (ui *UITemplProvider) GetDescription() string {
+ return "Reclaims disk space by removing deleted files from volumes"
+}
+
+// GetIcon returns the icon CSS class for this task type
+func (ui *UITemplProvider) GetIcon() string {
+ return "fas fa-broom text-primary"
+}
+
+// RenderConfigSections renders the configuration as templ section data
+func (ui *UITemplProvider) RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error) {
+ config := ui.getCurrentVacuumConfig()
+
+ // Detection settings section
+ detectionSection := components.ConfigSectionData{
+ Title: "Detection Settings",
+ Icon: "fas fa-search",
+ Description: "Configure when vacuum tasks should be triggered",
+ Fields: []interface{}{
+ components.CheckboxFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "enabled",
+ Label: "Enable Vacuum Tasks",
+ Description: "Whether vacuum tasks should be automatically created",
+ },
+ Checked: config.Enabled,
+ },
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "garbage_threshold",
+ Label: "Garbage Threshold",
+ Description: "Trigger vacuum when garbage ratio exceeds this percentage (0.0-1.0)",
+ Required: true,
+ },
+ Value: config.GarbageThreshold,
+ Step: "0.01",
+ Min: floatPtr(0.0),
+ Max: floatPtr(1.0),
+ },
+ components.DurationInputFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "scan_interval",
+ Label: "Scan Interval",
+ Description: "How often to scan for volumes needing vacuum",
+ Required: true,
+ },
+ Seconds: config.ScanIntervalSeconds,
+ },
+ components.DurationInputFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "min_volume_age",
+ Label: "Minimum Volume Age",
+ Description: "Only vacuum volumes older than this duration",
+ Required: true,
+ },
+ Seconds: config.MinVolumeAgeSeconds,
+ },
+ },
+ }
+
+ // Scheduling settings section
+ schedulingSection := components.ConfigSectionData{
+ Title: "Scheduling Settings",
+ Icon: "fas fa-clock",
+ Description: "Configure task scheduling and concurrency",
+ Fields: []interface{}{
+ components.NumberFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "max_concurrent",
+ Label: "Max Concurrent Tasks",
+ Description: "Maximum number of vacuum tasks that can run simultaneously",
+ Required: true,
+ },
+ Value: float64(config.MaxConcurrent),
+ Step: "1",
+ Min: floatPtr(1),
+ },
+ components.DurationInputFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "min_interval",
+ Label: "Minimum Interval",
+ Description: "Minimum time between vacuum operations on the same volume",
+ Required: true,
+ },
+ Seconds: config.MinIntervalSeconds,
+ },
+ },
+ }
+
+ // Performance impact info section
+ performanceSection := components.ConfigSectionData{
+ Title: "Performance Impact",
+ Icon: "fas fa-exclamation-triangle",
+ Description: "Important information about vacuum operations",
+ Fields: []interface{}{
+ components.TextFieldData{
+ FormFieldData: components.FormFieldData{
+ Name: "info_impact",
+ Label: "Impact",
+ Description: "Volume vacuum operations are I/O intensive and should be scheduled appropriately",
+ },
+ Value: "Configure thresholds and intervals based on your storage usage patterns",
+ },
+ },
+ }
+
+ return []components.ConfigSectionData{detectionSection, schedulingSection, performanceSection}, nil
+}
+
+// ParseConfigForm parses form data into configuration
+func (ui *UITemplProvider) ParseConfigForm(formData map[string][]string) (interface{}, error) {
+ config := &VacuumConfig{}
+
+ // Parse enabled checkbox
+ config.Enabled = len(formData["enabled"]) > 0 && formData["enabled"][0] == "on"
+
+ // Parse garbage threshold
+ if thresholdStr := formData["garbage_threshold"]; len(thresholdStr) > 0 {
+ if threshold, err := strconv.ParseFloat(thresholdStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid garbage threshold: %v", err)
+ } else if threshold < 0 || threshold > 1 {
+ return nil, fmt.Errorf("garbage threshold must be between 0.0 and 1.0")
+ } else {
+ config.GarbageThreshold = threshold
+ }
+ }
+
+ // Parse scan interval
+ if valueStr := formData["scan_interval"]; len(valueStr) > 0 {
+ if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid scan interval value: %v", err)
+ } else {
+ unit := "minutes" // default
+ if unitStr := formData["scan_interval_unit"]; len(unitStr) > 0 {
+ unit = unitStr[0]
+ }
+ config.ScanIntervalSeconds = valueAndUnitToSeconds(value, unit)
+ }
+ }
+
+ // Parse min volume age
+ if valueStr := formData["min_volume_age"]; len(valueStr) > 0 {
+ if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid min volume age value: %v", err)
+ } else {
+ unit := "minutes" // default
+ if unitStr := formData["min_volume_age_unit"]; len(unitStr) > 0 {
+ unit = unitStr[0]
+ }
+ config.MinVolumeAgeSeconds = valueAndUnitToSeconds(value, unit)
+ }
+ }
+
+ // Parse max concurrent
+ if concurrentStr := formData["max_concurrent"]; len(concurrentStr) > 0 {
+ if concurrent, err := strconv.Atoi(concurrentStr[0]); err != nil {
+ return nil, fmt.Errorf("invalid max concurrent: %v", err)
+ } else if concurrent < 1 {
+ return nil, fmt.Errorf("max concurrent must be at least 1")
+ } else {
+ config.MaxConcurrent = concurrent
+ }
+ }
+
+ // Parse min interval
+ if valueStr := formData["min_interval"]; len(valueStr) > 0 {
+ if value, err := strconv.ParseFloat(valueStr[0], 64); err != nil {
+ return nil, fmt.Errorf("invalid min interval value: %v", err)
+ } else {
+ unit := "minutes" // default
+ if unitStr := formData["min_interval_unit"]; len(unitStr) > 0 {
+ unit = unitStr[0]
+ }
+ config.MinIntervalSeconds = valueAndUnitToSeconds(value, unit)
+ }
+ }
+
+ return config, nil
+}
+
+// GetCurrentConfig returns the current configuration
+func (ui *UITemplProvider) GetCurrentConfig() interface{} {
+ return ui.getCurrentVacuumConfig()
+}
+
+// ApplyConfig applies the new configuration
+func (ui *UITemplProvider) ApplyConfig(config interface{}) error {
+ vacuumConfig, ok := config.(*VacuumConfig)
+ if !ok {
+ return fmt.Errorf("invalid config type, expected *VacuumConfig")
+ }
+
+ // Apply to detector
+ if ui.detector != nil {
+ ui.detector.SetEnabled(vacuumConfig.Enabled)
+ ui.detector.SetGarbageThreshold(vacuumConfig.GarbageThreshold)
+ ui.detector.SetScanInterval(time.Duration(vacuumConfig.ScanIntervalSeconds) * time.Second)
+ ui.detector.SetMinVolumeAge(time.Duration(vacuumConfig.MinVolumeAgeSeconds) * time.Second)
+ }
+
+ // Apply to scheduler
+ if ui.scheduler != nil {
+ ui.scheduler.SetEnabled(vacuumConfig.Enabled)
+ ui.scheduler.SetMaxConcurrent(vacuumConfig.MaxConcurrent)
+ ui.scheduler.SetMinInterval(time.Duration(vacuumConfig.MinIntervalSeconds) * time.Second)
+ }
+
+ glog.V(1).Infof("Applied vacuum configuration: enabled=%v, threshold=%.1f%%, scan_interval=%s, max_concurrent=%d",
+ vacuumConfig.Enabled, vacuumConfig.GarbageThreshold*100, formatDurationFromSeconds(vacuumConfig.ScanIntervalSeconds), vacuumConfig.MaxConcurrent)
+
+ return nil
+}
+
+// getCurrentVacuumConfig gets the current configuration from detector and scheduler
+func (ui *UITemplProvider) getCurrentVacuumConfig() *VacuumConfig {
+ config := &VacuumConfig{
+ // Default values (fallback if detectors/schedulers are nil)
+ Enabled: true,
+ GarbageThreshold: 0.3,
+ ScanIntervalSeconds: int((30 * time.Minute).Seconds()),
+ MinVolumeAgeSeconds: int((1 * time.Hour).Seconds()),
+ MaxConcurrent: 2,
+ MinIntervalSeconds: int((6 * time.Hour).Seconds()),
+ }
+
+ // Get current values from detector
+ if ui.detector != nil {
+ config.Enabled = ui.detector.IsEnabled()
+ config.GarbageThreshold = ui.detector.GetGarbageThreshold()
+ config.ScanIntervalSeconds = int(ui.detector.ScanInterval().Seconds())
+ config.MinVolumeAgeSeconds = int(ui.detector.GetMinVolumeAge().Seconds())
+ }
+
+ // Get current values from scheduler
+ if ui.scheduler != nil {
+ config.MaxConcurrent = ui.scheduler.GetMaxConcurrent()
+ config.MinIntervalSeconds = int(ui.scheduler.GetMinInterval().Seconds())
+ }
+
+ return config
+}
+
+// floatPtr is a helper function to create float64 pointers
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+// RegisterUITempl registers the vacuum templ UI provider with the UI registry
+func RegisterUITempl(uiRegistry *types.UITemplRegistry, detector *VacuumDetector, scheduler *VacuumScheduler) {
+ uiProvider := NewUITemplProvider(detector, scheduler)
+ uiRegistry.RegisterUI(uiProvider)
+
+ glog.V(1).Infof("✅ Registered vacuum task templ UI provider")
+}
diff --git a/weed/worker/tasks/vacuum/vacuum.go b/weed/worker/tasks/vacuum/vacuum.go
new file mode 100644
index 000000000..dbfe35cf8
--- /dev/null
+++ b/weed/worker/tasks/vacuum/vacuum.go
@@ -0,0 +1,79 @@
+package vacuum
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Task implements vacuum operation to reclaim disk space
+type Task struct {
+ *tasks.BaseTask
+ server string
+ volumeID uint32
+}
+
+// NewTask creates a new vacuum task instance
+func NewTask(server string, volumeID uint32) *Task {
+ task := &Task{
+ BaseTask: tasks.NewBaseTask(types.TaskTypeVacuum),
+ server: server,
+ volumeID: volumeID,
+ }
+ return task
+}
+
+// Execute executes the vacuum task
+func (t *Task) Execute(params types.TaskParams) error {
+ glog.Infof("Starting vacuum task for volume %d on server %s", t.volumeID, t.server)
+
+ // Simulate vacuum operation with progress updates
+ steps := []struct {
+ name string
+ duration time.Duration
+ progress float64
+ }{
+ {"Scanning volume", 1 * time.Second, 20},
+ {"Identifying deleted files", 2 * time.Second, 50},
+ {"Compacting data", 3 * time.Second, 80},
+ {"Finalizing vacuum", 1 * time.Second, 100},
+ }
+
+ for _, step := range steps {
+ if t.IsCancelled() {
+ return fmt.Errorf("vacuum task cancelled")
+ }
+
+ glog.V(1).Infof("Vacuum task step: %s", step.name)
+ t.SetProgress(step.progress)
+
+ // Simulate work
+ time.Sleep(step.duration)
+ }
+
+ glog.Infof("Vacuum task completed for volume %d on server %s", t.volumeID, t.server)
+ return nil
+}
+
+// Validate validates the task parameters
+func (t *Task) Validate(params types.TaskParams) error {
+ if params.VolumeID == 0 {
+ return fmt.Errorf("volume_id is required")
+ }
+ if params.Server == "" {
+ return fmt.Errorf("server is required")
+ }
+ return nil
+}
+
+// EstimateTime estimates the time needed for the task
+func (t *Task) EstimateTime(params types.TaskParams) time.Duration {
+ // Base time for vacuum operation
+ baseTime := 25 * time.Second
+
+ // Could adjust based on volume size or usage patterns
+ return baseTime
+}
diff --git a/weed/worker/tasks/vacuum/vacuum_detector.go b/weed/worker/tasks/vacuum/vacuum_detector.go
new file mode 100644
index 000000000..6d7230c6c
--- /dev/null
+++ b/weed/worker/tasks/vacuum/vacuum_detector.go
@@ -0,0 +1,132 @@
+package vacuum
+
+import (
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// VacuumDetector implements vacuum task detection using code instead of schemas
+type VacuumDetector struct {
+ enabled bool
+ garbageThreshold float64
+ minVolumeAge time.Duration
+ scanInterval time.Duration
+}
+
+// Compile-time interface assertions
+var (
+ _ types.TaskDetector = (*VacuumDetector)(nil)
+ _ types.PolicyConfigurableDetector = (*VacuumDetector)(nil)
+)
+
+// NewVacuumDetector creates a new simple vacuum detector
+func NewVacuumDetector() *VacuumDetector {
+ return &VacuumDetector{
+ enabled: true,
+ garbageThreshold: 0.3,
+ minVolumeAge: 24 * time.Hour,
+ scanInterval: 30 * time.Minute,
+ }
+}
+
+// GetTaskType returns the task type
+func (d *VacuumDetector) GetTaskType() types.TaskType {
+ return types.TaskTypeVacuum
+}
+
+// ScanForTasks scans for volumes that need vacuum operations
+func (d *VacuumDetector) ScanForTasks(volumeMetrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterInfo) ([]*types.TaskDetectionResult, error) {
+ if !d.enabled {
+ return nil, nil
+ }
+
+ var results []*types.TaskDetectionResult
+
+ for _, metric := range volumeMetrics {
+ // Check if volume needs vacuum
+ if metric.GarbageRatio >= d.garbageThreshold && metric.Age >= d.minVolumeAge {
+ // Higher priority for volumes with more garbage
+ priority := types.TaskPriorityNormal
+ if metric.GarbageRatio > 0.6 {
+ priority = types.TaskPriorityHigh
+ }
+
+ result := &types.TaskDetectionResult{
+ TaskType: types.TaskTypeVacuum,
+ VolumeID: metric.VolumeID,
+ Server: metric.Server,
+ Collection: metric.Collection,
+ Priority: priority,
+ Reason: "Volume has excessive garbage requiring vacuum",
+ Parameters: map[string]interface{}{
+ "garbage_ratio": metric.GarbageRatio,
+ "volume_age": metric.Age.String(),
+ },
+ ScheduleAt: time.Now(),
+ }
+ results = append(results, result)
+ }
+ }
+
+ glog.V(2).Infof("Vacuum detector found %d volumes needing vacuum", len(results))
+ return results, nil
+}
+
+// ScanInterval returns how often this detector should scan
+func (d *VacuumDetector) ScanInterval() time.Duration {
+ return d.scanInterval
+}
+
+// IsEnabled returns whether this detector is enabled
+func (d *VacuumDetector) IsEnabled() bool {
+ return d.enabled
+}
+
+// Configuration setters
+
+func (d *VacuumDetector) SetEnabled(enabled bool) {
+ d.enabled = enabled
+}
+
+func (d *VacuumDetector) SetGarbageThreshold(threshold float64) {
+ d.garbageThreshold = threshold
+}
+
+func (d *VacuumDetector) SetScanInterval(interval time.Duration) {
+ d.scanInterval = interval
+}
+
+func (d *VacuumDetector) SetMinVolumeAge(age time.Duration) {
+ d.minVolumeAge = age
+}
+
+// GetGarbageThreshold returns the current garbage threshold
+func (d *VacuumDetector) GetGarbageThreshold() float64 {
+ return d.garbageThreshold
+}
+
+// GetMinVolumeAge returns the minimum volume age
+func (d *VacuumDetector) GetMinVolumeAge() time.Duration {
+ return d.minVolumeAge
+}
+
+// GetScanInterval returns the scan interval
+func (d *VacuumDetector) GetScanInterval() time.Duration {
+ return d.scanInterval
+}
+
+// ConfigureFromPolicy configures the detector based on the maintenance policy
+func (d *VacuumDetector) ConfigureFromPolicy(policy interface{}) {
+ // Type assert to the maintenance policy type we expect
+ if maintenancePolicy, ok := policy.(interface {
+ GetVacuumEnabled() bool
+ GetVacuumGarbageRatio() float64
+ }); ok {
+ d.SetEnabled(maintenancePolicy.GetVacuumEnabled())
+ d.SetGarbageThreshold(maintenancePolicy.GetVacuumGarbageRatio())
+ } else {
+ glog.V(1).Infof("Could not configure vacuum detector from policy: unsupported policy type")
+ }
+}
diff --git a/weed/worker/tasks/vacuum/vacuum_register.go b/weed/worker/tasks/vacuum/vacuum_register.go
new file mode 100644
index 000000000..7d930a88e
--- /dev/null
+++ b/weed/worker/tasks/vacuum/vacuum_register.go
@@ -0,0 +1,81 @@
+package vacuum
+
+import (
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// Factory creates vacuum task instances
+type Factory struct {
+ *tasks.BaseTaskFactory
+}
+
+// NewFactory creates a new vacuum task factory
+func NewFactory() *Factory {
+ return &Factory{
+ BaseTaskFactory: tasks.NewBaseTaskFactory(
+ types.TaskTypeVacuum,
+ []string{"vacuum", "storage"},
+ "Vacuum operation to reclaim disk space by removing deleted files",
+ ),
+ }
+}
+
+// Create creates a new vacuum task instance
+func (f *Factory) Create(params types.TaskParams) (types.TaskInterface, error) {
+ // Validate parameters
+ if params.VolumeID == 0 {
+ return nil, fmt.Errorf("volume_id is required")
+ }
+ if params.Server == "" {
+ return nil, fmt.Errorf("server is required")
+ }
+
+ task := NewTask(params.Server, params.VolumeID)
+ task.SetEstimatedDuration(task.EstimateTime(params))
+
+ return task, nil
+}
+
+// Shared detector and scheduler instances
+var (
+ sharedDetector *VacuumDetector
+ sharedScheduler *VacuumScheduler
+)
+
+// getSharedInstances returns the shared detector and scheduler instances
+func getSharedInstances() (*VacuumDetector, *VacuumScheduler) {
+ if sharedDetector == nil {
+ sharedDetector = NewVacuumDetector()
+ }
+ if sharedScheduler == nil {
+ sharedScheduler = NewVacuumScheduler()
+ }
+ return sharedDetector, sharedScheduler
+}
+
+// GetSharedInstances returns the shared detector and scheduler instances (public access)
+func GetSharedInstances() (*VacuumDetector, *VacuumScheduler) {
+ return getSharedInstances()
+}
+
+// Auto-register this task when the package is imported
+func init() {
+ factory := NewFactory()
+ tasks.AutoRegister(types.TaskTypeVacuum, factory)
+
+ // Get shared instances for all registrations
+ detector, scheduler := getSharedInstances()
+
+ // Register with types registry
+ tasks.AutoRegisterTypes(func(registry *types.TaskRegistry) {
+ registry.RegisterTask(detector, scheduler)
+ })
+
+ // Register with UI registry using the same instances
+ tasks.AutoRegisterUI(func(uiRegistry *types.UIRegistry) {
+ RegisterUI(uiRegistry, detector, scheduler)
+ })
+}
diff --git a/weed/worker/tasks/vacuum/vacuum_scheduler.go b/weed/worker/tasks/vacuum/vacuum_scheduler.go
new file mode 100644
index 000000000..2b67a9f40
--- /dev/null
+++ b/weed/worker/tasks/vacuum/vacuum_scheduler.go
@@ -0,0 +1,111 @@
+package vacuum
+
+import (
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+)
+
+// VacuumScheduler implements vacuum task scheduling using code instead of schemas
+type VacuumScheduler struct {
+ enabled bool
+ maxConcurrent int
+ minInterval time.Duration
+}
+
+// Compile-time interface assertions
+var (
+ _ types.TaskScheduler = (*VacuumScheduler)(nil)
+)
+
+// NewVacuumScheduler creates a new simple vacuum scheduler
+func NewVacuumScheduler() *VacuumScheduler {
+ return &VacuumScheduler{
+ enabled: true,
+ maxConcurrent: 2,
+ minInterval: 6 * time.Hour,
+ }
+}
+
+// GetTaskType returns the task type
+func (s *VacuumScheduler) GetTaskType() types.TaskType {
+ return types.TaskTypeVacuum
+}
+
+// CanScheduleNow determines if a vacuum task can be scheduled right now
+func (s *VacuumScheduler) CanScheduleNow(task *types.Task, runningTasks []*types.Task, availableWorkers []*types.Worker) bool {
+ // Check if scheduler is enabled
+ if !s.enabled {
+ return false
+ }
+
+ // Check concurrent limit
+ runningVacuumCount := 0
+ for _, runningTask := range runningTasks {
+ if runningTask.Type == types.TaskTypeVacuum {
+ runningVacuumCount++
+ }
+ }
+
+ if runningVacuumCount >= s.maxConcurrent {
+ return false
+ }
+
+ // Check if there's an available worker with vacuum capability
+ for _, worker := range availableWorkers {
+ if worker.CurrentLoad < worker.MaxConcurrent {
+ for _, capability := range worker.Capabilities {
+ if capability == types.TaskTypeVacuum {
+ return true
+ }
+ }
+ }
+ }
+
+ return false
+}
+
+// GetPriority returns the priority for this task
+func (s *VacuumScheduler) GetPriority(task *types.Task) types.TaskPriority {
+ // Could adjust priority based on task parameters
+ if params, ok := task.Parameters["garbage_ratio"].(float64); ok {
+ if params > 0.8 {
+ return types.TaskPriorityHigh
+ }
+ }
+ return task.Priority
+}
+
+// GetMaxConcurrent returns max concurrent tasks of this type
+func (s *VacuumScheduler) GetMaxConcurrent() int {
+ return s.maxConcurrent
+}
+
+// GetDefaultRepeatInterval returns the default interval to wait before repeating vacuum tasks
+func (s *VacuumScheduler) GetDefaultRepeatInterval() time.Duration {
+ return s.minInterval
+}
+
+// IsEnabled returns whether this scheduler is enabled
+func (s *VacuumScheduler) IsEnabled() bool {
+ return s.enabled
+}
+
+// Configuration setters
+
+func (s *VacuumScheduler) SetEnabled(enabled bool) {
+ s.enabled = enabled
+}
+
+func (s *VacuumScheduler) SetMaxConcurrent(max int) {
+ s.maxConcurrent = max
+}
+
+func (s *VacuumScheduler) SetMinInterval(interval time.Duration) {
+ s.minInterval = interval
+}
+
+// GetMinInterval returns the minimum interval
+func (s *VacuumScheduler) GetMinInterval() time.Duration {
+ return s.minInterval
+}
diff --git a/weed/worker/types/config_types.go b/weed/worker/types/config_types.go
new file mode 100644
index 000000000..8e4113580
--- /dev/null
+++ b/weed/worker/types/config_types.go
@@ -0,0 +1,268 @@
+package types
+
+import (
+ "sync"
+ "time"
+)
+
+// WorkerConfig represents the configuration for a worker
+type WorkerConfig struct {
+ AdminServer string `json:"admin_server"`
+ Capabilities []TaskType `json:"capabilities"`
+ MaxConcurrent int `json:"max_concurrent"`
+ HeartbeatInterval time.Duration `json:"heartbeat_interval"`
+ TaskRequestInterval time.Duration `json:"task_request_interval"`
+ CustomParameters map[string]interface{} `json:"custom_parameters,omitempty"`
+}
+
+// MaintenanceConfig represents the configuration for the maintenance system
+type MaintenanceConfig struct {
+ Enabled bool `json:"enabled"`
+ ScanInterval time.Duration `json:"scan_interval"`
+ CleanInterval time.Duration `json:"clean_interval"`
+ TaskRetention time.Duration `json:"task_retention"`
+ WorkerTimeout time.Duration `json:"worker_timeout"`
+ Policy *MaintenancePolicy `json:"policy"`
+}
+
+// MaintenancePolicy represents policies for maintenance operations
+// This is now dynamic - task configurations are stored by task type
+type MaintenancePolicy struct {
+ // Task-specific configurations indexed by task type
+ TaskConfigs map[TaskType]interface{} `json:"task_configs"`
+
+ // Global maintenance settings
+ GlobalSettings *GlobalMaintenanceSettings `json:"global_settings"`
+}
+
+// GlobalMaintenanceSettings contains settings that apply to all tasks
+type GlobalMaintenanceSettings struct {
+ DefaultMaxConcurrent int `json:"default_max_concurrent"`
+ MaintenanceEnabled bool `json:"maintenance_enabled"`
+
+ // Global timing settings
+ DefaultScanInterval time.Duration `json:"default_scan_interval"`
+ DefaultTaskTimeout time.Duration `json:"default_task_timeout"`
+ DefaultRetryCount int `json:"default_retry_count"`
+ DefaultRetryInterval time.Duration `json:"default_retry_interval"`
+
+ // Global thresholds
+ DefaultPriorityBoostAge time.Duration `json:"default_priority_boost_age"`
+ GlobalConcurrentLimit int `json:"global_concurrent_limit"`
+}
+
+// MaintenanceStats represents statistics for the maintenance system
+type MaintenanceStats struct {
+ TotalTasks int `json:"total_tasks"`
+ CompletedToday int `json:"completed_today"`
+ FailedToday int `json:"failed_today"`
+ ActiveWorkers int `json:"active_workers"`
+ AverageTaskTime time.Duration `json:"average_task_time"`
+ TasksByStatus map[TaskStatus]int `json:"tasks_by_status"`
+ TasksByType map[TaskType]int `json:"tasks_by_type"`
+ LastScanTime time.Time `json:"last_scan_time"`
+ NextScanTime time.Time `json:"next_scan_time"`
+}
+
+// QueueStats represents statistics for the task queue
+type QueueStats struct {
+ PendingTasks int `json:"pending_tasks"`
+ AssignedTasks int `json:"assigned_tasks"`
+ InProgressTasks int `json:"in_progress_tasks"`
+ CompletedTasks int `json:"completed_tasks"`
+ FailedTasks int `json:"failed_tasks"`
+ CancelledTasks int `json:"cancelled_tasks"`
+ ActiveWorkers int `json:"active_workers"`
+}
+
+// MaintenanceConfigData represents the complete maintenance configuration data
+type MaintenanceConfigData struct {
+ Config *MaintenanceConfig `json:"config"`
+ IsEnabled bool `json:"is_enabled"`
+ LastScanTime time.Time `json:"last_scan_time"`
+ NextScanTime time.Time `json:"next_scan_time"`
+ SystemStats *MaintenanceStats `json:"system_stats"`
+}
+
+// MaintenanceQueueData represents data for the maintenance queue UI
+type MaintenanceQueueData struct {
+ Tasks []*Task `json:"tasks"`
+ Workers []*Worker `json:"workers"`
+ Stats *QueueStats `json:"stats"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// MaintenanceWorkersData represents data for the maintenance workers UI
+type MaintenanceWorkersData struct {
+ Workers []*WorkerDetailsData `json:"workers"`
+ ActiveWorkers int `json:"active_workers"`
+ BusyWorkers int `json:"busy_workers"`
+ TotalLoad int `json:"total_load"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// defaultCapabilities holds the default capabilities for workers
+var defaultCapabilities []TaskType
+var defaultCapabilitiesMutex sync.RWMutex
+
+// SetDefaultCapabilities sets the default capabilities for workers
+// This should be called after task registration is complete
+func SetDefaultCapabilities(capabilities []TaskType) {
+ defaultCapabilitiesMutex.Lock()
+ defer defaultCapabilitiesMutex.Unlock()
+ defaultCapabilities = make([]TaskType, len(capabilities))
+ copy(defaultCapabilities, capabilities)
+}
+
+// GetDefaultCapabilities returns the default capabilities for workers
+func GetDefaultCapabilities() []TaskType {
+ defaultCapabilitiesMutex.RLock()
+ defer defaultCapabilitiesMutex.RUnlock()
+
+ // Return a copy to prevent modification
+ result := make([]TaskType, len(defaultCapabilities))
+ copy(result, defaultCapabilities)
+ return result
+}
+
+// DefaultMaintenanceConfig returns default maintenance configuration
+func DefaultMaintenanceConfig() *MaintenanceConfig {
+ return &MaintenanceConfig{
+ Enabled: true,
+ ScanInterval: 30 * time.Minute,
+ CleanInterval: 6 * time.Hour,
+ TaskRetention: 7 * 24 * time.Hour, // 7 days
+ WorkerTimeout: 5 * time.Minute,
+ Policy: NewMaintenancePolicy(),
+ }
+}
+
+// DefaultWorkerConfig returns default worker configuration
+func DefaultWorkerConfig() *WorkerConfig {
+ // Get dynamic capabilities from registered task types
+ capabilities := GetDefaultCapabilities()
+
+ return &WorkerConfig{
+ AdminServer: "localhost:9333",
+ MaxConcurrent: 2,
+ HeartbeatInterval: 30 * time.Second,
+ TaskRequestInterval: 5 * time.Second,
+ Capabilities: capabilities,
+ }
+}
+
+// NewMaintenancePolicy creates a new dynamic maintenance policy
+func NewMaintenancePolicy() *MaintenancePolicy {
+ return &MaintenancePolicy{
+ TaskConfigs: make(map[TaskType]interface{}),
+ GlobalSettings: &GlobalMaintenanceSettings{
+ DefaultMaxConcurrent: 2,
+ MaintenanceEnabled: true,
+ DefaultScanInterval: 30 * time.Minute,
+ DefaultTaskTimeout: 5 * time.Minute,
+ DefaultRetryCount: 3,
+ DefaultRetryInterval: 5 * time.Minute,
+ DefaultPriorityBoostAge: 24 * time.Hour,
+ GlobalConcurrentLimit: 5,
+ },
+ }
+}
+
+// SetTaskConfig sets the configuration for a specific task type
+func (p *MaintenancePolicy) SetTaskConfig(taskType TaskType, config interface{}) {
+ if p.TaskConfigs == nil {
+ p.TaskConfigs = make(map[TaskType]interface{})
+ }
+ p.TaskConfigs[taskType] = config
+}
+
+// GetTaskConfig returns the configuration for a specific task type
+func (p *MaintenancePolicy) GetTaskConfig(taskType TaskType) interface{} {
+ if p.TaskConfigs == nil {
+ return nil
+ }
+ return p.TaskConfigs[taskType]
+}
+
+// IsTaskEnabled returns whether a task type is enabled (generic helper)
+func (p *MaintenancePolicy) IsTaskEnabled(taskType TaskType) bool {
+ if !p.GlobalSettings.MaintenanceEnabled {
+ return false
+ }
+
+ config := p.GetTaskConfig(taskType)
+ if config == nil {
+ return false
+ }
+
+ // Try to get enabled field from config using type assertion
+ if configMap, ok := config.(map[string]interface{}); ok {
+ if enabled, exists := configMap["enabled"]; exists {
+ if enabledBool, ok := enabled.(bool); ok {
+ return enabledBool
+ }
+ }
+ }
+
+ // If we can't determine from config, default to global setting
+ return p.GlobalSettings.MaintenanceEnabled
+}
+
+// GetMaxConcurrent returns the max concurrent setting for a task type
+func (p *MaintenancePolicy) GetMaxConcurrent(taskType TaskType) int {
+ config := p.GetTaskConfig(taskType)
+ if config == nil {
+ return p.GlobalSettings.DefaultMaxConcurrent
+ }
+
+ // Try to get max_concurrent field from config
+ if configMap, ok := config.(map[string]interface{}); ok {
+ if maxConcurrent, exists := configMap["max_concurrent"]; exists {
+ if maxConcurrentInt, ok := maxConcurrent.(int); ok {
+ return maxConcurrentInt
+ }
+ if maxConcurrentFloat, ok := maxConcurrent.(float64); ok {
+ return int(maxConcurrentFloat)
+ }
+ }
+ }
+
+ return p.GlobalSettings.DefaultMaxConcurrent
+}
+
+// GetScanInterval returns the scan interval for a task type
+func (p *MaintenancePolicy) GetScanInterval(taskType TaskType) time.Duration {
+ config := p.GetTaskConfig(taskType)
+ if config == nil {
+ return p.GlobalSettings.DefaultScanInterval
+ }
+
+ // Try to get scan_interval field from config
+ if configMap, ok := config.(map[string]interface{}); ok {
+ if scanInterval, exists := configMap["scan_interval"]; exists {
+ if scanIntervalDuration, ok := scanInterval.(time.Duration); ok {
+ return scanIntervalDuration
+ }
+ if scanIntervalString, ok := scanInterval.(string); ok {
+ if duration, err := time.ParseDuration(scanIntervalString); err == nil {
+ return duration
+ }
+ }
+ }
+ }
+
+ return p.GlobalSettings.DefaultScanInterval
+}
+
+// GetAllTaskTypes returns all configured task types
+func (p *MaintenancePolicy) GetAllTaskTypes() []TaskType {
+ if p.TaskConfigs == nil {
+ return []TaskType{}
+ }
+
+ taskTypes := make([]TaskType, 0, len(p.TaskConfigs))
+ for taskType := range p.TaskConfigs {
+ taskTypes = append(taskTypes, taskType)
+ }
+ return taskTypes
+}
diff --git a/weed/worker/types/data_types.go b/weed/worker/types/data_types.go
new file mode 100644
index 000000000..4a018563a
--- /dev/null
+++ b/weed/worker/types/data_types.go
@@ -0,0 +1,40 @@
+package types
+
+import (
+ "time"
+)
+
+// ClusterInfo contains cluster information for task detection
+type ClusterInfo struct {
+ Servers []*VolumeServerInfo
+ TotalVolumes int
+ TotalServers int
+ LastUpdated time.Time
+}
+
+// VolumeHealthMetrics contains health information about a volume (simplified)
+type VolumeHealthMetrics struct {
+ VolumeID uint32
+ Server string
+ Collection string
+ Size uint64
+ DeletedBytes uint64
+ GarbageRatio float64
+ LastModified time.Time
+ Age time.Duration
+ ReplicaCount int
+ ExpectedReplicas int
+ IsReadOnly bool
+ HasRemoteCopy bool
+ IsECVolume bool
+ FullnessRatio float64
+}
+
+// VolumeServerInfo contains information about a volume server (simplified)
+type VolumeServerInfo struct {
+ Address string
+ Volumes int
+ UsedSpace uint64
+ FreeSpace uint64
+ IsActive bool
+}
diff --git a/weed/worker/types/task_detector.go b/weed/worker/types/task_detector.go
new file mode 100644
index 000000000..95f168068
--- /dev/null
+++ b/weed/worker/types/task_detector.go
@@ -0,0 +1,28 @@
+package types
+
+import (
+ "time"
+)
+
+// TaskDetector defines the interface for task detection
+type TaskDetector interface {
+ // GetTaskType returns the task type this detector handles
+ GetTaskType() TaskType
+
+ // ScanForTasks scans for tasks that need to be executed
+ ScanForTasks(volumeMetrics []*VolumeHealthMetrics, clusterInfo *ClusterInfo) ([]*TaskDetectionResult, error)
+
+ // ScanInterval returns how often this detector should scan
+ ScanInterval() time.Duration
+
+ // IsEnabled returns whether this detector is enabled
+ IsEnabled() bool
+}
+
+// PolicyConfigurableDetector defines the interface for detectors that can be configured from policy
+type PolicyConfigurableDetector interface {
+ TaskDetector
+
+ // ConfigureFromPolicy configures the detector based on the maintenance policy
+ ConfigureFromPolicy(policy interface{})
+}
diff --git a/weed/worker/types/task_registry.go b/weed/worker/types/task_registry.go
new file mode 100644
index 000000000..a94cdddfb
--- /dev/null
+++ b/weed/worker/types/task_registry.go
@@ -0,0 +1,54 @@
+package types
+
+// TaskRegistry manages task detectors and schedulers
+type TaskRegistry struct {
+ detectors map[TaskType]TaskDetector
+ schedulers map[TaskType]TaskScheduler
+}
+
+// NewTaskRegistry creates a new simple task registry
+func NewTaskRegistry() *TaskRegistry {
+ return &TaskRegistry{
+ detectors: make(map[TaskType]TaskDetector),
+ schedulers: make(map[TaskType]TaskScheduler),
+ }
+}
+
+// RegisterTask registers both detector and scheduler for a task type
+func (r *TaskRegistry) RegisterTask(detector TaskDetector, scheduler TaskScheduler) {
+ taskType := detector.GetTaskType()
+ if taskType != scheduler.GetTaskType() {
+ panic("detector and scheduler task types must match")
+ }
+
+ r.detectors[taskType] = detector
+ r.schedulers[taskType] = scheduler
+}
+
+// GetDetector returns the detector for a task type
+func (r *TaskRegistry) GetDetector(taskType TaskType) TaskDetector {
+ return r.detectors[taskType]
+}
+
+// GetScheduler returns the scheduler for a task type
+func (r *TaskRegistry) GetScheduler(taskType TaskType) TaskScheduler {
+ return r.schedulers[taskType]
+}
+
+// GetAllDetectors returns all registered detectors
+func (r *TaskRegistry) GetAllDetectors() map[TaskType]TaskDetector {
+ result := make(map[TaskType]TaskDetector)
+ for k, v := range r.detectors {
+ result[k] = v
+ }
+ return result
+}
+
+// GetAllSchedulers returns all registered schedulers
+func (r *TaskRegistry) GetAllSchedulers() map[TaskType]TaskScheduler {
+ result := make(map[TaskType]TaskScheduler)
+ for k, v := range r.schedulers {
+ result[k] = v
+ }
+ return result
+}
diff --git a/weed/worker/types/task_scheduler.go b/weed/worker/types/task_scheduler.go
new file mode 100644
index 000000000..958bf892a
--- /dev/null
+++ b/weed/worker/types/task_scheduler.go
@@ -0,0 +1,32 @@
+package types
+
+import "time"
+
+// TaskScheduler defines the interface for task scheduling
+type TaskScheduler interface {
+ // GetTaskType returns the task type this scheduler handles
+ GetTaskType() TaskType
+
+ // CanScheduleNow determines if a task can be scheduled now
+ CanScheduleNow(task *Task, runningTasks []*Task, availableWorkers []*Worker) bool
+
+ // GetPriority returns the priority for tasks of this type
+ GetPriority(task *Task) TaskPriority
+
+ // GetMaxConcurrent returns the maximum concurrent tasks of this type
+ GetMaxConcurrent() int
+
+ // GetDefaultRepeatInterval returns the default interval to wait before repeating tasks of this type
+ GetDefaultRepeatInterval() time.Duration
+
+ // IsEnabled returns whether this scheduler is enabled
+ IsEnabled() bool
+}
+
+// PolicyConfigurableScheduler defines the interface for schedulers that can be configured from policy
+type PolicyConfigurableScheduler interface {
+ TaskScheduler
+
+ // ConfigureFromPolicy configures the scheduler based on the maintenance policy
+ ConfigureFromPolicy(policy interface{})
+}
diff --git a/weed/worker/types/task_types.go b/weed/worker/types/task_types.go
new file mode 100644
index 000000000..b0fdb009f
--- /dev/null
+++ b/weed/worker/types/task_types.go
@@ -0,0 +1,89 @@
+package types
+
+import (
+ "time"
+)
+
+// TaskType represents the type of maintenance task
+type TaskType string
+
+const (
+ TaskTypeVacuum TaskType = "vacuum"
+ TaskTypeErasureCoding TaskType = "erasure_coding"
+ TaskTypeBalance TaskType = "balance"
+)
+
+// TaskStatus represents the status of a maintenance task
+type TaskStatus string
+
+const (
+ TaskStatusPending TaskStatus = "pending"
+ TaskStatusAssigned TaskStatus = "assigned"
+ TaskStatusInProgress TaskStatus = "in_progress"
+ TaskStatusCompleted TaskStatus = "completed"
+ TaskStatusFailed TaskStatus = "failed"
+ TaskStatusCancelled TaskStatus = "cancelled"
+)
+
+// TaskPriority represents the priority of a maintenance task
+type TaskPriority int
+
+const (
+ TaskPriorityLow TaskPriority = 1
+ TaskPriorityNormal TaskPriority = 5
+ TaskPriorityHigh TaskPriority = 10
+)
+
+// Task represents a maintenance task
+type Task struct {
+ ID string `json:"id"`
+ Type TaskType `json:"type"`
+ Status TaskStatus `json:"status"`
+ Priority TaskPriority `json:"priority"`
+ VolumeID uint32 `json:"volume_id,omitempty"`
+ Server string `json:"server,omitempty"`
+ Collection string `json:"collection,omitempty"`
+ WorkerID string `json:"worker_id,omitempty"`
+ Progress float64 `json:"progress"`
+ Error string `json:"error,omitempty"`
+ Parameters map[string]interface{} `json:"parameters,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ ScheduledAt time.Time `json:"scheduled_at"`
+ StartedAt *time.Time `json:"started_at,omitempty"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+ RetryCount int `json:"retry_count"`
+ MaxRetries int `json:"max_retries"`
+}
+
+// TaskParams represents parameters for task execution
+type TaskParams struct {
+ VolumeID uint32 `json:"volume_id,omitempty"`
+ Server string `json:"server,omitempty"`
+ Collection string `json:"collection,omitempty"`
+ Parameters map[string]interface{} `json:"parameters,omitempty"`
+}
+
+// TaskDetectionResult represents the result of scanning for maintenance needs
+type TaskDetectionResult struct {
+ TaskType TaskType `json:"task_type"`
+ VolumeID uint32 `json:"volume_id,omitempty"`
+ Server string `json:"server,omitempty"`
+ Collection string `json:"collection,omitempty"`
+ Priority TaskPriority `json:"priority"`
+ Reason string `json:"reason"`
+ Parameters map[string]interface{} `json:"parameters,omitempty"`
+ ScheduleAt time.Time `json:"schedule_at"`
+}
+
+// ClusterReplicationTask represents a cluster replication task parameters
+type ClusterReplicationTask struct {
+ SourcePath string `json:"source_path"`
+ TargetCluster string `json:"target_cluster"`
+ TargetPath string `json:"target_path"`
+ ReplicationMode string `json:"replication_mode"` // "sync", "async", "backup"
+ Priority int `json:"priority"`
+ Checksum string `json:"checksum,omitempty"`
+ FileSize int64 `json:"file_size"`
+ CreatedAt time.Time `json:"created_at"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
diff --git a/weed/worker/types/task_ui.go b/weed/worker/types/task_ui.go
new file mode 100644
index 000000000..e1e2752ba
--- /dev/null
+++ b/weed/worker/types/task_ui.go
@@ -0,0 +1,281 @@
+package types
+
+import (
+ "fmt"
+ "html/template"
+ "time"
+)
+
+// TaskUIProvider defines how tasks provide their configuration UI
+type TaskUIProvider interface {
+ // GetTaskType returns the task type
+ GetTaskType() TaskType
+
+ // GetDisplayName returns the human-readable name
+ GetDisplayName() string
+
+ // GetDescription returns a description of what this task does
+ GetDescription() string
+
+ // GetIcon returns the icon CSS class or HTML for this task type
+ GetIcon() string
+
+ // RenderConfigForm renders the configuration form HTML
+ RenderConfigForm(currentConfig interface{}) (template.HTML, error)
+
+ // ParseConfigForm parses form data into configuration
+ ParseConfigForm(formData map[string][]string) (interface{}, error)
+
+ // GetCurrentConfig returns the current configuration
+ GetCurrentConfig() interface{}
+
+ // ApplyConfig applies the new configuration
+ ApplyConfig(config interface{}) error
+}
+
+// TaskStats represents runtime statistics for a task type
+type TaskStats struct {
+ TaskType TaskType `json:"task_type"`
+ DisplayName string `json:"display_name"`
+ Enabled bool `json:"enabled"`
+ LastScan time.Time `json:"last_scan"`
+ NextScan time.Time `json:"next_scan"`
+ PendingTasks int `json:"pending_tasks"`
+ RunningTasks int `json:"running_tasks"`
+ CompletedToday int `json:"completed_today"`
+ FailedToday int `json:"failed_today"`
+ MaxConcurrent int `json:"max_concurrent"`
+ ScanInterval time.Duration `json:"scan_interval"`
+}
+
+// UIRegistry manages task UI providers
+type UIRegistry struct {
+ providers map[TaskType]TaskUIProvider
+}
+
+// NewUIRegistry creates a new UI registry
+func NewUIRegistry() *UIRegistry {
+ return &UIRegistry{
+ providers: make(map[TaskType]TaskUIProvider),
+ }
+}
+
+// RegisterUI registers a task UI provider
+func (r *UIRegistry) RegisterUI(provider TaskUIProvider) {
+ r.providers[provider.GetTaskType()] = provider
+}
+
+// GetProvider returns the UI provider for a task type
+func (r *UIRegistry) GetProvider(taskType TaskType) TaskUIProvider {
+ return r.providers[taskType]
+}
+
+// GetAllProviders returns all registered UI providers
+func (r *UIRegistry) GetAllProviders() map[TaskType]TaskUIProvider {
+ result := make(map[TaskType]TaskUIProvider)
+ for k, v := range r.providers {
+ result[k] = v
+ }
+ return result
+}
+
+// Common UI data structures for shared components
+type TaskListData struct {
+ Tasks []*Task `json:"tasks"`
+ TaskStats []*TaskStats `json:"task_stats"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+type TaskDetailsData struct {
+ Task *Task `json:"task"`
+ TaskType TaskType `json:"task_type"`
+ DisplayName string `json:"display_name"`
+ Description string `json:"description"`
+ Stats *TaskStats `json:"stats"`
+ ConfigForm template.HTML `json:"config_form"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// Common form field types for simple form building
+type FormField struct {
+ Name string `json:"name"`
+ Label string `json:"label"`
+ Type string `json:"type"` // text, number, checkbox, select, duration
+ Value interface{} `json:"value"`
+ Description string `json:"description"`
+ Required bool `json:"required"`
+ Options []FormOption `json:"options,omitempty"` // For select fields
+}
+
+type FormOption struct {
+ Value string `json:"value"`
+ Label string `json:"label"`
+}
+
+// Helper for building forms in code
+type FormBuilder struct {
+ fields []FormField
+}
+
+// NewFormBuilder creates a new form builder
+func NewFormBuilder() *FormBuilder {
+ return &FormBuilder{
+ fields: make([]FormField, 0),
+ }
+}
+
+// AddTextField adds a text input field
+func (fb *FormBuilder) AddTextField(name, label, description string, value string, required bool) *FormBuilder {
+ fb.fields = append(fb.fields, FormField{
+ Name: name,
+ Label: label,
+ Type: "text",
+ Value: value,
+ Description: description,
+ Required: required,
+ })
+ return fb
+}
+
+// AddNumberField adds a number input field
+func (fb *FormBuilder) AddNumberField(name, label, description string, value float64, required bool) *FormBuilder {
+ fb.fields = append(fb.fields, FormField{
+ Name: name,
+ Label: label,
+ Type: "number",
+ Value: value,
+ Description: description,
+ Required: required,
+ })
+ return fb
+}
+
+// AddCheckboxField adds a checkbox field
+func (fb *FormBuilder) AddCheckboxField(name, label, description string, value bool) *FormBuilder {
+ fb.fields = append(fb.fields, FormField{
+ Name: name,
+ Label: label,
+ Type: "checkbox",
+ Value: value,
+ Description: description,
+ Required: false,
+ })
+ return fb
+}
+
+// AddSelectField adds a select dropdown field
+func (fb *FormBuilder) AddSelectField(name, label, description string, value string, options []FormOption, required bool) *FormBuilder {
+ fb.fields = append(fb.fields, FormField{
+ Name: name,
+ Label: label,
+ Type: "select",
+ Value: value,
+ Description: description,
+ Required: required,
+ Options: options,
+ })
+ return fb
+}
+
+// AddDurationField adds a duration input field
+func (fb *FormBuilder) AddDurationField(name, label, description string, value time.Duration, required bool) *FormBuilder {
+ fb.fields = append(fb.fields, FormField{
+ Name: name,
+ Label: label,
+ Type: "duration",
+ Value: value.String(),
+ Description: description,
+ Required: required,
+ })
+ return fb
+}
+
+// Build generates the HTML form fields with Bootstrap styling
+func (fb *FormBuilder) Build() template.HTML {
+ html := ""
+
+ for _, field := range fb.fields {
+ html += fb.renderField(field)
+ }
+
+ return template.HTML(html)
+}
+
+// renderField renders a single form field with Bootstrap classes
+func (fb *FormBuilder) renderField(field FormField) string {
+ html := "<div class=\"mb-3\">\n"
+
+ // Special handling for checkbox fields
+ if field.Type == "checkbox" {
+ checked := ""
+ if field.Value.(bool) {
+ checked = " checked"
+ }
+ html += " <div class=\"form-check\">\n"
+ html += " <input type=\"checkbox\" class=\"form-check-input\" id=\"" + field.Name + "\" name=\"" + field.Name + "\"" + checked + ">\n"
+ html += " <label class=\"form-check-label\" for=\"" + field.Name + "\">" + field.Label + "</label>\n"
+ html += " </div>\n"
+ // Description for checkbox
+ if field.Description != "" {
+ html += " <div class=\"form-text text-muted\">" + field.Description + "</div>\n"
+ }
+ html += "</div>\n"
+ return html
+ }
+
+ // Label for non-checkbox fields
+ required := ""
+ if field.Required {
+ required = " <span class=\"text-danger\">*</span>"
+ }
+ html += " <label for=\"" + field.Name + "\" class=\"form-label\">" + field.Label + required + "</label>\n"
+
+ // Input based on type
+ switch field.Type {
+ case "text":
+ html += " <input type=\"text\" class=\"form-control\" id=\"" + field.Name + "\" name=\"" + field.Name + "\" value=\"" + field.Value.(string) + "\""
+ if field.Required {
+ html += " required"
+ }
+ html += ">\n"
+
+ case "number":
+ html += " <input type=\"number\" class=\"form-control\" id=\"" + field.Name + "\" name=\"" + field.Name + "\" step=\"any\" value=\"" +
+ fmt.Sprintf("%v", field.Value) + "\""
+ if field.Required {
+ html += " required"
+ }
+ html += ">\n"
+
+ case "select":
+ html += " <select class=\"form-select\" id=\"" + field.Name + "\" name=\"" + field.Name + "\""
+ if field.Required {
+ html += " required"
+ }
+ html += ">\n"
+ for _, option := range field.Options {
+ selected := ""
+ if option.Value == field.Value.(string) {
+ selected = " selected"
+ }
+ html += " <option value=\"" + option.Value + "\"" + selected + ">" + option.Label + "</option>\n"
+ }
+ html += " </select>\n"
+
+ case "duration":
+ html += " <input type=\"text\" class=\"form-control\" id=\"" + field.Name + "\" name=\"" + field.Name + "\" value=\"" + field.Value.(string) +
+ "\" placeholder=\"e.g., 30m, 2h, 24h\""
+ if field.Required {
+ html += " required"
+ }
+ html += ">\n"
+ }
+
+ // Description for non-checkbox fields
+ if field.Description != "" {
+ html += " <div class=\"form-text text-muted\">" + field.Description + "</div>\n"
+ }
+
+ html += "</div>\n"
+ return html
+}
diff --git a/weed/worker/types/task_ui_templ.go b/weed/worker/types/task_ui_templ.go
new file mode 100644
index 000000000..77e80b408
--- /dev/null
+++ b/weed/worker/types/task_ui_templ.go
@@ -0,0 +1,63 @@
+package types
+
+import (
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/components"
+)
+
+// TaskUITemplProvider defines how tasks provide their configuration UI using templ components
+type TaskUITemplProvider interface {
+ // GetTaskType returns the task type
+ GetTaskType() TaskType
+
+ // GetDisplayName returns the human-readable name
+ GetDisplayName() string
+
+ // GetDescription returns a description of what this task does
+ GetDescription() string
+
+ // GetIcon returns the icon CSS class or HTML for this task type
+ GetIcon() string
+
+ // RenderConfigSections renders the configuration as templ section data
+ RenderConfigSections(currentConfig interface{}) ([]components.ConfigSectionData, error)
+
+ // ParseConfigForm parses form data into configuration
+ ParseConfigForm(formData map[string][]string) (interface{}, error)
+
+ // GetCurrentConfig returns the current configuration
+ GetCurrentConfig() interface{}
+
+ // ApplyConfig applies the new configuration
+ ApplyConfig(config interface{}) error
+}
+
+// UITemplRegistry manages task UI providers that use templ components
+type UITemplRegistry struct {
+ providers map[TaskType]TaskUITemplProvider
+}
+
+// NewUITemplRegistry creates a new templ-based UI registry
+func NewUITemplRegistry() *UITemplRegistry {
+ return &UITemplRegistry{
+ providers: make(map[TaskType]TaskUITemplProvider),
+ }
+}
+
+// RegisterUI registers a task UI provider
+func (r *UITemplRegistry) RegisterUI(provider TaskUITemplProvider) {
+ r.providers[provider.GetTaskType()] = provider
+}
+
+// GetProvider returns the UI provider for a task type
+func (r *UITemplRegistry) GetProvider(taskType TaskType) TaskUITemplProvider {
+ return r.providers[taskType]
+}
+
+// GetAllProviders returns all registered UI providers
+func (r *UITemplRegistry) GetAllProviders() map[TaskType]TaskUITemplProvider {
+ result := make(map[TaskType]TaskUITemplProvider)
+ for k, v := range r.providers {
+ result[k] = v
+ }
+ return result
+}
diff --git a/weed/worker/types/worker_types.go b/weed/worker/types/worker_types.go
new file mode 100644
index 000000000..b9b13e6c9
--- /dev/null
+++ b/weed/worker/types/worker_types.go
@@ -0,0 +1,111 @@
+package types
+
+import (
+ "time"
+)
+
+// Worker represents a maintenance worker instance
+type Worker struct {
+ ID string `json:"id"`
+ Address string `json:"address"`
+ LastHeartbeat time.Time `json:"last_heartbeat"`
+ Status string `json:"status"` // active, inactive, busy
+ CurrentTask *Task `json:"current_task,omitempty"`
+ Capabilities []TaskType `json:"capabilities"`
+ MaxConcurrent int `json:"max_concurrent"`
+ CurrentLoad int `json:"current_load"`
+}
+
+// WorkerStatus represents the current status of a worker
+type WorkerStatus struct {
+ WorkerID string `json:"worker_id"`
+ Status string `json:"status"`
+ Capabilities []TaskType `json:"capabilities"`
+ MaxConcurrent int `json:"max_concurrent"`
+ CurrentLoad int `json:"current_load"`
+ LastHeartbeat time.Time `json:"last_heartbeat"`
+ CurrentTasks []Task `json:"current_tasks"`
+ Uptime time.Duration `json:"uptime"`
+ TasksCompleted int `json:"tasks_completed"`
+ TasksFailed int `json:"tasks_failed"`
+}
+
+// WorkerDetailsData represents detailed worker information
+type WorkerDetailsData struct {
+ Worker *Worker `json:"worker"`
+ CurrentTasks []*Task `json:"current_tasks"`
+ RecentTasks []*Task `json:"recent_tasks"`
+ Performance *WorkerPerformance `json:"performance"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// WorkerPerformance tracks worker performance metrics
+type WorkerPerformance struct {
+ TasksCompleted int `json:"tasks_completed"`
+ TasksFailed int `json:"tasks_failed"`
+ AverageTaskTime time.Duration `json:"average_task_time"`
+ Uptime time.Duration `json:"uptime"`
+ SuccessRate float64 `json:"success_rate"`
+}
+
+// RegistryStats represents statistics for the worker registry
+type RegistryStats struct {
+ TotalWorkers int `json:"total_workers"`
+ ActiveWorkers int `json:"active_workers"`
+ BusyWorkers int `json:"busy_workers"`
+ IdleWorkers int `json:"idle_workers"`
+ TotalTasks int `json:"total_tasks"`
+ CompletedTasks int `json:"completed_tasks"`
+ FailedTasks int `json:"failed_tasks"`
+ StartTime time.Time `json:"start_time"`
+ Uptime time.Duration `json:"uptime"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+// WorkerSummary represents a summary of all workers
+type WorkerSummary struct {
+ TotalWorkers int `json:"total_workers"`
+ ByStatus map[string]int `json:"by_status"`
+ ByCapability map[TaskType]int `json:"by_capability"`
+ TotalLoad int `json:"total_load"`
+ MaxCapacity int `json:"max_capacity"`
+}
+
+// WorkerFactory creates worker instances
+type WorkerFactory interface {
+ Create(config WorkerConfig) (WorkerInterface, error)
+ Type() string
+ Description() string
+}
+
+// WorkerInterface defines the interface for all worker implementations
+type WorkerInterface interface {
+ ID() string
+ Start() error
+ Stop() error
+ RegisterTask(taskType TaskType, factory TaskFactory)
+ GetCapabilities() []TaskType
+ GetStatus() WorkerStatus
+ HandleTask(task *Task) error
+ SetCapabilities(capabilities []TaskType)
+ SetMaxConcurrent(max int)
+ SetHeartbeatInterval(interval time.Duration)
+ SetTaskRequestInterval(interval time.Duration)
+}
+
+// TaskFactory creates task instances
+type TaskFactory interface {
+ Create(params TaskParams) (TaskInterface, error)
+ Capabilities() []string
+ Description() string
+}
+
+// TaskInterface defines the interface for all task implementations
+type TaskInterface interface {
+ Type() TaskType
+ Execute(params TaskParams) error
+ Validate(params TaskParams) error
+ EstimateTime(params TaskParams) time.Duration
+ GetProgress() float64
+ Cancel() error
+}
diff --git a/weed/worker/worker.go b/weed/worker/worker.go
new file mode 100644
index 000000000..7050d21c9
--- /dev/null
+++ b/weed/worker/worker.go
@@ -0,0 +1,410 @@
+package worker
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/worker/tasks"
+ "github.com/seaweedfs/seaweedfs/weed/worker/types"
+
+ // Import task packages to trigger their auto-registration
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/balance"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/erasure_coding"
+ _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
+)
+
+// Worker represents a maintenance worker instance
+type Worker struct {
+ id string
+ config *types.WorkerConfig
+ registry *tasks.TaskRegistry
+ currentTasks map[string]*types.Task
+ adminClient AdminClient
+ running bool
+ stopChan chan struct{}
+ mutex sync.RWMutex
+ startTime time.Time
+ tasksCompleted int
+ tasksFailed int
+ heartbeatTicker *time.Ticker
+ requestTicker *time.Ticker
+}
+
+// AdminClient defines the interface for communicating with the admin server
+type AdminClient interface {
+ Connect() error
+ Disconnect() error
+ RegisterWorker(worker *types.Worker) error
+ SendHeartbeat(workerID string, status *types.WorkerStatus) error
+ RequestTask(workerID string, capabilities []types.TaskType) (*types.Task, error)
+ CompleteTask(taskID string, success bool, errorMsg string) error
+ UpdateTaskProgress(taskID string, progress float64) error
+ IsConnected() bool
+}
+
+// NewWorker creates a new worker instance
+func NewWorker(config *types.WorkerConfig) (*Worker, error) {
+ if config == nil {
+ config = types.DefaultWorkerConfig()
+ }
+
+ // Always auto-generate worker ID
+ hostname, _ := os.Hostname()
+ workerID := fmt.Sprintf("worker-%s-%d", hostname, time.Now().Unix())
+
+ // Use the global registry that already has all tasks registered
+ registry := tasks.GetGlobalRegistry()
+
+ worker := &Worker{
+ id: workerID,
+ config: config,
+ registry: registry,
+ currentTasks: make(map[string]*types.Task),
+ stopChan: make(chan struct{}),
+ startTime: time.Now(),
+ }
+
+ glog.V(1).Infof("Worker created with %d registered task types", len(registry.GetSupportedTypes()))
+
+ return worker, nil
+}
+
+// ID returns the worker ID
+func (w *Worker) ID() string {
+ return w.id
+}
+
+// Start starts the worker
+func (w *Worker) Start() error {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ if w.running {
+ return fmt.Errorf("worker is already running")
+ }
+
+ if w.adminClient == nil {
+ return fmt.Errorf("admin client is not set")
+ }
+
+ // Connect to admin server
+ if err := w.adminClient.Connect(); err != nil {
+ return fmt.Errorf("failed to connect to admin server: %v", err)
+ }
+
+ w.running = true
+ w.startTime = time.Now()
+
+ // Register with admin server
+ workerInfo := &types.Worker{
+ ID: w.id,
+ Capabilities: w.config.Capabilities,
+ MaxConcurrent: w.config.MaxConcurrent,
+ Status: "active",
+ CurrentLoad: 0,
+ LastHeartbeat: time.Now(),
+ }
+
+ if err := w.adminClient.RegisterWorker(workerInfo); err != nil {
+ w.running = false
+ w.adminClient.Disconnect()
+ return fmt.Errorf("failed to register worker: %v", err)
+ }
+
+ // Start worker loops
+ go w.heartbeatLoop()
+ go w.taskRequestLoop()
+
+ glog.Infof("Worker %s started", w.id)
+ return nil
+}
+
+// Stop stops the worker
+func (w *Worker) Stop() error {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ if !w.running {
+ return nil
+ }
+
+ w.running = false
+ close(w.stopChan)
+
+ // Stop tickers
+ if w.heartbeatTicker != nil {
+ w.heartbeatTicker.Stop()
+ }
+ if w.requestTicker != nil {
+ w.requestTicker.Stop()
+ }
+
+ // Wait for current tasks to complete or timeout
+ timeout := time.NewTimer(30 * time.Second)
+ defer timeout.Stop()
+
+ for len(w.currentTasks) > 0 {
+ select {
+ case <-timeout.C:
+ glog.Warningf("Worker %s stopping with %d tasks still running", w.id, len(w.currentTasks))
+ break
+ case <-time.After(time.Second):
+ // Check again
+ }
+ }
+
+ // Disconnect from admin server
+ if w.adminClient != nil {
+ if err := w.adminClient.Disconnect(); err != nil {
+ glog.Errorf("Error disconnecting from admin server: %v", err)
+ }
+ }
+
+ glog.Infof("Worker %s stopped", w.id)
+ return nil
+}
+
+// RegisterTask registers a task factory
+func (w *Worker) RegisterTask(taskType types.TaskType, factory types.TaskFactory) {
+ w.registry.Register(taskType, factory)
+}
+
+// GetCapabilities returns the worker capabilities
+func (w *Worker) GetCapabilities() []types.TaskType {
+ return w.config.Capabilities
+}
+
+// GetStatus returns the current worker status
+func (w *Worker) GetStatus() types.WorkerStatus {
+ w.mutex.RLock()
+ defer w.mutex.RUnlock()
+
+ var currentTasks []types.Task
+ for _, task := range w.currentTasks {
+ currentTasks = append(currentTasks, *task)
+ }
+
+ status := "active"
+ if len(w.currentTasks) >= w.config.MaxConcurrent {
+ status = "busy"
+ }
+
+ return types.WorkerStatus{
+ WorkerID: w.id,
+ Status: status,
+ Capabilities: w.config.Capabilities,
+ MaxConcurrent: w.config.MaxConcurrent,
+ CurrentLoad: len(w.currentTasks),
+ LastHeartbeat: time.Now(),
+ CurrentTasks: currentTasks,
+ Uptime: time.Since(w.startTime),
+ TasksCompleted: w.tasksCompleted,
+ TasksFailed: w.tasksFailed,
+ }
+}
+
+// HandleTask handles a task execution
+func (w *Worker) HandleTask(task *types.Task) error {
+ w.mutex.Lock()
+ if len(w.currentTasks) >= w.config.MaxConcurrent {
+ w.mutex.Unlock()
+ return fmt.Errorf("worker is at capacity")
+ }
+ w.currentTasks[task.ID] = task
+ w.mutex.Unlock()
+
+ // Execute task in goroutine
+ go w.executeTask(task)
+
+ return nil
+}
+
+// SetCapabilities sets the worker capabilities
+func (w *Worker) SetCapabilities(capabilities []types.TaskType) {
+ w.config.Capabilities = capabilities
+}
+
+// SetMaxConcurrent sets the maximum concurrent tasks
+func (w *Worker) SetMaxConcurrent(max int) {
+ w.config.MaxConcurrent = max
+}
+
+// SetHeartbeatInterval sets the heartbeat interval
+func (w *Worker) SetHeartbeatInterval(interval time.Duration) {
+ w.config.HeartbeatInterval = interval
+}
+
+// SetTaskRequestInterval sets the task request interval
+func (w *Worker) SetTaskRequestInterval(interval time.Duration) {
+ w.config.TaskRequestInterval = interval
+}
+
+// SetAdminClient sets the admin client
+func (w *Worker) SetAdminClient(client AdminClient) {
+ w.adminClient = client
+}
+
+// executeTask executes a task
+func (w *Worker) executeTask(task *types.Task) {
+ defer func() {
+ w.mutex.Lock()
+ delete(w.currentTasks, task.ID)
+ w.mutex.Unlock()
+ }()
+
+ glog.Infof("Worker %s executing task %s: %s", w.id, task.ID, task.Type)
+
+ // Create task instance
+ taskParams := types.TaskParams{
+ VolumeID: task.VolumeID,
+ Server: task.Server,
+ Collection: task.Collection,
+ Parameters: task.Parameters,
+ }
+
+ taskInstance, err := w.registry.CreateTask(task.Type, taskParams)
+ if err != nil {
+ w.completeTask(task.ID, false, fmt.Sprintf("failed to create task: %v", err))
+ return
+ }
+
+ // Execute task
+ err = taskInstance.Execute(taskParams)
+
+ // Report completion
+ if err != nil {
+ w.completeTask(task.ID, false, err.Error())
+ w.tasksFailed++
+ glog.Errorf("Worker %s failed to execute task %s: %v", w.id, task.ID, err)
+ } else {
+ w.completeTask(task.ID, true, "")
+ w.tasksCompleted++
+ glog.Infof("Worker %s completed task %s successfully", w.id, task.ID)
+ }
+}
+
+// completeTask reports task completion to admin server
+func (w *Worker) completeTask(taskID string, success bool, errorMsg string) {
+ if w.adminClient != nil {
+ if err := w.adminClient.CompleteTask(taskID, success, errorMsg); err != nil {
+ glog.Errorf("Failed to report task completion: %v", err)
+ }
+ }
+}
+
+// heartbeatLoop sends periodic heartbeats to the admin server
+func (w *Worker) heartbeatLoop() {
+ w.heartbeatTicker = time.NewTicker(w.config.HeartbeatInterval)
+ defer w.heartbeatTicker.Stop()
+
+ for {
+ select {
+ case <-w.stopChan:
+ return
+ case <-w.heartbeatTicker.C:
+ w.sendHeartbeat()
+ }
+ }
+}
+
+// taskRequestLoop periodically requests new tasks from the admin server
+func (w *Worker) taskRequestLoop() {
+ w.requestTicker = time.NewTicker(w.config.TaskRequestInterval)
+ defer w.requestTicker.Stop()
+
+ for {
+ select {
+ case <-w.stopChan:
+ return
+ case <-w.requestTicker.C:
+ w.requestTasks()
+ }
+ }
+}
+
+// sendHeartbeat sends heartbeat to admin server
+func (w *Worker) sendHeartbeat() {
+ if w.adminClient != nil {
+ if err := w.adminClient.SendHeartbeat(w.id, &types.WorkerStatus{
+ WorkerID: w.id,
+ Status: "active",
+ Capabilities: w.config.Capabilities,
+ MaxConcurrent: w.config.MaxConcurrent,
+ CurrentLoad: len(w.currentTasks),
+ LastHeartbeat: time.Now(),
+ }); err != nil {
+ glog.Warningf("Failed to send heartbeat: %v", err)
+ }
+ }
+}
+
+// requestTasks requests new tasks from the admin server
+func (w *Worker) requestTasks() {
+ w.mutex.RLock()
+ currentLoad := len(w.currentTasks)
+ w.mutex.RUnlock()
+
+ if currentLoad >= w.config.MaxConcurrent {
+ return // Already at capacity
+ }
+
+ if w.adminClient != nil {
+ task, err := w.adminClient.RequestTask(w.id, w.config.Capabilities)
+ if err != nil {
+ glog.V(2).Infof("Failed to request task: %v", err)
+ return
+ }
+
+ if task != nil {
+ if err := w.HandleTask(task); err != nil {
+ glog.Errorf("Failed to handle task: %v", err)
+ }
+ }
+ }
+}
+
+// GetTaskRegistry returns the task registry
+func (w *Worker) GetTaskRegistry() *tasks.TaskRegistry {
+ return w.registry
+}
+
+// GetCurrentTasks returns the current tasks
+func (w *Worker) GetCurrentTasks() map[string]*types.Task {
+ w.mutex.RLock()
+ defer w.mutex.RUnlock()
+
+ tasks := make(map[string]*types.Task)
+ for id, task := range w.currentTasks {
+ tasks[id] = task
+ }
+ return tasks
+}
+
+// GetConfig returns the worker configuration
+func (w *Worker) GetConfig() *types.WorkerConfig {
+ return w.config
+}
+
+// GetPerformanceMetrics returns performance metrics
+func (w *Worker) GetPerformanceMetrics() *types.WorkerPerformance {
+ w.mutex.RLock()
+ defer w.mutex.RUnlock()
+
+ uptime := time.Since(w.startTime)
+ var successRate float64
+ totalTasks := w.tasksCompleted + w.tasksFailed
+ if totalTasks > 0 {
+ successRate = float64(w.tasksCompleted) / float64(totalTasks) * 100
+ }
+
+ return &types.WorkerPerformance{
+ TasksCompleted: w.tasksCompleted,
+ TasksFailed: w.tasksFailed,
+ AverageTaskTime: 0, // Would need to track this
+ Uptime: uptime,
+ SuccessRate: successRate,
+ }
+}