This commit is contained in:
despiegk 2025-05-24 09:24:19 +04:00
parent e60b9f62f1
commit 8bc1759dcb
14 changed files with 1380 additions and 1 deletions

View File

@ -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 {}

BIN
heroagent

Binary file not shown.

View File

@ -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

View 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
}

View 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
}

View 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,
},
}
}

View File

@ -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

View 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;
}
}

View File

@ -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 }}

View File

@ -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" }}

View 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 }}

View 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 }}

View 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 }}

11
scripts/restart_server.sh Executable file
View File

@ -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!"