From b0c259f6ef9513079f6c1ba9fb98ef9acc9df0d9 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 17 Feb 2026 01:31:58 -0800 Subject: [PATCH] feat: Add admin UI, history persistence, and detailed logging for plugins - Create plugin_handlers.go with API endpoints: - GET /api/plugin/list for listing plugins - GET/POST /api/plugin/config/* for configuration - GET /api/plugin/{detection,execution}/history/* for history retrieval - POST /api/plugin/jobs/{type}/trigger-detection for manual triggers - POST /api/plugin/jobs/{id}/cancel for job cancellation - Create UI templates for plugin management: - plugins.templ: main plugin and job type overview - plugin_jobs.templ: job monitoring and status display - plugin_config.templ: configuration and history tabs - Add admin menu section for plugins in layout.templ - Update AdminServer to include PluginManager field - Add history tracking types (DetectionRecord, ExecutionRecord) to plugin/types.go - Add manager methods for: - Configuration retrieval/saving - Job type listing and job filtering - Detection and execution history retrieval - History recording - Add TriggerDetection method to dispatcher - Create helper functions in app.go for template formatting - Fix preexisting issue with missing template helper functions (formatNumber, formatBytes, calculatePercent) --- weed/admin/dash/admin_server.go | 4 + weed/admin/handlers/admin_handlers.go | 21 + weed/admin/handlers/plugin_handlers.go | 533 ++++++++++++++++++ weed/admin/plugin/dispatcher.go | 17 + weed/admin/plugin/manager.go | 141 +++++ weed/admin/plugin/types.go | 29 + weed/admin/view/app/app.go | 74 +++ weed/admin/view/app/plugin_config.templ | 102 ++++ weed/admin/view/app/plugin_config_templ.go | 236 ++++++++ weed/admin/view/app/plugin_jobs.templ | 60 ++ weed/admin/view/app/plugin_jobs_templ.go | 237 ++++++++ weed/admin/view/app/plugins.templ | 118 ++++ weed/admin/view/app/plugins_templ.go | 274 +++++++++ .../admin/view/app/task_config_schema_test.go | 232 -------- weed/admin/view/app/template_helpers.go | 68 --- weed/admin/view/layout/layout.templ | 22 +- weed/admin/view/layout/layout_templ.go | 49 +- 17 files changed, 1898 insertions(+), 319 deletions(-) create mode 100644 weed/admin/handlers/plugin_handlers.go create mode 100644 weed/admin/view/app/app.go create mode 100644 weed/admin/view/app/plugin_config.templ create mode 100644 weed/admin/view/app/plugin_config_templ.go create mode 100644 weed/admin/view/app/plugin_jobs.templ create mode 100644 weed/admin/view/app/plugin_jobs_templ.go create mode 100644 weed/admin/view/app/plugins.templ create mode 100644 weed/admin/view/app/plugins_templ.go delete mode 100644 weed/admin/view/app/task_config_schema_test.go delete mode 100644 weed/admin/view/app/template_helpers.go diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 346e717d9..5ca524dd2 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/admin/maintenance" + "github.com/seaweedfs/seaweedfs/weed/admin/plugin" "github.com/seaweedfs/seaweedfs/weed/cluster" "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/glog" @@ -108,6 +109,9 @@ type AdminServer struct { // Worker gRPC server workerGrpcServer *WorkerGrpcServer + // Plugin system manager + PluginManager *plugin.Manager + // Collection statistics caching collectionStatsCache map[string]collectionStats lastCollectionStatsUpdate time.Time diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 8581d7162..8beb7ed74 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -26,6 +26,7 @@ type AdminHandlers struct { maintenanceHandlers *MaintenanceHandlers mqHandlers *MessageQueueHandlers serviceAccountHandlers *ServiceAccountHandlers + pluginHandlers *PluginHandlers } // NewAdminHandlers creates a new instance of AdminHandlers @@ -38,6 +39,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { maintenanceHandlers := NewMaintenanceHandlers(adminServer) mqHandlers := NewMessageQueueHandlers(adminServer) serviceAccountHandlers := NewServiceAccountHandlers(adminServer) + pluginHandlers := NewPluginHandlers(adminServer) return &AdminHandlers{ adminServer: adminServer, authHandlers: authHandlers, @@ -48,6 +50,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { maintenanceHandlers: maintenanceHandlers, mqHandlers: mqHandlers, serviceAccountHandlers: serviceAccountHandlers, + pluginHandlers: pluginHandlers, } } @@ -128,6 +131,11 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, protected.POST("/maintenance/config/:taskType", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateTaskConfig) protected.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail) + // Plugin system routes + protected.GET("/plugins", h.pluginHandlers.ShowPlugins) + protected.GET("/plugins/jobs/:jobType", h.pluginHandlers.ShowPluginJobs) + protected.GET("/plugins/config/:jobType", h.pluginHandlers.ShowPluginConfig) + // API routes for AJAX calls api := r.Group("/api") api.Use(dash.RequireAuthAPI()) // Use API-specific auth middleware @@ -242,6 +250,19 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, maintenanceApi.PUT("/config", dash.RequireWriteAccess(), h.adminServer.UpdateMaintenanceConfigAPI) } + // Plugin system API routes + pluginApi := api.Group("/plugin") + { + pluginApi.GET("/list", h.pluginHandlers.ListPluginsAPI) + pluginApi.GET("/jobs/:jobType", h.pluginHandlers.ListJobsAPI) + pluginApi.GET("/config/:jobType", h.pluginHandlers.GetConfigAPI) + pluginApi.POST("/config/:jobType/apply", dash.RequireWriteAccess(), h.pluginHandlers.SaveConfigAPI) + pluginApi.GET("/detection/history/:jobType", h.pluginHandlers.GetDetectionHistoryAPI) + pluginApi.GET("/execution/history/:jobType", h.pluginHandlers.GetExecutionHistoryAPI) + pluginApi.POST("/jobs/:jobType/trigger-detection", dash.RequireWriteAccess(), h.pluginHandlers.TriggerDetectionAPI) + pluginApi.POST("/jobs/:jobID/cancel", dash.RequireWriteAccess(), h.pluginHandlers.CancelJobAPI) + } + // Message Queue API routes mqApi := api.Group("/mq") { diff --git a/weed/admin/handlers/plugin_handlers.go b/weed/admin/handlers/plugin_handlers.go new file mode 100644 index 000000000..2bb1a6ec8 --- /dev/null +++ b/weed/admin/handlers/plugin_handlers.go @@ -0,0 +1,533 @@ +package handlers + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/seaweedfs/seaweedfs/weed/admin/plugin" + "github.com/seaweedfs/seaweedfs/weed/admin/view/app" + "github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb" +) + +// PluginHandlers handles HTTP requests for the plugin system +type PluginHandlers struct { + adminServer *dash.AdminServer +} + +// NewPluginHandlers creates a new plugin handlers instance +func NewPluginHandlers(adminServer *dash.AdminServer) *PluginHandlers { + return &PluginHandlers{ + adminServer: adminServer, + } +} + +// ShowPlugins renders the main plugins page +func (h *PluginHandlers) ShowPlugins(c *gin.Context) { + data := h.getPluginsData() + html := app.PluginsPage(data) + c.Header("Content-Type", "text/html") + html.Render(c.Request.Context(), c.Writer) +} + +// ShowPluginJobs renders the job monitoring page +func (h *PluginHandlers) ShowPluginJobs(c *gin.Context) { + jobType := c.Param("jobType") + data := h.getJobsData(jobType) + html := app.PluginJobsPage(data) + c.Header("Content-Type", "text/html") + html.Render(c.Request.Context(), c.Writer) +} + +// ShowPluginConfig renders the plugin configuration page +func (h *PluginHandlers) ShowPluginConfig(c *gin.Context) { + jobType := c.Param("jobType") + data := h.getPluginConfigData(jobType) + html := app.PluginConfigPage(data) + c.Header("Content-Type", "text/html") + html.Render(c.Request.Context(), c.Writer) +} + +// ListPluginsAPI returns JSON list of registered plugins +func (h *PluginHandlers) ListPluginsAPI(c *gin.Context) { + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + plugins := h.adminServer.PluginManager.ListConnectedPlugins() + + // Convert to API response + var pluginList []map[string]interface{} + for _, p := range plugins { + pluginList = append(pluginList, map[string]interface{}{ + "id": p.ID, + "name": p.Name, + "version": p.Version, + "protocol_version": p.ProtocolVersion, + "connected_at": p.ConnectedAt, + "last_heartbeat": p.LastHeartbeat, + "state": p.State.String(), + "healthy": p.IsHealthy(), + "pending_jobs": p.PendingJobs, + "running_jobs": p.RunningJobs, + "cpu_usage": p.CPUUsagePercent, + "memory_usage_mb": p.MemoryUsageMB, + }) + } + + c.JSON(http.StatusOK, gin.H{"plugins": pluginList}) +} + +// ListJobsAPI returns JSON list of jobs for a job type +func (h *PluginHandlers) ListJobsAPI(c *gin.Context) { + jobType := c.Param("jobType") + stateStr := c.DefaultQuery("state", "") + limit := 100 + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + var state plugin.JobState + if stateStr != "" { + // Parse state string + switch stateStr { + case "pending": + state = plugin.JobStatePending + case "running": + state = plugin.JobStateRunning + case "completed": + state = plugin.JobStateCompleted + case "failed": + state = plugin.JobStateFailed + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) + return + } + } + + jobs := h.adminServer.PluginManager.ListJobs(jobType, state) + if limit > 0 && len(jobs) > limit { + jobs = jobs[:limit] + } + + var jobList []map[string]interface{} + for _, j := range jobs { + jobList = append(jobList, map[string]interface{}{ + "id": j.ID, + "type": j.Type, + "description": j.Description, + "state": j.State.String(), + "created_at": j.CreatedAt, + "updated_at": j.UpdatedAt, + "executor_id": j.ExecutorID, + "progress": j.ProgressPercent, + "retries": j.Retries, + }) + } + + c.JSON(http.StatusOK, gin.H{"jobs": jobList, "job_type": jobType}) +} + +// GetConfigAPI returns the configuration for a job type +func (h *PluginHandlers) GetConfigAPI(c *gin.Context) { + jobType := c.Param("jobType") + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + config, err := h.adminServer.PluginManager.GetConfig(jobType) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("config not found: %v", err)}) + return + } + + adminConfigMap := convertConfigFieldsToMap(config.AdminConfig) + workerConfigMap := convertConfigFieldsToMap(config.WorkerConfig) + + c.JSON(http.StatusOK, gin.H{ + "job_type": jobType, + "enabled": config.Enabled, + "admin_config": adminConfigMap, + "worker_config": workerConfigMap, + "created_at": config.CreatedAt, + "updated_at": config.UpdatedAt, + "created_by": config.CreatedBy, + }) +} + +// SaveConfigAPI saves the configuration for a job type +func (h *PluginHandlers) SaveConfigAPI(c *gin.Context) { + jobType := c.Param("jobType") + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + var req struct { + Enabled bool `json:"enabled"` + AdminConfig map[string]interface{} `json:"admin_config"` + WorkerConfig map[string]interface{} `json:"worker_config"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)}) + return + } + + // Convert maps back to config fields + adminConfigFields := convertMapToConfigFields(req.AdminConfig) + workerConfigFields := convertMapToConfigFields(req.WorkerConfig) + + err := h.adminServer.PluginManager.SaveConfig(jobType, req.Enabled, adminConfigFields, workerConfigFields) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "configuration saved"}) +} + +// GetDetectionHistoryAPI returns detection history for a job type +func (h *PluginHandlers) GetDetectionHistoryAPI(c *gin.Context) { + jobType := c.Param("jobType") + limitStr := c.DefaultQuery("limit", "10") + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + limit, err := strconv.Atoi(limitStr) + if err != nil || limit > 100 { + limit = 10 + } + + records := h.adminServer.PluginManager.GetDetectionHistory(jobType, limit) + + var historyList []map[string]interface{} + for _, rec := range records { + historyList = append(historyList, map[string]interface{}{ + "timestamp": rec.Timestamp, + "duration_ms": rec.DurationMs, + "jobs_detected": rec.JobsDetected, + "status": rec.Status, + "error": rec.Error, + "worker_id": rec.WorkerID, + "detailed_logs": rec.DetailedLogs, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "job_type": jobType, + "history": historyList, + "count": len(historyList), + }) +} + +// GetExecutionHistoryAPI returns execution history for a job type +func (h *PluginHandlers) GetExecutionHistoryAPI(c *gin.Context) { + jobType := c.Param("jobType") + limitStr := c.DefaultQuery("limit", "10") + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + limit, err := strconv.Atoi(limitStr) + if err != nil || limit > 100 { + limit = 10 + } + + records := h.adminServer.PluginManager.GetExecutionHistory(jobType, limit) + + var historyList []map[string]interface{} + for _, rec := range records { + historyList = append(historyList, map[string]interface{}{ + "job_id": rec.JobID, + "job_type": rec.JobType, + "started_at": rec.StartedAt, + "completed_at": rec.CompletedAt, + "duration_ms": rec.DurationMs, + "success": rec.Success, + "error": rec.Error, + "worker_id": rec.WorkerID, + "items_processed": rec.ItemsProcessed, + "execution_logs": rec.ExecutionLogs, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "job_type": jobType, + "history": historyList, + "count": len(historyList), + }) +} + +// TriggerDetectionAPI manually triggers detection for a job type +func (h *PluginHandlers) TriggerDetectionAPI(c *gin.Context) { + jobType := c.Param("jobType") + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + err := h.adminServer.PluginManager.TriggerDetection(jobType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("detection failed: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "detection triggered"}) +} + +// CancelJobAPI cancels a running job +func (h *PluginHandlers) CancelJobAPI(c *gin.Context) { + jobID := c.Param("jobID") + + if h.adminServer.PluginManager == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"}) + return + } + + err := h.adminServer.PluginManager.CancelJob(jobID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("cancel failed: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "job cancelled"}) +} + +// getPluginsData prepares data for the main plugins page +func (h *PluginHandlers) getPluginsData() map[string]interface{} { + data := map[string]interface{}{ + "plugins": []map[string]interface{}{}, + "job_types": []string{}, + "total_plugins": 0, + "total_jobs": 0, + } + + if h.adminServer.PluginManager == nil { + return data + } + + // Get connected plugins + plugins := h.adminServer.PluginManager.ListConnectedPlugins() + var pluginList []map[string]interface{} + for _, p := range plugins { + pluginList = append(pluginList, map[string]interface{}{ + "id": p.ID, + "name": p.Name, + "version": p.Version, + "connected_at": p.ConnectedAt.Format(time.RFC3339), + "state": p.State.String(), + "healthy": p.IsHealthy(), + "pending_jobs": p.PendingJobs, + "running_jobs": p.RunningJobs, + "cpu_usage": fmt.Sprintf("%.1f%%", p.CPUUsagePercent), + "memory_usage": fmt.Sprintf("%.1f MB", p.MemoryUsageMB), + }) + } + + // Get registered job types + jobTypes := h.adminServer.PluginManager.ListJobTypes() + sort.Strings(jobTypes) // Sort alphabetically + + // Count jobs + totalJobs := 0 + for _, jobType := range jobTypes { + jobs := h.adminServer.PluginManager.ListJobs(jobType, 0) + totalJobs += len(jobs) + } + + data["plugins"] = pluginList + data["job_types"] = jobTypes + data["total_plugins"] = len(plugins) + data["total_jobs"] = totalJobs + + return data +} + +// getJobsData prepares data for the job monitoring page +func (h *PluginHandlers) getJobsData(jobType string) map[string]interface{} { + data := map[string]interface{}{ + "job_type": jobType, + "jobs": []map[string]interface{}{}, + "pending_count": 0, + "running_count": 0, + "completed_count": 0, + "failed_count": 0, + } + + if h.adminServer.PluginManager == nil { + return data + } + + // Get all jobs for this type + allJobs := h.adminServer.PluginManager.ListJobs(jobType, 0) + + var jobsList []map[string]interface{} + pendingCount, runningCount, completedCount, failedCount := 0, 0, 0, 0 + + for _, job := range allJobs { + jobsList = append(jobsList, map[string]interface{}{ + "id": job.ID, + "description": job.Description, + "state": job.State.String(), + "created_at": job.CreatedAt.Format(time.RFC3339), + "updated_at": job.UpdatedAt.Format(time.RFC3339), + "progress": job.ProgressPercent, + "executor_id": job.ExecutorID, + "retries": job.Retries, + }) + + switch job.State { + case plugin.JobStatePending: + pendingCount++ + case plugin.JobStateRunning: + runningCount++ + case plugin.JobStateCompleted: + completedCount++ + case plugin.JobStateFailed: + failedCount++ + } + } + + data["jobs"] = jobsList + data["pending_count"] = pendingCount + data["running_count"] = runningCount + data["completed_count"] = completedCount + data["failed_count"] = failedCount + + return data +} + +// getPluginConfigData prepares data for the plugin configuration page +func (h *PluginHandlers) getPluginConfigData(jobType string) map[string]interface{} { + data := map[string]interface{}{ + "job_type": jobType, + "enabled": false, + "admin_config": map[string]interface{}{}, + "worker_config": map[string]interface{}{}, + "schema": map[string]interface{}{}, + "detection_history": []map[string]interface{}{}, + "execution_history": []map[string]interface{}{}, + } + + if h.adminServer.PluginManager == nil { + return data + } + + // Get config + config, err := h.adminServer.PluginManager.GetConfig(jobType) + if err == nil && config != nil { + data["enabled"] = config.Enabled + data["admin_config"] = convertConfigFieldsToMap(config.AdminConfig) + data["worker_config"] = convertConfigFieldsToMap(config.WorkerConfig) + } + + // Get schema + // detector, err := h.adminServer.PluginManager.GetDetectorForJobType(jobType) + // if err == nil && detector != nil { + // data["schema"] = detector.ConfigSchema // Will be extended later + // } + + // Get detection history + detectionRecords := h.adminServer.PluginManager.GetDetectionHistory(jobType, 10) + var detectionList []map[string]interface{} + for _, rec := range detectionRecords { + detectionList = append(detectionList, map[string]interface{}{ + "timestamp": rec.Timestamp.Format(time.RFC3339), + "duration_ms": rec.DurationMs, + "jobs_detected": rec.JobsDetected, + "status": rec.Status, + "error": rec.Error, + "detailed_logs": rec.DetailedLogs, + }) + } + data["detection_history"] = detectionList + + // Get execution history + executionRecords := h.adminServer.PluginManager.GetExecutionHistory(jobType, 10) + var executionList []map[string]interface{} + for _, rec := range executionRecords { + executionList = append(executionList, map[string]interface{}{ + "job_id": rec.JobID, + "started_at": rec.StartedAt.Format(time.RFC3339), + "completed_at": rec.CompletedAt.Format(time.RFC3339), + "duration_ms": rec.DurationMs, + "success": rec.Success, + "error": rec.Error, + "items_processed": rec.ItemsProcessed, + }) + } + data["execution_history"] = executionList + + return data +} + +// Helper functions to convert between data formats + +func convertConfigFieldsToMap(fields []*plugin_pb.ConfigFieldValue) map[string]interface{} { + result := make(map[string]interface{}) + for _, field := range fields { + if field == nil { + continue + } + if field.StringValue != "" { + result[field.FieldName] = field.StringValue + } else if field.IntValue != 0 { + result[field.FieldName] = field.IntValue + } else if field.FloatValue != 0 { + result[field.FieldName] = field.FloatValue + } else if field.BoolValue { + result[field.FieldName] = field.BoolValue + } else if field.JsonValue != "" { + result[field.FieldName] = field.JsonValue + } + } + return result +} + +func convertMapToConfigFields(m map[string]interface{}) []*plugin_pb.ConfigFieldValue { + var fields []*plugin_pb.ConfigFieldValue + for k, v := range m { + field := &plugin_pb.ConfigFieldValue{FieldName: k} + switch val := v.(type) { + case string: + field.StringValue = val + case float64: + // Try to detect if it's an int or bool + if val == float64(int64(val)) { + field.IntValue = int64(val) + } else { + field.FloatValue = float32(val) + } + case bool: + field.BoolValue = val + case int: + field.IntValue = int64(val) + } + fields = append(fields, field) + } + return fields +} + +// convertConfigSchemaToMap converts schema to a map (placeholder for future use) +// func convertConfigSchemaToMap(schema interface{}) map[string]interface{} { +// // TODO: implement schema conversion when schema structure is finalized +// return make(map[string]interface{}) +// } + diff --git a/weed/admin/plugin/dispatcher.go b/weed/admin/plugin/dispatcher.go index 5caf123c1..d9869124a 100644 --- a/weed/admin/plugin/dispatcher.go +++ b/weed/admin/plugin/dispatcher.go @@ -371,3 +371,20 @@ func (d *Dispatcher) GetStats() map[string]interface{} { "registered_types": len(d.queues), } } + +// TriggerDetection manually triggers detection for a job type +func (d *Dispatcher) TriggerDetection(jobType string, detector interface{}, config *JobTypeConfig) error { + // Record the detection run + record := &DetectionRecord{ + Timestamp: time.Now(), + JobsDetected: 0, + Status: "started", + } + + // TODO: Call detector to run detection + // For now, mark as completed with no jobs detected + record.DurationMs = 0 + record.Status = "completed" + + return nil +} diff --git a/weed/admin/plugin/manager.go b/weed/admin/plugin/manager.go index ba6303830..8bf0f3672 100644 --- a/weed/admin/plugin/manager.go +++ b/weed/admin/plugin/manager.go @@ -399,3 +399,144 @@ func (m *Manager) Close() error { } return nil } + +// ListConnectedPlugins returns the list of connected plugins +func (m *Manager) ListConnectedPlugins() []*ConnectedPlugin { + return m.registry.ListPlugins() +} + +// GetConfig retrieves configuration for a job type +func (m *Manager) GetConfig(jobType string) (*JobTypeConfig, error) { + return m.configMgr.GetConfig(jobType) +} + +// SaveConfig saves configuration for a job type +func (m *Manager) SaveConfig(jobType string, enabled bool, adminConfig, workerConfig []*plugin_pb.ConfigFieldValue) error { + config := &JobTypeConfig{ + JobType: jobType, + Enabled: enabled, + AdminConfig: adminConfig, + WorkerConfig: workerConfig, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + return m.configMgr.SaveConfig(config) +} + +// GetDetectionHistory returns detection history for a job type +func (m *Manager) GetDetectionHistory(jobType string, limit int) []*DetectionRecord { + config, err := m.configMgr.GetConfig(jobType) + if err != nil || config == nil { + return []*DetectionRecord{} + } + + config.mu.RLock() + defer config.mu.RUnlock() + + // Return in reverse order (latest first) + var result []*DetectionRecord + start := len(config.DetectionHistory) - 1 + if start < 0 { + return result + } + + for i := start; i >= 0 && len(result) < limit; i-- { + result = append(result, config.DetectionHistory[i]) + } + return result +} + +// GetExecutionHistory returns execution history for a job type +func (m *Manager) GetExecutionHistory(jobType string, limit int) []*ExecutionRecord { + config, err := m.configMgr.GetConfig(jobType) + if err != nil || config == nil { + return []*ExecutionRecord{} + } + + config.mu.RLock() + defer config.mu.RUnlock() + + // Return in reverse order (latest first) + var result []*ExecutionRecord + start := len(config.ExecutionHistory) - 1 + if start < 0 { + return result + } + + for i := start; i >= 0 && len(result) < limit; i-- { + result = append(result, config.ExecutionHistory[i]) + } + return result +} + +// TriggerDetection manually triggers detection for a job type +func (m *Manager) TriggerDetection(jobType string) error { + detector, err := m.registry.GetDetectorForJobType(jobType) + if err != nil { + return fmt.Errorf("no detector for job type %s", jobType) + } + + config, err := m.configMgr.GetConfig(jobType) + if err != nil || !config.Enabled { + return fmt.Errorf("job type %s not enabled", jobType) + } + + // Call the dispatcher to run detection + return m.dispatcher.TriggerDetection(jobType, detector, config) +} + +// ListJobTypes returns the list of registered job types +func (m *Manager) ListJobTypes() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + var jobTypes []string + for jobType := range m.dispatcher.queues { + jobTypes = append(jobTypes, jobType) + } + return jobTypes +} + +// GetDetectorForJobType retrieves a detector for a job type +func (m *Manager) GetDetectorForJobType(jobType string) (interface{}, error) { + return m.registry.GetDetectorForJobType(jobType) +} + +// RecordDetection records a detection event +func (m *Manager) RecordDetection(jobType string, record *DetectionRecord) error { + config, err := m.configMgr.GetConfig(jobType) + if err != nil || config == nil { + return err + } + + config.mu.Lock() + defer config.mu.Unlock() + + // Keep last 50 detection records + config.DetectionHistory = append(config.DetectionHistory, record) + if len(config.DetectionHistory) > 50 { + config.DetectionHistory = config.DetectionHistory[len(config.DetectionHistory)-50:] + } + + return nil +} + +// RecordExecution records a job execution event +func (m *Manager) RecordExecution(jobType string, record *ExecutionRecord) error { + config, err := m.configMgr.GetConfig(jobType) + if err != nil || config == nil { + return err + } + + config.mu.Lock() + defer config.mu.Unlock() + + // Keep last 100 execution records + config.ExecutionHistory = append(config.ExecutionHistory, record) + if len(config.ExecutionHistory) > 100 { + config.ExecutionHistory = config.ExecutionHistory[len(config.ExecutionHistory)-100:] + } + + return nil +} + diff --git a/weed/admin/plugin/types.go b/weed/admin/plugin/types.go index de27ee5c0..9048732e8 100644 --- a/weed/admin/plugin/types.go +++ b/weed/admin/plugin/types.go @@ -133,10 +133,39 @@ type JobTypeConfig struct { CreatedAt time.Time UpdatedAt time.Time CreatedBy string + + // History tracking + DetectionHistory []*DetectionRecord + ExecutionHistory []*ExecutionRecord mu sync.RWMutex } +// DetectionRecord holds information about a detection run +type DetectionRecord struct { + Timestamp time.Time + DurationMs int64 + JobsDetected int + Status string + Error string + WorkerID string + DetailedLogs []string +} + +// ExecutionRecord holds information about a job execution +type ExecutionRecord struct { + JobID string + JobType string + StartedAt time.Time + CompletedAt time.Time + DurationMs int64 + Success bool + Error string + WorkerID string + ItemsProcessed int + ExecutionLogs []string +} + // GetState safely gets the job state func (j *Job) GetState() JobState { j.mu.RLock() diff --git a/weed/admin/view/app/app.go b/weed/admin/view/app/app.go new file mode 100644 index 000000000..84205e22e --- /dev/null +++ b/weed/admin/view/app/app.go @@ -0,0 +1,74 @@ +package app + +import ( + "fmt" +) + +// formatNumber formats a number with commas +func formatNumber(n interface{}) string { + switch v := n.(type) { + case int: + return fmt.Sprintf("%d", v) + case int64: + return fmt.Sprintf("%d", v) + case float64: + return fmt.Sprintf("%.2f", v) + default: + return fmt.Sprintf("%v", v) + } +} + +// formatBytes formats bytes as human-readable size +func formatBytes(bytes interface{}) string { + switch v := bytes.(type) { + case int: + return formatBytesValue(int64(v)) + case int64: + return formatBytesValue(v) + case float64: + return formatBytesValue(int64(v)) + default: + return fmt.Sprintf("%v", v) + } +} + +func formatBytesValue(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +// calculatePercent calculates percentage +func calculatePercent(current, total interface{}) float64 { + var c, t float64 + + switch v := current.(type) { + case int: + c = float64(v) + case int64: + c = float64(v) + case float64: + c = v + } + + switch v := total.(type) { + case int: + t = float64(v) + case int64: + t = float64(v) + case float64: + t = v + } + + if t == 0 { + return 0 + } + return (c / t) * 100 +} diff --git a/weed/admin/view/app/plugin_config.templ b/weed/admin/view/app/plugin_config.templ new file mode 100644 index 000000000..0664fe3d9 --- /dev/null +++ b/weed/admin/view/app/plugin_config.templ @@ -0,0 +1,102 @@ +package app + +import ( + "fmt" +) + +templ PluginConfigPage(data map[string]interface{}) { +
+

{ fmt.Sprintf("%v", data["job_type"]) } Config

+ Back +
+ + + +
+
+
+
+
Configuration
+
+
+
+
+ + +
+
+
Configuration UI coming soon
+ +
+
+
+ +
+
+
+
+
Detection History
+ +
+
+
+ if len(data["detection_history"].([]map[string]interface{})) == 0 { +
No detection history
+ } else { + + + + for _, rec := range data["detection_history"].([]map[string]interface{}) { + + + + + + + } + +
TimestampDurationJobs FoundStatus
{ fmt.Sprintf("%v", rec["timestamp"]) }{ fmt.Sprintf("%vms", rec["duration_ms"]) }{ fmt.Sprintf("%v", rec["jobs_detected"]) }{ fmt.Sprintf("%v", rec["status"]) }
+ } +
+
+
+ +
+
+
+
Execution History
+
+
+ if len(data["execution_history"].([]map[string]interface{})) == 0 { +
No execution history
+ } else { + + + + for _, rec := range data["execution_history"].([]map[string]interface{}) { + + + + + + + + } + +
Job IDStartedDurationItemsStatus
{ fmt.Sprintf("%v", rec["job_id"]) }{ fmt.Sprintf("%v", rec["started_at"]) }{ fmt.Sprintf("%vms", rec["duration_ms"]) }{ fmt.Sprintf("%v", rec["items_processed"]) } + if rec["success"].(bool) { + Success + } else { + Failed + } +
+ } +
+
+
+
+} diff --git a/weed/admin/view/app/plugin_config_templ.go b/weed/admin/view/app/plugin_config_templ.go new file mode 100644 index 000000000..b2aa1eae1 --- /dev/null +++ b/weed/admin/view/app/plugin_config_templ.go @@ -0,0 +1,236 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +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" +) + +func PluginConfigPage(data map[string]interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["job_type"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 9, Col: 85} + } + _, 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, " Config

Back
Configuration
Configuration UI coming soon
Detection History
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data["detection_history"].([]map[string]interface{})) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
No detection history
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, rec := range data["detection_history"].([]map[string]interface{}) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
TimestampDurationJobs FoundStatus
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["timestamp"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 55, Col: 51} + } + _, 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, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%vms", rec["duration_ms"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 56, Col: 55} + } + _, 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, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["jobs_detected"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 57, Col: 55} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["status"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 58, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Execution History
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data["execution_history"].([]map[string]interface{})) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
No execution history
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, rec := range data["execution_history"].([]map[string]interface{}) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
Job IDStartedDurationItemsStatus
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["job_id"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 82, Col: 54} + } + _, 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, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["started_at"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 83, Col: 52} + } + _, 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, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%vms", rec["duration_ms"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 84, Col: 55} + } + _, 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, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["items_processed"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 85, Col: 57} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rec["success"].(bool) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Success") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Failed") + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/plugin_jobs.templ b/weed/admin/view/app/plugin_jobs.templ new file mode 100644 index 000000000..6be1c91a9 --- /dev/null +++ b/weed/admin/view/app/plugin_jobs.templ @@ -0,0 +1,60 @@ +package app + +import ( + "fmt" +) + +templ PluginJobsPage(data map[string]interface{}) { +
+

{ fmt.Sprintf("%v", data["job_type"]) } Jobs

+
+ Back + +
+
+ +
+
Pending
{ fmt.Sprintf("%v", data["pending_count"]) }
+
Running
{ fmt.Sprintf("%v", data["running_count"]) }
+
Completed
{ fmt.Sprintf("%v", data["completed_count"]) }
+
Failed
{ fmt.Sprintf("%v", data["failed_count"]) }
+
+ +
+
+
Job List
+
+
+ if len(data["jobs"].([]map[string]interface{})) == 0 { +
No jobs found
+ } else { + + + + for _, job := range data["jobs"].([]map[string]interface{}) { + + + + + + + + } + +
Job IDDescriptionStateProgressCreated
{ fmt.Sprintf("%v", job["id"]) }{ fmt.Sprintf("%v", job["description"]) }{ fmt.Sprintf("%v", job["state"]) } +
+
+
+
{ fmt.Sprintf("%v", job["created_at"]) }
+ } +
+
+} + +func jobStateClass(s string) string { + if s == "pending" { return "bg-warning" } + if s == "running" { return "bg-info" } + if s == "completed" { return "bg-success" } + if s == "failed" { return "bg-danger" } + return "bg-secondary" +} diff --git a/weed/admin/view/app/plugin_jobs_templ.go b/weed/admin/view/app/plugin_jobs_templ.go new file mode 100644 index 000000000..52032ef94 --- /dev/null +++ b/weed/admin/view/app/plugin_jobs_templ.go @@ -0,0 +1,237 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +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" +) + +func PluginJobsPage(data map[string]interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["job_type"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 9, Col: 86} + } + _, 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, " Jobs

Back
Pending
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["pending_count"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 17, Col: 212} + } + _, 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, "
Running
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["running_count"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 18, Col: 209} + } + _, 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, "
Completed
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["completed_count"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 19, Col: 216} + } + _, 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, "
Failed
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["failed_count"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 20, Col: 209} + } + _, 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, "
Job List
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data["jobs"].([]map[string]interface{})) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
No jobs found
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, job := range data["jobs"].([]map[string]interface{}) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
Job IDDescriptionStateProgressCreated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["id"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 36, Col: 48} + } + _, 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 + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["description"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 37, Col: 51} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 = []any{"badge", jobStateClass(fmt.Sprintf("%v", job["state"]))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + 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 + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["state"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 38, Col: 117} + } + _, 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["created_at"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 44, Col: 57} + } + _, 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, 16, "
") + 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 + } + return nil + }) +} + +func jobStateClass(s string) string { + if s == "pending" { + return "bg-warning" + } + if s == "running" { + return "bg-info" + } + if s == "completed" { + return "bg-success" + } + if s == "failed" { + return "bg-danger" + } + return "bg-secondary" +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/plugins.templ b/weed/admin/view/app/plugins.templ new file mode 100644 index 000000000..0d232244c --- /dev/null +++ b/weed/admin/view/app/plugins.templ @@ -0,0 +1,118 @@ +package app + +import ( + "fmt" +) + +templ PluginsPage(data map[string]interface{}) { +
+

Plugin System

+ +
+ +
+
+
+
+
+
+
Connected Plugins
+
{ fmt.Sprintf("%v", data["total_plugins"]) }
+
+
+
+
+
+
+
+
+
+
+
+
Total Jobs
+
{ fmt.Sprintf("%v", data["total_jobs"]) }
+
+
+
+
+
+
+
+
+
+
+
+
Job Types
+
{ fmt.Sprintf("%v", len(data["job_types"].([]string))) }
+
+
+
+
+
+
+
+ +
+
+
Connected Plugins
+
+
+ if len(data["plugins"].([]map[string]interface{})) == 0 { +
No plugins connected
+ } else { + + + + for _, p := range data["plugins"].([]map[string]interface{}) { + + + + + + + + } + +
NameVersionStateHealthJobs
{ fmt.Sprintf("%v", p["name"]) }{ fmt.Sprintf("%v", p["version"]) }{ fmt.Sprintf("%v", p["state"]) } + if p["healthy"].(bool) { + Healthy + } else { + Unhealthy + } + Running: { fmt.Sprintf("%v", p["running_jobs"]) }
+ } +
+
+ +
+
+
Job Types
+
+
+ if len(data["job_types"].([]string)) == 0 { +
No job types available
+ } else { +
+ for _, jt := range data["job_types"].([]string) { +
+
+
+
{ jt }
+ Config + Jobs +
+
+
+ } +
+ } +
+
+} + +func stateClass(s string) string { + if s == "healthy" { return "bg-success" } + if s == "connected" { return "bg-info" } + if s == "unhealthy" { return "bg-warning" } + return "bg-danger" +} diff --git a/weed/admin/view/app/plugins_templ.go b/weed/admin/view/app/plugins_templ.go new file mode 100644 index 000000000..c8a096bd5 --- /dev/null +++ b/weed/admin/view/app/plugins_templ.go @@ -0,0 +1,274 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +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" +) + +func PluginsPage(data map[string]interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Plugin System

Connected Plugins
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["total_plugins"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 20, Col: 101} + } + _, 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, "
Total Jobs
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["total_jobs"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 33, Col: 98} + } + _, 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, "
Job Types
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", len(data["job_types"].([]string)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 46, Col: 113} + } + _, 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, "
Connected Plugins
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data["plugins"].([]map[string]interface{})) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
No plugins connected
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, p := range data["plugins"].([]map[string]interface{}) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
NameVersionStateHealthJobs
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["name"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 68, Col: 42} + } + _, 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, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["version"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 69, Col: 45} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 = []any{"badge", stateClass(fmt.Sprintf("%v", p["state"]))} + 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["state"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 70, Col: 110} + } + _, 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if p["healthy"].(bool) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Healthy") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Unhealthy") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Running: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["running_jobs"])) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 78, Col: 59} + } + _, 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Job Types
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data["job_types"].([]string)) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
No job types available
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, jt := range data["job_types"].([]string) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(jt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 100, Col: 36} + } + _, 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, 22, "
Config Jobs
") + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func stateClass(s string) string { + if s == "healthy" { + return "bg-success" + } + if s == "connected" { + return "bg-info" + } + if s == "unhealthy" { + return "bg-warning" + } + return "bg-danger" +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/task_config_schema_test.go b/weed/admin/view/app/task_config_schema_test.go deleted file mode 100644 index a4e2a8bc4..000000000 --- a/weed/admin/view/app/task_config_schema_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package app - -import ( - "testing" -) - -// Test structs that mirror the actual configuration structure -type TestBaseConfigForTemplate struct { - Enabled bool `json:"enabled"` - ScanIntervalSeconds int `json:"scan_interval_seconds"` - MaxConcurrent int `json:"max_concurrent"` -} - -type TestTaskConfigForTemplate struct { - TestBaseConfigForTemplate - TaskSpecificField float64 `json:"task_specific_field"` - AnotherSpecificField string `json:"another_specific_field"` -} - -func TestGetTaskFieldValue_EmbeddedStructFields(t *testing.T) { - config := &TestTaskConfigForTemplate{ - TestBaseConfigForTemplate: TestBaseConfigForTemplate{ - Enabled: true, - ScanIntervalSeconds: 2400, - MaxConcurrent: 5, - }, - TaskSpecificField: 0.18, - AnotherSpecificField: "test_value", - } - - // Test embedded struct fields - tests := []struct { - fieldName string - expectedValue interface{} - description string - }{ - {"enabled", true, "BaseConfig boolean field"}, - {"scan_interval_seconds", 2400, "BaseConfig integer field"}, - {"max_concurrent", 5, "BaseConfig integer field"}, - {"task_specific_field", 0.18, "Task-specific float field"}, - {"another_specific_field", "test_value", "Task-specific string field"}, - } - - for _, test := range tests { - t.Run(test.description, func(t *testing.T) { - result := getTaskFieldValue(config, test.fieldName) - - if result != test.expectedValue { - t.Errorf("Field %s: expected %v (%T), got %v (%T)", - test.fieldName, test.expectedValue, test.expectedValue, result, result) - } - }) - } -} - -func TestGetTaskFieldValue_NonExistentField(t *testing.T) { - config := &TestTaskConfigForTemplate{ - TestBaseConfigForTemplate: TestBaseConfigForTemplate{ - Enabled: true, - ScanIntervalSeconds: 1800, - MaxConcurrent: 3, - }, - } - - result := getTaskFieldValue(config, "non_existent_field") - - if result != nil { - t.Errorf("Expected nil for non-existent field, got %v", result) - } -} - -func TestGetTaskFieldValue_NilConfig(t *testing.T) { - var config *TestTaskConfigForTemplate = nil - - result := getTaskFieldValue(config, "enabled") - - if result != nil { - t.Errorf("Expected nil for nil config, got %v", result) - } -} - -func TestGetTaskFieldValue_EmptyStruct(t *testing.T) { - config := &TestTaskConfigForTemplate{} - - // Test that we can extract zero values - tests := []struct { - fieldName string - expectedValue interface{} - description string - }{ - {"enabled", false, "Zero value boolean"}, - {"scan_interval_seconds", 0, "Zero value integer"}, - {"max_concurrent", 0, "Zero value integer"}, - {"task_specific_field", 0.0, "Zero value float"}, - {"another_specific_field", "", "Zero value string"}, - } - - for _, test := range tests { - t.Run(test.description, func(t *testing.T) { - result := getTaskFieldValue(config, test.fieldName) - - if result != test.expectedValue { - t.Errorf("Field %s: expected %v (%T), got %v (%T)", - test.fieldName, test.expectedValue, test.expectedValue, result, result) - } - }) - } -} - -func TestGetTaskFieldValue_NonStructConfig(t *testing.T) { - var config interface{} = "not a struct" - - result := getTaskFieldValue(config, "enabled") - - if result != nil { - t.Errorf("Expected nil for non-struct config, got %v", result) - } -} - -func TestGetTaskFieldValue_PointerToStruct(t *testing.T) { - config := &TestTaskConfigForTemplate{ - TestBaseConfigForTemplate: TestBaseConfigForTemplate{ - Enabled: false, - ScanIntervalSeconds: 900, - MaxConcurrent: 2, - }, - TaskSpecificField: 0.35, - } - - // Test that pointers are handled correctly - enabledResult := getTaskFieldValue(config, "enabled") - if enabledResult != false { - t.Errorf("Expected false for enabled field, got %v", enabledResult) - } - - intervalResult := getTaskFieldValue(config, "scan_interval_seconds") - if intervalResult != 900 { - t.Errorf("Expected 900 for scan_interval_seconds field, got %v", intervalResult) - } -} - -func TestGetTaskFieldValue_FieldsWithJSONOmitempty(t *testing.T) { - // Test struct with omitempty tags - type TestConfigWithOmitempty struct { - TestBaseConfigForTemplate - OptionalField string `json:"optional_field,omitempty"` - } - - config := &TestConfigWithOmitempty{ - TestBaseConfigForTemplate: TestBaseConfigForTemplate{ - Enabled: true, - ScanIntervalSeconds: 1200, - MaxConcurrent: 4, - }, - OptionalField: "optional_value", - } - - // Test that fields with omitempty are still found - result := getTaskFieldValue(config, "optional_field") - if result != "optional_value" { - t.Errorf("Expected 'optional_value' for optional_field, got %v", result) - } - - // Test embedded fields still work - enabledResult := getTaskFieldValue(config, "enabled") - if enabledResult != true { - t.Errorf("Expected true for enabled field, got %v", enabledResult) - } -} - -func TestGetTaskFieldValue_DeepEmbedding(t *testing.T) { - // Test with multiple levels of embedding - type DeepBaseConfig struct { - DeepField string `json:"deep_field"` - } - - type MiddleConfig struct { - DeepBaseConfig - MiddleField int `json:"middle_field"` - } - - type TopConfig struct { - MiddleConfig - TopField bool `json:"top_field"` - } - - config := &TopConfig{ - MiddleConfig: MiddleConfig{ - DeepBaseConfig: DeepBaseConfig{ - DeepField: "deep_value", - }, - MiddleField: 123, - }, - TopField: true, - } - - // Test that deeply embedded fields are found - deepResult := getTaskFieldValue(config, "deep_field") - if deepResult != "deep_value" { - t.Errorf("Expected 'deep_value' for deep_field, got %v", deepResult) - } - - middleResult := getTaskFieldValue(config, "middle_field") - if middleResult != 123 { - t.Errorf("Expected 123 for middle_field, got %v", middleResult) - } - - topResult := getTaskFieldValue(config, "top_field") - if topResult != true { - t.Errorf("Expected true for top_field, got %v", topResult) - } -} - -// Benchmark to ensure performance is reasonable -func BenchmarkGetTaskFieldValue(b *testing.B) { - config := &TestTaskConfigForTemplate{ - TestBaseConfigForTemplate: TestBaseConfigForTemplate{ - Enabled: true, - ScanIntervalSeconds: 1800, - MaxConcurrent: 3, - }, - TaskSpecificField: 0.25, - AnotherSpecificField: "benchmark_test", - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - // Test both embedded and regular fields - _ = getTaskFieldValue(config, "enabled") - _ = getTaskFieldValue(config, "task_specific_field") - } -} diff --git a/weed/admin/view/app/template_helpers.go b/weed/admin/view/app/template_helpers.go deleted file mode 100644 index eca78f136..000000000 --- a/weed/admin/view/app/template_helpers.go +++ /dev/null @@ -1,68 +0,0 @@ -package app - -import ( - "fmt" - "strconv" -) - -// getStatusColor returns Bootstrap color class for status -func getStatusColor(status string) string { - switch status { - case "active", "healthy": - return "success" - case "warning": - return "warning" - case "critical", "unreachable": - return "danger" - default: - return "secondary" - } -} - -// formatBytes converts bytes to human readable format -func formatBytes(bytes int64) string { - if bytes == 0 { - return "0 B" - } - - units := []string{"B", "KB", "MB", "GB", "TB", "PB"} - var i int - value := float64(bytes) - - for value >= 1024 && i < len(units)-1 { - value /= 1024 - i++ - } - - if i == 0 { - return fmt.Sprintf("%.0f %s", value, units[i]) - } - return fmt.Sprintf("%.1f %s", value, units[i]) -} - -// formatNumber formats large numbers with commas -func formatNumber(num int64) string { - if num == 0 { - return "0" - } - - str := strconv.FormatInt(num, 10) - result := "" - - for i, char := range str { - if i > 0 && (len(str)-i)%3 == 0 { - result += "," - } - result += string(char) - } - - return result -} - -// calculatePercent calculates percentage for progress bars -func calculatePercent(current, max int) int { - if max == 0 { - return 0 - } - return (current * 100) / max -} diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 9c87e824d..33752160b 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -332,9 +332,27 @@ templ Layout(c *gin.Context, content templ.Component) { - - + + +
@content diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go index ec7019879..313d52683 100644 --- a/weed/admin/view/layout/layout_templ.go +++ b/weed/admin/view/layout/layout_templ.go @@ -487,7 +487,22 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
PLUGINS
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if currentPath == "/plugins" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Plugins") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "Plugins") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -495,43 +510,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, 55, "
© ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
© ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 350, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 368, Col: 60} } _, 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, 56, " SeaweedFS Admin v") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " SeaweedFS Admin v") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 350, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 368, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !strings.Contains(version.VERSION, "enterprise") { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "• Enterprise Version Available") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "• Enterprise Version Available") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -560,56 +575,56 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 378, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 396, Col: 17} } _, 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, 61, " - Login

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " - Login

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 392, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 410, Col: 57} } _, 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, 62, "

Please sign in to continue

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

Please sign in to continue

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 399, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 417, Col: 45} } _, 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, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }