diff --git a/cmd/heroagent/main.go b/cmd/heroagent/main.go index d34efdf..84738c4 100644 --- a/cmd/heroagent/main.go +++ b/cmd/heroagent/main.go @@ -20,6 +20,7 @@ func main() { enableRedisFlag := flag.Bool("redis", true, "Enable Redis server") enableWebDAVFlag := flag.Bool("webdav", true, "Enable WebDAV server") enableUIFlag := flag.Bool("ui", true, "Enable UI server") + enableJobsFlag := flag.Bool("jobs", true, "Enable Job Manager") flag.Parse() @@ -35,6 +36,7 @@ func main() { config.EnableRedis = *enableRedisFlag config.EnableWebDAV = *enableWebDAVFlag config.EnableUI = *enableUIFlag + config.EnableJobs = *enableJobsFlag // Override with environment variables if provided if redisPortStr := os.Getenv("REDIS_PORT"); redisPortStr != "" { @@ -70,6 +72,9 @@ func main() { if config.EnableUI { fmt.Printf("- UI server running on port %s\n", config.UI.Port) } + if config.EnableJobs { + fmt.Printf("- Job Manager running\n") + } // Keep the main goroutine running select {} diff --git a/heroagent b/heroagent index c131b8a..4a32c51 100755 Binary files a/heroagent and b/heroagent differ diff --git a/pkg/servers/ui/app.go b/pkg/servers/ui/app.go index 390df95..c2f16ff 100644 --- a/pkg/servers/ui/app.go +++ b/pkg/servers/ui/app.go @@ -1,8 +1,12 @@ package ui import ( + "fmt" + "log" "os" "path/filepath" + "regexp" + "strings" "git.ourworld.tf/herocode/heroagent/pkg/servers/ui/routes" // Import the routes package "github.com/gofiber/fiber/v2" @@ -54,9 +58,86 @@ func NewApp(config AppConfig) *fiber.App { // Enable template reloading for development engine.Reload(true) - // Create a new Fiber app with the configured Jet engine + // No custom functions for now + + // Create a new Fiber app with the configured Jet engine and enhanced error handling app := fiber.New(fiber.Config{ Views: engine, + ErrorHandler: func(c *fiber.Ctx, err error) error { + // Log the detailed error + log.Printf("ERROR: %v", err) + + // Check if it's a template rendering error + if err.Error() != "" && (c.Route().Path != "" && c.Method() == "GET") { + // Extract template name and line number from error message + errorMsg := err.Error() + templateInfo := "Unknown template" + lineInfo := "Unknown line" + variableInfo := "Unknown variable" + + // Try to extract template name and line number + if strings.Contains(errorMsg, "Jet Runtime Error") { + // Extract template and line number + templateLineRegex := regexp.MustCompile(`"([^"]+)":(\d+)`) + templateMatches := templateLineRegex.FindStringSubmatch(errorMsg) + if len(templateMatches) >= 3 { + templateInfo = templateMatches[1] + lineInfo = templateMatches[2] + } + + // Extract variable name + varRegex := regexp.MustCompile(`there is no field or method '([^']+)'`) + varMatches := varRegex.FindStringSubmatch(errorMsg) + if len(varMatches) >= 2 { + variableInfo = varMatches[1] + } + + // Log more detailed information + log.Printf("Template Error Details - Template: %s, Line: %s, Variable: %s", + templateInfo, lineInfo, variableInfo) + } + + // Create a more detailed error page + errorHTML := fmt.Sprintf(` + + + Template Error + + + +

Template Error

+
+

Error Details:

+

Template: %s

+

Line: %s

+

Missing Variable: %s

+
%s
+
+
+

Debugging Tips:

+

1. Check if the variable %s is passed to the template

+

2. Visit /debug to see available template variables

+

3. Check for typos in variable names

+

4. Ensure the variable is of the expected type

+

5. Check the controller that renders this template to ensure all required data is provided

+
+ + + `, templateInfo, lineInfo, variableInfo, errorMsg, variableInfo) + + return c.Status(fiber.StatusInternalServerError).Type("html").SendString(errorHTML) + } + + // For other errors, use the default error handler + return fiber.DefaultErrorHandler(c, err) + }, }) // Setup static file serving diff --git a/pkg/servers/ui/controllers/job_controller.go b/pkg/servers/ui/controllers/job_controller.go new file mode 100644 index 0000000..ac028de --- /dev/null +++ b/pkg/servers/ui/controllers/job_controller.go @@ -0,0 +1,271 @@ +package controllers + +import ( + "log" + "strconv" + + "git.ourworld.tf/herocode/heroagent/pkg/herojobs" + "git.ourworld.tf/herocode/heroagent/pkg/servers/ui/models" + "github.com/gofiber/fiber/v2" +) + +// JobController handles requests related to job management +type JobController struct { + jobManager models.JobManager +} + +// NewJobController creates a new instance of JobController +func NewJobController(jobManager models.JobManager) *JobController { + return &JobController{ + jobManager: jobManager, + } +} + +// ShowJobsPage renders the jobs management page +func (jc *JobController) ShowJobsPage(c *fiber.Ctx) error { + // Get filter parameters + circleID := c.Query("circle", "") + topic := c.Query("topic", "") + status := c.Query("status", "") + + var jobs []*models.JobInfo + var err error + + // Apply filters + if circleID != "" { + jobs, err = jc.jobManager.GetJobsByCircle(circleID) + } else if topic != "" { + jobs, err = jc.jobManager.GetJobsByTopic(topic) + } else if status != "" { + // Convert status string to JobStatus + var jobStatus herojobs.JobStatus + switch status { + case "new": + jobStatus = herojobs.JobStatusNew + case "active": + jobStatus = herojobs.JobStatusActive + case "error": + jobStatus = herojobs.JobStatusError + case "done": + jobStatus = herojobs.JobStatusDone + default: + // Invalid status, get all jobs + jobs, err = jc.jobManager.GetAllJobs() + } + + if jobStatus != "" { + jobs, err = jc.jobManager.GetJobsByStatus(jobStatus) + } + } else { + // No filters, get all jobs + jobs, err = jc.jobManager.GetAllJobs() + } + + if err != nil { + log.Printf("Error fetching jobs: %v", err) + return c.Status(fiber.StatusInternalServerError).Render("pages/error", fiber.Map{ + "Title": "Error", + "Message": "Failed to fetch jobs: " + err.Error(), + }) + } + + // Group jobs by circle and topic for tree view + jobTree := buildJobTree(jobs) + + // Get unique circles and topics for filter dropdowns + circles := getUniqueCircles(jobs) + topics := getUniqueTopics(jobs) + + // Create circle options with selected state + circleOptions := make([]map[string]interface{}, 0, len(circles)) + for _, circle := range circles { + circleOptions = append(circleOptions, map[string]interface{}{ + "Value": circle, + "Text": circle, + "Selected": circle == circleID, + }) + } + + // Create topic options with selected state + topicOptions := make([]map[string]interface{}, 0, len(topics)) + for _, topicName := range topics { + topicOptions = append(topicOptions, map[string]interface{}{ + "Value": topicName, + "Text": topicName, + "Selected": topicName == topic, + }) + } + + // Create status options with selected state + statusOptions := []map[string]interface{}{ + {"Value": "new", "Text": "New", "Selected": status == "new"}, + {"Value": "active", "Text": "Active", "Selected": status == "active"}, + {"Value": "done", "Text": "Done", "Selected": status == "done"}, + {"Value": "error", "Text": "Error", "Selected": status == "error"}, + } + + // Convert map options to OptionData structs + circleOptionData := make([]OptionData, 0, len(circleOptions)) + for _, option := range circleOptions { + circleOptionData = append(circleOptionData, OptionData{ + Value: option["Value"].(string), + Text: option["Text"].(string), + Selected: option["Selected"].(bool), + }) + } + + topicOptionData := make([]OptionData, 0, len(topicOptions)) + for _, option := range topicOptions { + topicOptionData = append(topicOptionData, OptionData{ + Value: option["Value"].(string), + Text: option["Text"].(string), + Selected: option["Selected"].(bool), + }) + } + + statusOptionData := make([]OptionData, 0, len(statusOptions)) + for _, option := range statusOptions { + statusOptionData = append(statusOptionData, OptionData{ + Value: option["Value"].(string), + Text: option["Text"].(string), + Selected: option["Selected"].(bool), + }) + } + + // Create JobPageData struct for the template + pageData := JobPageData{ + Title: "Job Management", + Jobs: jobs, + JobTree: jobTree, + CircleOptions: circleOptionData, + TopicOptions: topicOptionData, + StatusOptions: statusOptionData, + FilterCircle: circleID, + FilterTopic: topic, + FilterStatus: status, + TotalJobs: len(jobs), + ActiveJobs: countJobsByStatus(jobs, herojobs.JobStatusActive), + CompletedJobs: countJobsByStatus(jobs, herojobs.JobStatusDone), + ErrorJobs: countJobsByStatus(jobs, herojobs.JobStatusError), + NewJobs: countJobsByStatus(jobs, herojobs.JobStatusNew), + } + + // Render the template with the structured data + return c.Render("pages/jobs", pageData) +} + +// ShowJobDetails renders the job details page +func (jc *JobController) ShowJobDetails(c *fiber.Ctx) error { + // Get job ID from URL parameter + jobIDStr := c.Params("id") + jobID, err := strconv.ParseUint(jobIDStr, 10, 32) + if err != nil { + return c.Status(fiber.StatusBadRequest).Render("pages/error", fiber.Map{ + "Title": "Error", + "Message": "Invalid job ID: " + jobIDStr, + }) + } + + // Get job details + job, err := jc.jobManager.GetJob(uint32(jobID)) + if err != nil { + return c.Status(fiber.StatusNotFound).Render("pages/error", fiber.Map{ + "Title": "Error", + "Message": "Job not found: " + jobIDStr, + }) + } + + return c.Render("pages/job_details", fiber.Map{ + "Title": "Job Details", + "Job": job, + }) +} + +// CircleNode represents a circle in the job tree +type CircleNode struct { + CircleID string `json:"id"` + Name string `json:"name"` + Topics map[string]*TopicNode `json:"topics"` +} + +// TopicNode represents a topic in the job tree +type TopicNode struct { + Topic string `json:"topic"` + Name string `json:"name"` + Jobs []*models.JobInfo `json:"jobs"` +} + +// buildJobTree groups jobs by circle and topic for the tree view +func buildJobTree(jobs []*models.JobInfo) map[string]*CircleNode { + tree := make(map[string]*CircleNode) + + for _, job := range jobs { + // Get or create circle node + circle, exists := tree[job.CircleID] + if !exists { + circle = &CircleNode{ + CircleID: job.CircleID, + Name: job.CircleID, // Use CircleID as name for now + Topics: make(map[string]*TopicNode), + } + tree[job.CircleID] = circle + } + + // Get or create topic node + topic, exists := circle.Topics[job.Topic] + if !exists { + topic = &TopicNode{ + Topic: job.Topic, + Name: job.Topic, // Use Topic as name for now + Jobs: make([]*models.JobInfo, 0), + } + circle.Topics[job.Topic] = topic + } + + // Add job to topic + topic.Jobs = append(topic.Jobs, job) + } + + return tree +} + +// getUniqueCircles returns a list of unique circle IDs from jobs +func getUniqueCircles(jobs []*models.JobInfo) []string { + circleMap := make(map[string]bool) + for _, job := range jobs { + circleMap[job.CircleID] = true + } + + circles := make([]string, 0, len(circleMap)) + for circle := range circleMap { + circles = append(circles, circle) + } + + return circles +} + +// getUniqueTopics returns a list of unique topics from jobs +func getUniqueTopics(jobs []*models.JobInfo) []string { + topicMap := make(map[string]bool) + for _, job := range jobs { + topicMap[job.Topic] = true + } + + topics := make([]string, 0, len(topicMap)) + for topic := range topicMap { + topics = append(topics, topic) + } + + return topics +} + +// countJobsByStatus counts jobs with a specific status +func countJobsByStatus(jobs []*models.JobInfo, status herojobs.JobStatus) int { + count := 0 + for _, job := range jobs { + if job.Status == status { + count++ + } + } + return count +} diff --git a/pkg/servers/ui/controllers/job_page_data.go b/pkg/servers/ui/controllers/job_page_data.go new file mode 100644 index 0000000..364a9d7 --- /dev/null +++ b/pkg/servers/ui/controllers/job_page_data.go @@ -0,0 +1,30 @@ +package controllers + +import ( + "git.ourworld.tf/herocode/heroagent/pkg/servers/ui/models" +) + +// JobPageData represents the data needed for the jobs page +type JobPageData struct { + Title string + Jobs []*models.JobInfo + JobTree map[string]*CircleNode + CircleOptions []OptionData + TopicOptions []OptionData + StatusOptions []OptionData + FilterCircle string + FilterTopic string + FilterStatus string + TotalJobs int + ActiveJobs int + CompletedJobs int + ErrorJobs int + NewJobs int +} + +// OptionData represents a select option +type OptionData struct { + Value string + Text string + Selected bool +} diff --git a/pkg/servers/ui/models/job_manager.go b/pkg/servers/ui/models/job_manager.go new file mode 100644 index 0000000..6169476 --- /dev/null +++ b/pkg/servers/ui/models/job_manager.go @@ -0,0 +1,348 @@ +package models + +import ( + "context" + "fmt" + "log" + "time" + + "git.ourworld.tf/herocode/heroagent/pkg/herojobs" +) + +// JobManager provides an interface for job management operations +type JobManager interface { + // GetAllJobs returns all jobs + GetAllJobs() ([]*JobInfo, error) + // GetJobsByCircle returns jobs for a specific circle + GetJobsByCircle(circleID string) ([]*JobInfo, error) + // GetJobsByTopic returns jobs for a specific topic + GetJobsByTopic(topic string) ([]*JobInfo, error) + // GetJobsByStatus returns jobs with a specific status + GetJobsByStatus(status herojobs.JobStatus) ([]*JobInfo, error) + // GetJob returns a specific job by ID + GetJob(jobID uint32) (*JobInfo, error) +} + +// JobInfo represents job information for the UI +type JobInfo struct { + JobID uint32 `json:"jobid"` + SessionKey string `json:"sessionkey"` + CircleID string `json:"circleid"` + Topic string `json:"topic"` + ParamsType herojobs.ParamsType `json:"params_type"` + Status herojobs.JobStatus `json:"status"` + TimeScheduled time.Time `json:"time_scheduled"` + TimeStart time.Time `json:"time_start"` + TimeEnd time.Time `json:"time_end"` + Duration string `json:"duration"` + Error string `json:"error"` + HasError bool `json:"has_error"` +} + +// HeroJobManager implements JobManager interface using herojobs package +type HeroJobManager struct { + factory *herojobs.Factory + ctx context.Context +} + +// NewHeroJobManager creates a new HeroJobManager +func NewHeroJobManager(redisURL string) (*HeroJobManager, error) { + factory, err := herojobs.NewFactory(redisURL) + if err != nil { + return nil, fmt.Errorf("failed to create job factory: %w", err) + } + + return &HeroJobManager{ + factory: factory, + ctx: context.Background(), + }, nil +} + +// GetAllJobs returns all jobs from Redis +func (jm *HeroJobManager) GetAllJobs() ([]*JobInfo, error) { + // This is a simplified implementation + // In a real-world scenario, you would need to: + // 1. Get all circles and topics + // 2. For each circle/topic combination, get all jobs + // 3. Combine the results + + // For now, we'll just list all job IDs from Redis + jobIDs, err := jm.factory.ListJobs(jm.ctx) + if err != nil { + return nil, fmt.Errorf("failed to list jobs: %w", err) + } + + jobs := make([]*JobInfo, 0, len(jobIDs)) + for _, jobID := range jobIDs { + // Extract job ID from the key + // Assuming the key format is "job:" + jobData, err := jm.factory.GetJob(jm.ctx, jobID) + if err != nil { + log.Printf("Warning: failed to get job %s: %v", jobID, err) + continue + } + + // Parse job data + job, err := herojobs.NewJobFromJSON(jobData) + if err != nil { + log.Printf("Warning: failed to parse job data for %s: %v", jobID, err) + continue + } + + // Convert to JobInfo + jobInfo := convertToJobInfo(job) + jobs = append(jobs, jobInfo) + } + + return jobs, nil +} + +// GetJobsByCircle returns jobs for a specific circle +func (jm *HeroJobManager) GetJobsByCircle(circleID string) ([]*JobInfo, error) { + // Implementation would filter jobs by circle ID + // For now, return all jobs and filter in memory + allJobs, err := jm.GetAllJobs() + if err != nil { + return nil, err + } + + filteredJobs := make([]*JobInfo, 0) + for _, job := range allJobs { + if job.CircleID == circleID { + filteredJobs = append(filteredJobs, job) + } + } + + return filteredJobs, nil +} + +// GetJobsByTopic returns jobs for a specific topic +func (jm *HeroJobManager) GetJobsByTopic(topic string) ([]*JobInfo, error) { + // Implementation would filter jobs by topic + // For now, return all jobs and filter in memory + allJobs, err := jm.GetAllJobs() + if err != nil { + return nil, err + } + + filteredJobs := make([]*JobInfo, 0) + for _, job := range allJobs { + if job.Topic == topic { + filteredJobs = append(filteredJobs, job) + } + } + + return filteredJobs, nil +} + +// GetJobsByStatus returns jobs with a specific status +func (jm *HeroJobManager) GetJobsByStatus(status herojobs.JobStatus) ([]*JobInfo, error) { + // Implementation would filter jobs by status + // For now, return all jobs and filter in memory + allJobs, err := jm.GetAllJobs() + if err != nil { + return nil, err + } + + filteredJobs := make([]*JobInfo, 0) + for _, job := range allJobs { + if job.Status == status { + filteredJobs = append(filteredJobs, job) + } + } + + return filteredJobs, nil +} + +// GetJob returns a specific job by ID +func (jm *HeroJobManager) GetJob(jobID uint32) (*JobInfo, error) { + // Implementation would get a specific job by ID + // This is a placeholder implementation + allJobs, err := jm.GetAllJobs() + if err != nil { + return nil, err + } + + for _, job := range allJobs { + if job.JobID == jobID { + return job, nil + } + } + + return nil, fmt.Errorf("job not found: %d", jobID) +} + +// convertToJobInfo converts a herojobs.Job to a JobInfo +func convertToJobInfo(job *herojobs.Job) *JobInfo { + // Convert Unix timestamps to time.Time + timeScheduled := time.Unix(job.TimeScheduled, 0) + timeStart := time.Time{} + if job.TimeStart > 0 { + timeStart = time.Unix(job.TimeStart, 0) + } + timeEnd := time.Time{} + if job.TimeEnd > 0 { + timeEnd = time.Unix(job.TimeEnd, 0) + } + + // Calculate duration + var duration string + if job.TimeStart > 0 { + if job.TimeEnd > 0 { + // Job has completed + duration = time.Unix(job.TimeEnd, 0).Sub(time.Unix(job.TimeStart, 0)).String() + } else { + // Job is still running + duration = time.Since(time.Unix(job.TimeStart, 0)).String() + } + } else { + duration = "Not started" + } + + return &JobInfo{ + JobID: job.JobID, + SessionKey: job.SessionKey, + CircleID: job.CircleID, + Topic: job.Topic, + ParamsType: job.ParamsType, + Status: job.Status, + TimeScheduled: timeScheduled, + TimeStart: timeStart, + TimeEnd: timeEnd, + Duration: duration, + Error: job.Error, + HasError: job.Error != "", + } +} + +// MockJobManager is a mock implementation of JobManager for testing +type MockJobManager struct{} + +// NewMockJobManager creates a new MockJobManager +func NewMockJobManager() *MockJobManager { + return &MockJobManager{} +} + +// GetAllJobs returns mock jobs +func (m *MockJobManager) GetAllJobs() ([]*JobInfo, error) { + return generateMockJobs(), nil +} + +// GetJobsByCircle returns mock jobs for a circle +func (m *MockJobManager) GetJobsByCircle(circleID string) ([]*JobInfo, error) { + allJobs := generateMockJobs() + filteredJobs := make([]*JobInfo, 0) + for _, job := range allJobs { + if job.CircleID == circleID { + filteredJobs = append(filteredJobs, job) + } + } + return filteredJobs, nil +} + +// GetJobsByTopic returns mock jobs for a topic +func (m *MockJobManager) GetJobsByTopic(topic string) ([]*JobInfo, error) { + allJobs := generateMockJobs() + filteredJobs := make([]*JobInfo, 0) + for _, job := range allJobs { + if job.Topic == topic { + filteredJobs = append(filteredJobs, job) + } + } + return filteredJobs, nil +} + +// GetJobsByStatus returns mock jobs with a status +func (m *MockJobManager) GetJobsByStatus(status herojobs.JobStatus) ([]*JobInfo, error) { + allJobs := generateMockJobs() + filteredJobs := make([]*JobInfo, 0) + for _, job := range allJobs { + if job.Status == status { + filteredJobs = append(filteredJobs, job) + } + } + return filteredJobs, nil +} + +// GetJob returns a mock job by ID +func (m *MockJobManager) GetJob(jobID uint32) (*JobInfo, error) { + allJobs := generateMockJobs() + for _, job := range allJobs { + if job.JobID == jobID { + return job, nil + } + } + return nil, fmt.Errorf("job not found: %d", jobID) +} + +// generateMockJobs generates mock jobs for testing +func generateMockJobs() []*JobInfo { + now := time.Now() + return []*JobInfo{ + { + JobID: 1, + CircleID: "circle1", + Topic: "email", + ParamsType: herojobs.ParamsTypeHeroScript, + Status: herojobs.JobStatusDone, + TimeScheduled: now.Add(-30 * time.Minute), + TimeStart: now.Add(-29 * time.Minute), + TimeEnd: now.Add(-28 * time.Minute), + Duration: "1m0s", + Error: "", + HasError: false, + }, + { + JobID: 2, + CircleID: "circle1", + Topic: "backup", + ParamsType: herojobs.ParamsTypeOpenRPC, + Status: herojobs.JobStatusActive, + TimeScheduled: now.Add(-15 * time.Minute), + TimeStart: now.Add(-14 * time.Minute), + TimeEnd: time.Time{}, + Duration: "14m0s", + Error: "", + HasError: false, + }, + { + JobID: 3, + CircleID: "circle2", + Topic: "sync", + ParamsType: herojobs.ParamsTypeRhaiScript, + Status: herojobs.JobStatusError, + TimeScheduled: now.Add(-45 * time.Minute), + TimeStart: now.Add(-44 * time.Minute), + TimeEnd: now.Add(-43 * time.Minute), + Duration: "1m0s", + Error: "Failed to connect to remote server", + HasError: true, + }, + { + JobID: 4, + CircleID: "circle2", + Topic: "email", + ParamsType: herojobs.ParamsTypeHeroScript, + Status: herojobs.JobStatusNew, + TimeScheduled: now.Add(-5 * time.Minute), + TimeStart: time.Time{}, + TimeEnd: time.Time{}, + Duration: "Not started", + Error: "", + HasError: false, + }, + { + JobID: 5, + CircleID: "circle3", + Topic: "ai", + ParamsType: herojobs.ParamsTypeAI, + Status: herojobs.JobStatusDone, + TimeScheduled: now.Add(-60 * time.Minute), + TimeStart: now.Add(-59 * time.Minute), + TimeEnd: now.Add(-40 * time.Minute), + Duration: "19m0s", + Error: "", + HasError: false, + }, + } +} diff --git a/pkg/servers/ui/routes/router.go b/pkg/servers/ui/routes/router.go index 7db7a60..1fdfd54 100644 --- a/pkg/servers/ui/routes/router.go +++ b/pkg/servers/ui/routes/router.go @@ -11,9 +11,11 @@ func SetupRoutes(app *fiber.App) { // Initialize services and controllers // For now, using the mock process manager processManagerService := models.NewMockProcessManager() + jobManagerService := models.NewMockJobManager() dashboardController := controllers.NewDashboardController() processController := controllers.NewProcessController(processManagerService) + jobController := controllers.NewJobController(jobManagerService) authController := controllers.NewAuthController() // --- Public Routes --- @@ -32,9 +34,36 @@ func SetupRoutes(app *fiber.App) { // For now, routes are public for development ease app.Get("/", dashboardController.ShowDashboard) + + // Process management routes app.Get("/processes", processController.ShowProcessManager) app.Post("/processes/kill/:pid", processController.HandleKillProcess) + // Job management routes + app.Get("/jobs", jobController.ShowJobsPage) + app.Get("/jobs/:id", jobController.ShowJobDetails) + + // Debug routes + app.Get("/debug", func(c *fiber.Ctx) error { + // Get all data from the jobs page to debug + jobManagerService := models.NewMockJobManager() + jobs, _ := jobManagerService.GetAllJobs() + + // Create debug data + debugData := fiber.Map{ + "Title": "Debug Page", + "Jobs": jobs, + "TemplateData": fiber.Map{ + "TotalJobs": len(jobs), + "ActiveJobs": 0, + "CompletedJobs": 0, + "ErrorJobs": 0, + }, + } + + // Return as JSON instead of rendering a template + return c.JSON(debugData) + }) } // TODO: Implement authMiddleware diff --git a/pkg/servers/ui/static/css/jobs.css b/pkg/servers/ui/static/css/jobs.css new file mode 100644 index 0000000..2eec631 --- /dev/null +++ b/pkg/servers/ui/static/css/jobs.css @@ -0,0 +1,167 @@ +/* Job Management UI Styles */ + +/* Tree View Styles */ +.job-tree { + font-size: 0.9rem; + max-height: 600px; + overflow-y: auto; +} + +.circle-node, .topic-node { + margin-bottom: 8px; +} + +.circle-header, .topic-header { + cursor: pointer; + padding: 8px; + border-radius: 4px; + display: flex; + align-items: center; + transition: background-color 0.2s; +} + +.circle-header:hover, .topic-header:hover { + background-color: #f0f0f0; +} + +.circle-name, .topic-name { + margin-left: 8px; + font-weight: 500; +} + +.badge { + margin-left: 8px; +} + +.job-node { + padding: 5px 0; +} + +.job-link { + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.job-link:hover { + background-color: #f0f0f0; +} + +.rotate-90 { + transform: rotate(90deg); + transition: transform 0.2s; +} + +.topics-container, .jobs-container { + padding-top: 8px; + padding-left: 8px; +} + +/* Status Colors */ +.status-new { + color: #0d6efd; +} + +.status-active { + color: #ffc107; +} + +.status-done { + color: #198754; +} + +.status-error { + color: #dc3545; +} + +/* Job Details Page */ +.job-details-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.job-details-title { + font-size: 1.5rem; + font-weight: 600; +} + +pre.error-message { + background-color: #f8d7da; + color: #842029; + padding: 15px; + border-radius: 4px; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +pre.result-content { + background-color: #d1e7dd; + color: #0f5132; + padding: 15px; + border-radius: 4px; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +pre.params-content { + background-color: #f8f9fa; + padding: 15px; + border-radius: 4px; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +/* Job Stats Cards */ +.stats-card { + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: transform 0.2s; +} + +.stats-card:hover { + transform: translateY(-5px); +} + +.stats-card .card-title { + font-size: 1rem; + font-weight: 600; +} + +.stats-card .card-text { + font-size: 2rem; + font-weight: 700; +} + +/* Filter Section */ +.filter-section { + background-color: #f8f9fa; + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; +} + +.filter-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 15px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .job-tree { + max-height: 300px; + } + + .stats-card .card-text { + font-size: 1.5rem; + } +} \ No newline at end of file diff --git a/pkg/servers/ui/views/components/sidebar.jet b/pkg/servers/ui/views/components/sidebar.jet index c3aae15..0139671 100644 --- a/pkg/servers/ui/views/components/sidebar.jet +++ b/pkg/servers/ui/views/components/sidebar.jet @@ -12,6 +12,12 @@ Process Manager + {{ end }} \ No newline at end of file diff --git a/pkg/servers/ui/views/layouts/base.jet b/pkg/servers/ui/views/layouts/base.jet index 6a14a5f..4d5fd43 100644 --- a/pkg/servers/ui/views/layouts/base.jet +++ b/pkg/servers/ui/views/layouts/base.jet @@ -8,6 +8,8 @@ + + {{ block css() }}{{ end }} {{ include "../components/navbar" }} diff --git a/pkg/servers/ui/views/pages/debug.jet b/pkg/servers/ui/views/pages/debug.jet new file mode 100644 index 0000000..7b769b9 --- /dev/null +++ b/pkg/servers/ui/views/pages/debug.jet @@ -0,0 +1,33 @@ +{{ extends "../layouts/base" }} + +{{ block title() }}Debug Template Variables{{ end }} + +{{ block body() }} +
+

Template Variables Debug

+
+
+
Available Variables
+
+
+
{{ dump(.) }}
+
+
+
+{{ end }} + +{{ block scripts() }} + +{{ end }} \ No newline at end of file diff --git a/pkg/servers/ui/views/pages/job_details.jet b/pkg/servers/ui/views/pages/job_details.jet new file mode 100644 index 0000000..617c966 --- /dev/null +++ b/pkg/servers/ui/views/pages/job_details.jet @@ -0,0 +1,172 @@ +{{ extends "../layouts/base" }} + +{{ block title() }}Job Details - HeroApp UI{{ end }} + +{{ block body() }} +
+

Job Details

+
+
+ + + Back to Jobs + + +
+
+
+ +
+
+
+
+
Job #{{ .Job.JobID }}
+ + {{ .Job.Status }} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Job ID{{ .Job.JobID }}
Circle ID{{ .Job.CircleID }}
Topic{{ .Job.Topic }}
Status + + {{ .Job.Status }} + +
Parameters Type{{ .Job.ParamsType }}
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Scheduled Time{{ .Job.TimeScheduled.Format("2006-01-02 15:04:05") }}
Start Time + {{ if not .Job.TimeStart.IsZero }} + {{ .Job.TimeStart.Format("2006-01-02 15:04:05") }} + {{ else }} + Not started + {{ end }} +
End Time + {{ if not .Job.TimeEnd.IsZero }} + {{ .Job.TimeEnd.Format("2006-01-02 15:04:05") }} + {{ else }} + Not completed + {{ end }} +
Duration{{ .Job.Duration }}
Session Key{{ .Job.SessionKey }}
+
+
+
+
+ + + {{ if .Job.HasError }} +
+
+
Error
+
+
+
{{ .Job.Error }}
+
+
+ {{ end }} + + + {{ if .Job.Status == "done" }} +
+
+
Result
+
+
+
{{ .Job.Result }}
+
+
+ {{ end }} + + +
+
+
Parameters
+
+
+
{{ .Job.Params }}
+
+
+
+
+{{ end }} + +{{ block scripts() }} + + +{{ end }} \ No newline at end of file diff --git a/pkg/servers/ui/views/pages/jobs.jet b/pkg/servers/ui/views/pages/jobs.jet new file mode 100644 index 0000000..9a31875 --- /dev/null +++ b/pkg/servers/ui/views/pages/jobs.jet @@ -0,0 +1,224 @@ +{{ extends "../layouts/base" }} + +{{ block title() }}Job Management - HeroApp UI{{ end }} + +{{ block body() }} +
+

Job Management

+
+
+ +
+
+
+ + +
+
+
+
+
Total Jobs
+

0

+
+
+
+
+
+
+
Completed Jobs
+

0

+
+
+
+
+
+
+
Active Jobs
+

0

+
+
+
+
+
+
+
Error Jobs
+

0

+
+
+
+
+ + +
+
+
+
+
Filters
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear Filters +
+
+
+
+
+
+ +
+ +
+
+
+
Job Tree
+
+
+
+

No job tree data available

+
+
+
+
+ + +
+
+
+
Job List
+
+
+
+ + + + + + + + + + + + + + + + + +
IDCircleTopicStatusScheduledDurationActions
No jobs available
+
+
+
+
+
+{{ end }} + +{{ block scripts() }} + + +{{ end }} \ No newline at end of file diff --git a/scripts/restart_server.sh b/scripts/restart_server.sh new file mode 100755 index 0000000..c5047eb --- /dev/null +++ b/scripts/restart_server.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "Stopping any running heroagent processes..." +pkill -f heroagent || true + +echo "Building and starting heroagent with enhanced error reporting..." +cd "$(dirname "$0")/.." +go build -o heroagent cmd/heroagent/main.go +./heroagent -ui=true -redis=false -webdav=false -jobs=false + +echo "Server started!" \ No newline at end of file