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
+
+
+
+ Job 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
+
+
+{{ 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 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 }}
+
+ {{ end }}
+
+
+ {{ if .Job.Status == "done" }}
+
+ {{ end }}
+
+
+
+
+
+{{ 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() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No job tree data available
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ID |
+ Circle |
+ Topic |
+ Status |
+ Scheduled |
+ Duration |
+ Actions |
+
+
+
+
+ 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