...
This commit is contained in:
		| @@ -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(` | ||||
| 					<html> | ||||
| 					<head> | ||||
| 						<title>Template Error</title> | ||||
| 						<style> | ||||
| 							body { font-family: Arial, sans-serif; margin: 20px; } | ||||
| 							.error-box { background-color: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 5px; } | ||||
| 							.error-details { background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 15px; border-radius: 5px; margin-top: 20px; } | ||||
| 							.code-context { background-color: #f0f0f0; padding: 10px; border-radius: 5px; font-family: monospace; } | ||||
| 							pre { white-space: pre-wrap; } | ||||
| 							.highlight { background-color: #ffeb3b; font-weight: bold; } | ||||
| 						</style> | ||||
| 					</head> | ||||
| 					<body> | ||||
| 						<h1>Template Error</h1> | ||||
| 						<div class="error-box"> | ||||
| 							<h3>Error Details:</h3> | ||||
| 							<p><strong>Template:</strong> %s</p> | ||||
| 							<p><strong>Line:</strong> %s</p> | ||||
| 							<p><strong>Missing Variable:</strong> <span class="highlight">%s</span></p> | ||||
| 							<pre>%s</pre> | ||||
| 						</div> | ||||
| 						<div class="error-details"> | ||||
| 							<h3>Debugging Tips:</h3> | ||||
| 							<p>1. Check if the variable <span class="highlight">%s</span> is passed to the template</p> | ||||
| 							<p>2. Visit <a href="/debug">/debug</a> to see available template variables</p> | ||||
| 							<p>3. Check for typos in variable names</p> | ||||
| 							<p>4. Ensure the variable is of the expected type</p> | ||||
| 							<p>5. Check the controller that renders this template to ensure all required data is provided</p> | ||||
| 						</div> | ||||
| 					</body> | ||||
| 					</html> | ||||
| 				`, 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 | ||||
|   | ||||
							
								
								
									
										271
									
								
								pkg/servers/ui/controllers/job_controller.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										271
									
								
								pkg/servers/ui/controllers/job_controller.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										30
									
								
								pkg/servers/ui/controllers/job_page_data.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								pkg/servers/ui/controllers/job_page_data.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										348
									
								
								pkg/servers/ui/models/job_manager.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										348
									
								
								pkg/servers/ui/models/job_manager.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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:<id>" | ||||
| 		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, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										167
									
								
								pkg/servers/ui/static/css/jobs.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								pkg/servers/ui/static/css/jobs.css
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -12,6 +12,12 @@ | ||||
|             Process Manager | ||||
|         </a> | ||||
|     </li> | ||||
|     <li class="nav-item"> | ||||
|         <a class="nav-link" href="/jobs"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-briefcase"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg> | ||||
|             Job Manager | ||||
|         </a> | ||||
|     </li> | ||||
|     <!-- Add more menu items here as needed --> | ||||
| </ul> | ||||
| {{ end }} | ||||
| @@ -8,6 +8,8 @@ | ||||
|     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous"> | ||||
|     <!-- Custom CSS --> | ||||
|     <link rel="stylesheet" href="/static/css/custom.css"> | ||||
|     <link rel="stylesheet" href="/static/css/jobs.css"> | ||||
|     {{ block css() }}{{ end }} | ||||
| </head> | ||||
| <body> | ||||
|     {{ include "../components/navbar" }} | ||||
|   | ||||
							
								
								
									
										33
									
								
								pkg/servers/ui/views/pages/debug.jet
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								pkg/servers/ui/views/pages/debug.jet
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| {{ extends "../layouts/base" }} | ||||
|  | ||||
| {{ block title() }}Debug Template Variables{{ end }} | ||||
|  | ||||
| {{ block body() }} | ||||
| <div class="container mt-4"> | ||||
|     <h1>Template Variables Debug</h1> | ||||
|     <div class="card"> | ||||
|         <div class="card-header"> | ||||
|             <h5>Available Variables</h5> | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|             <pre id="debug-output">{{ dump(.) }}</pre> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| {{ end }} | ||||
|  | ||||
| {{ block scripts() }} | ||||
| <script> | ||||
|     // Format the JSON for better readability | ||||
|     document.addEventListener('DOMContentLoaded', function() { | ||||
|         try { | ||||
|             const debugOutput = document.getElementById('debug-output'); | ||||
|             const content = debugOutput.textContent; | ||||
|             const obj = JSON.parse(content); | ||||
|             debugOutput.textContent = JSON.stringify(obj, null, 2); | ||||
|         } catch (e) { | ||||
|             console.error('Failed to parse JSON:', e); | ||||
|         } | ||||
|     }); | ||||
| </script> | ||||
| {{ end }} | ||||
							
								
								
									
										172
									
								
								pkg/servers/ui/views/pages/job_details.jet
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								pkg/servers/ui/views/pages/job_details.jet
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| {{ extends "../layouts/base" }} | ||||
|  | ||||
| {{ block title() }}Job Details - HeroApp UI{{ end }} | ||||
|  | ||||
| {{ block body() }} | ||||
| <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> | ||||
|     <h1 class="h2">Job Details</h1> | ||||
|     <div class="btn-toolbar mb-2 mb-md-0"> | ||||
|         <div class="btn-group me-2"> | ||||
|             <a href="/jobs" class="btn btn-sm btn-outline-secondary"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg> | ||||
|                 Back to Jobs | ||||
|             </a> | ||||
|             <button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshJobDetails()"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg> | ||||
|                 Refresh | ||||
|             </button> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="row"> | ||||
|     <div class="col-md-12"> | ||||
|         <div class="card mb-4"> | ||||
|             <div class="card-header d-flex justify-content-between align-items-center"> | ||||
|                 <h5>Job #{{ .Job.JobID }}</h5> | ||||
|                 <span class="badge {{ if .Job.Status == "error" }}bg-danger{{ else if .Job.Status == "done" }}bg-success{{ else if .Job.Status == "active" }}bg-warning text-dark{{ else }}bg-primary{{ end }}"> | ||||
|                     {{ .Job.Status }} | ||||
|                 </span> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <div class="row"> | ||||
|                     <div class="col-md-6"> | ||||
|                         <table class="table"> | ||||
|                             <tbody> | ||||
|                                 <tr> | ||||
|                                     <th>Job ID</th> | ||||
|                                     <td>{{ .Job.JobID }}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Circle ID</th> | ||||
|                                     <td>{{ .Job.CircleID }}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Topic</th> | ||||
|                                     <td>{{ .Job.Topic }}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Status</th> | ||||
|                                     <td> | ||||
|                                         <span class="badge {{ if .Job.Status == "error" }}bg-danger{{ else if .Job.Status == "done" }}bg-success{{ else if .Job.Status == "active" }}bg-warning text-dark{{ else }}bg-primary{{ end }}"> | ||||
|                                             {{ .Job.Status }} | ||||
|                                         </span> | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Parameters Type</th> | ||||
|                                     <td>{{ .Job.ParamsType }}</td> | ||||
|                                 </tr> | ||||
|                             </tbody> | ||||
|                         </table> | ||||
|                     </div> | ||||
|                     <div class="col-md-6"> | ||||
|                         <table class="table"> | ||||
|                             <tbody> | ||||
|                                 <tr> | ||||
|                                     <th>Scheduled Time</th> | ||||
|                                     <td>{{ .Job.TimeScheduled.Format("2006-01-02 15:04:05") }}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Start Time</th> | ||||
|                                     <td> | ||||
|                                         {{ if not .Job.TimeStart.IsZero }} | ||||
|                                             {{ .Job.TimeStart.Format("2006-01-02 15:04:05") }} | ||||
|                                         {{ else }} | ||||
|                                             Not started | ||||
|                                         {{ end }} | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>End Time</th> | ||||
|                                     <td> | ||||
|                                         {{ if not .Job.TimeEnd.IsZero }} | ||||
|                                             {{ .Job.TimeEnd.Format("2006-01-02 15:04:05") }} | ||||
|                                         {{ else }} | ||||
|                                             Not completed | ||||
|                                         {{ end }} | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Duration</th> | ||||
|                                     <td>{{ .Job.Duration }}</td> | ||||
|                                 </tr> | ||||
|                                 <tr> | ||||
|                                     <th>Session Key</th> | ||||
|                                     <td>{{ .Job.SessionKey }}</td> | ||||
|                                 </tr> | ||||
|                             </tbody> | ||||
|                         </table> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Error Section (if applicable) --> | ||||
|         {{ if .Job.HasError }} | ||||
|         <div class="card mb-4 border-danger"> | ||||
|             <div class="card-header bg-danger text-white"> | ||||
|                 <h5>Error</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <pre class="error-message">{{ .Job.Error }}</pre> | ||||
|             </div> | ||||
|         </div> | ||||
|         {{ end }} | ||||
|  | ||||
|         <!-- Result Section (if completed) --> | ||||
|         {{ if .Job.Status == "done" }} | ||||
|         <div class="card mb-4 border-success"> | ||||
|             <div class="card-header bg-success text-white"> | ||||
|                 <h5>Result</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <pre class="result-content">{{ .Job.Result }}</pre> | ||||
|             </div> | ||||
|         </div> | ||||
|         {{ end }} | ||||
|  | ||||
|         <!-- Parameters Section --> | ||||
|         <div class="card mb-4"> | ||||
|             <div class="card-header"> | ||||
|                 <h5>Parameters</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <pre class="params-content">{{ .Job.Params }}</pre> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| {{ end }} | ||||
|  | ||||
| {{ block scripts() }} | ||||
| <script> | ||||
|     function refreshJobDetails() { | ||||
|         window.location.reload(); | ||||
|     } | ||||
| </script> | ||||
| <style> | ||||
|     pre.error-message { | ||||
|         background-color: #f8d7da; | ||||
|         color: #842029; | ||||
|         padding: 15px; | ||||
|         border-radius: 4px; | ||||
|         white-space: pre-wrap; | ||||
|     } | ||||
|      | ||||
|     pre.result-content { | ||||
|         background-color: #d1e7dd; | ||||
|         color: #0f5132; | ||||
|         padding: 15px; | ||||
|         border-radius: 4px; | ||||
|         white-space: pre-wrap; | ||||
|     } | ||||
|      | ||||
|     pre.params-content { | ||||
|         background-color: #f8f9fa; | ||||
|         padding: 15px; | ||||
|         border-radius: 4px; | ||||
|         white-space: pre-wrap; | ||||
|     } | ||||
| </style> | ||||
| {{ end }} | ||||
							
								
								
									
										224
									
								
								pkg/servers/ui/views/pages/jobs.jet
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								pkg/servers/ui/views/pages/jobs.jet
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,224 @@ | ||||
| {{ extends "../layouts/base" }} | ||||
|  | ||||
| {{ block title() }}Job Management - HeroApp UI{{ end }} | ||||
|  | ||||
| {{ block body() }} | ||||
| <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> | ||||
|     <h1 class="h2">Job Management</h1> | ||||
|     <div class="btn-toolbar mb-2 mb-md-0"> | ||||
|         <div class="btn-group me-2"> | ||||
|             <button type="button" class="btn btn-sm btn-outline-secondary" onclick="refreshJobs()"> | ||||
|                 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg> | ||||
|                 Refresh | ||||
|             </button> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <!-- Job Stats Cards --> | ||||
| <div class="row mb-4"> | ||||
|     <div class="col-md-3"> | ||||
|         <div class="card text-white bg-primary"> | ||||
|             <div class="card-body"> | ||||
|                 <h5 class="card-title">Total Jobs</h5> | ||||
|                 <p class="card-text display-6">0</p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="col-md-3"> | ||||
|         <div class="card text-white bg-success"> | ||||
|             <div class="card-body"> | ||||
|                 <h5 class="card-title">Completed Jobs</h5> | ||||
|                 <p class="card-text display-6">0</p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="col-md-3"> | ||||
|         <div class="card text-white bg-warning"> | ||||
|             <div class="card-body"> | ||||
|                 <h5 class="card-title">Active Jobs</h5> | ||||
|                 <p class="card-text display-6">0</p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="col-md-3"> | ||||
|         <div class="card text-white bg-danger"> | ||||
|             <div class="card-body"> | ||||
|                 <h5 class="card-title">Error Jobs</h5> | ||||
|                 <p class="card-text display-6">0</p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <!-- Filters --> | ||||
| <div class="row mb-4"> | ||||
|     <div class="col-md-12"> | ||||
|         <div class="card"> | ||||
|             <div class="card-header"> | ||||
|                 <h5>Filters</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <form id="filterForm" action="/jobs" method="get" class="row g-3"> | ||||
|                     <div class="col-md-3"> | ||||
|                         <label for="circle" class="form-label">Circle</label> | ||||
|                         <select class="form-select" id="circle" name="circle" onchange="this.form.submit()"> | ||||
|                             <option value="">All Circles</option> | ||||
|                         </select> | ||||
|                     </div> | ||||
|                     <div class="col-md-3"> | ||||
|                         <label for="topic" class="form-label">Topic</label> | ||||
|                         <select class="form-select" id="topic" name="topic" onchange="this.form.submit()"> | ||||
|                             <option value="">All Topics</option> | ||||
|                         </select> | ||||
|                     </div> | ||||
|                     <div class="col-md-3"> | ||||
|                         <label for="status" class="form-label">Status</label> | ||||
|                         <select class="form-select" id="status" name="status" onchange="this.form.submit()"> | ||||
|                             <option value="">All Statuses</option> | ||||
|                             <option value="new">New</option> | ||||
|                             <option value="active">Active</option> | ||||
|                             <option value="done">Done</option> | ||||
|                             <option value="error">Error</option> | ||||
|                         </select> | ||||
|                     </div> | ||||
|                     <div class="col-md-3 d-flex align-items-end"> | ||||
|                         <button type="submit" class="btn btn-primary">Apply Filters</button> | ||||
|                         <a href="/jobs" class="btn btn-secondary ms-2">Clear Filters</a> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="row"> | ||||
|     <!-- Tree View --> | ||||
|     <div class="col-md-4"> | ||||
|         <div class="card"> | ||||
|             <div class="card-header"> | ||||
|                 <h5>Job Tree</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <div id="jobTree" class="job-tree"> | ||||
|                     <p>No job tree data available</p> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Job List --> | ||||
|     <div class="col-md-8"> | ||||
|         <div class="card"> | ||||
|             <div class="card-header"> | ||||
|                 <h5>Job List</h5> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <div class="table-responsive"> | ||||
|                     <table class="table table-striped table-hover"> | ||||
|                         <thead> | ||||
|                             <tr> | ||||
|                                 <th>ID</th> | ||||
|                                 <th>Circle</th> | ||||
|                                 <th>Topic</th> | ||||
|                                 <th>Status</th> | ||||
|                                 <th>Scheduled</th> | ||||
|                                 <th>Duration</th> | ||||
|                                 <th>Actions</th> | ||||
|                             </tr> | ||||
|                         </thead> | ||||
|                         <tbody> | ||||
|                             <tr> | ||||
|                                 <td colspan="7" class="text-center">No jobs available</td> | ||||
|                             </tr> | ||||
|                         </tbody> | ||||
|                     </table> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| {{ end }} | ||||
|  | ||||
| {{ block scripts() }} | ||||
| <script> | ||||
|     function toggleCircle(element) { | ||||
|         const topicsContainer = element.nextElementSibling; | ||||
|         const chevron = element.querySelector('svg'); | ||||
|          | ||||
|         if (topicsContainer.style.display === 'none') { | ||||
|             topicsContainer.style.display = 'block'; | ||||
|             chevron.classList.add('rotate-90'); | ||||
|         } else { | ||||
|             topicsContainer.style.display = 'none'; | ||||
|             chevron.classList.remove('rotate-90'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function toggleTopic(element) { | ||||
|         const jobsContainer = element.nextElementSibling; | ||||
|         const chevron = element.querySelector('svg'); | ||||
|          | ||||
|         if (jobsContainer.style.display === 'none') { | ||||
|             jobsContainer.style.display = 'block'; | ||||
|             chevron.classList.add('rotate-90'); | ||||
|         } else { | ||||
|             jobsContainer.style.display = 'none'; | ||||
|             chevron.classList.remove('rotate-90'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function refreshJobs() { | ||||
|         window.location.reload(); | ||||
|     } | ||||
| </script> | ||||
| <style> | ||||
|     .job-tree { | ||||
|         font-size: 0.9rem; | ||||
|     } | ||||
|      | ||||
|     .circle-header, .topic-header { | ||||
|         cursor: pointer; | ||||
|         padding: 5px; | ||||
|         border-radius: 4px; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|     } | ||||
|      | ||||
|     .circle-header:hover, .topic-header:hover { | ||||
|         background-color: #f8f9fa; | ||||
|     } | ||||
|      | ||||
|     .circle-name, .topic-name { | ||||
|         margin-left: 5px; | ||||
|         font-weight: 500; | ||||
|     } | ||||
|      | ||||
|     .badge { | ||||
|         margin-left: 8px; | ||||
|     } | ||||
|      | ||||
|     .job-node { | ||||
|         padding: 3px 0; | ||||
|     } | ||||
|      | ||||
|     .job-link { | ||||
|         text-decoration: none; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 5px; | ||||
|     } | ||||
|      | ||||
|     .rotate-90 { | ||||
|         transform: rotate(90deg); | ||||
|     } | ||||
|      | ||||
|     .circle-node, .topic-node { | ||||
|         margin-bottom: 5px; | ||||
|     } | ||||
|      | ||||
|     .topics-container, .jobs-container { | ||||
|         padding-top: 5px; | ||||
|     } | ||||
| </style> | ||||
| {{ end }} | ||||
		Reference in New Issue
	
	Block a user