Compare commits

...

9 Commits

Author SHA1 Message Date
4a79011793 Update git remote URL from git.ourworld.tf to git.threefold.info 2025-06-15 16:21:09 +02:00
0b62ac9ecd ... 2025-05-24 10:42:24 +04:00
c9b14730ad ... 2025-05-24 10:33:50 +04:00
2ee8a95a90 ... 2025-05-24 09:52:43 +04:00
8bc1759dcb ... 2025-05-24 09:24:19 +04:00
e60b9f62f1 ... 2025-05-24 07:24:17 +04:00
5d241e9ade ... 2025-05-24 07:09:15 +04:00
b8c8da9e31 ... 2025-05-24 06:56:02 +04:00
55a05a5571 ... 2025-05-23 22:12:17 +04:00
110 changed files with 5311 additions and 230 deletions

View File

@@ -11,7 +11,7 @@ import (
"syscall"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/mycelium_client"
"git.threefold.info/herocode/heroagent/pkg/mycelium_client"
)
type config struct {

View File

@@ -8,7 +8,7 @@ import (
"os"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/mycelium_client"
"git.threefold.info/herocode/heroagent/pkg/mycelium_client"
)
func main() {

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/system/stats"
"git.threefold.info/herocode/heroagent/pkg/system/stats"
"github.com/gofiber/fiber/v2"
)

View File

@@ -3,7 +3,7 @@ package api
import (
"time"
"git.ourworld.tf/herocode/heroagent/pkg/sal/executor"
"git.threefold.info/herocode/heroagent/pkg/sal/executor"
"github.com/gofiber/fiber/v2"
)

View File

@@ -7,9 +7,9 @@ import (
"strconv"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager/interfaces"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager/interfaces/openrpc"
"git.threefold.info/herocode/heroagent/pkg/processmanager"
"git.threefold.info/herocode/heroagent/pkg/processmanager/interfaces"
"git.threefold.info/herocode/heroagent/pkg/processmanager/interfaces/openrpc"
"github.com/gofiber/fiber/v2"
)

View File

@@ -12,16 +12,16 @@ import (
"syscall"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/heroagent/api"
"git.ourworld.tf/herocode/heroagent/pkg/heroagent/handlers"
"git.ourworld.tf/herocode/heroagent/pkg/heroagent/pages"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager"
"git.ourworld.tf/herocode/heroagent/pkg/sal/executor"
"git.ourworld.tf/herocode/heroagent/pkg/servers/redisserver"
"git.ourworld.tf/herocode/heroagent/pkg/system/stats"
"git.threefold.info/herocode/heroagent/pkg/heroagent/api"
"git.threefold.info/herocode/heroagent/pkg/heroagent/handlers"
"git.threefold.info/herocode/heroagent/pkg/heroagent/pages"
"git.threefold.info/herocode/heroagent/pkg/processmanager"
"git.threefold.info/herocode/heroagent/pkg/sal/executor"
"git.threefold.info/herocode/heroagent/pkg/servers/redisserver"
"git.threefold.info/herocode/heroagent/pkg/system/stats"
// "git.ourworld.tf/herocode/heroagent/pkg/vfs/interfaces"
// "git.ourworld.tf/herocode/heroagent/pkg/vfs/interfaces/mock"
// "git.threefold.info/herocode/heroagent/pkg/vfs/interfaces"
// "git.threefold.info/herocode/heroagent/pkg/vfs/interfaces/mock"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"

View File

@@ -5,7 +5,7 @@ import (
"log"
"strconv" // Added strconv for JobID parsing
"git.ourworld.tf/herocode/heroagent/pkg/herojobs"
"git.threefold.info/herocode/heroagent/pkg/herojobs"
"github.com/gofiber/fiber/v2"
)

View File

@@ -10,7 +10,7 @@ import (
"net/http/httptest"
"testing"
"git.ourworld.tf/herocode/heroagent/pkg/herojobs"
"git.threefold.info/herocode/heroagent/pkg/herojobs"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

View File

@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/logger"
"git.threefold.info/herocode/heroagent/pkg/logger"
"github.com/gofiber/fiber/v2"
)

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/system/stats"
"git.threefold.info/herocode/heroagent/pkg/system/stats"
"github.com/gofiber/fiber/v2"
)

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager/interfaces"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager/interfaces/openrpc"
"git.threefold.info/herocode/heroagent/pkg/processmanager/interfaces"
"git.threefold.info/herocode/heroagent/pkg/processmanager/interfaces/openrpc"
"github.com/gofiber/fiber/v2"
)

View File

@@ -5,7 +5,7 @@ import (
"strings"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/system/stats"
"git.threefold.info/herocode/heroagent/pkg/system/stats"
"github.com/gofiber/fiber/v2"
"github.com/shirou/gopsutil/v3/host"
)

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/heroagent/handlers"
"git.ourworld.tf/herocode/heroagent/pkg/system/stats"
"git.threefold.info/herocode/heroagent/pkg/heroagent/handlers"
"git.threefold.info/herocode/heroagent/pkg/system/stats"
"github.com/gofiber/fiber/v2"
"github.com/shirou/gopsutil/v3/host"
)

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/herojobs"
"git.threefold.info/herocode/heroagent/pkg/herojobs"
"github.com/gofiber/fiber/v2"
)

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager/interfaces/openrpc"
"git.threefold.info/herocode/heroagent/pkg/processmanager/interfaces/openrpc"
"github.com/gofiber/fiber/v2"
)

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager"
"git.threefold.info/herocode/heroagent/pkg/processmanager"
)
// ProcessDisplayInfo represents information about a process for display purposes

View File

@@ -35,7 +35,7 @@ Key features:
```go
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
// Create a new playbook from HeroScript text

View File

@@ -7,7 +7,7 @@ import (
"os/signal"
"syscall"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory/herohandler"
"git.threefold.info/herocode/heroagent/pkg/handlerfactory/herohandler"
)
func main() {

View File

@@ -8,7 +8,7 @@ import (
"os"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
func main() {

View File

@@ -3,8 +3,8 @@ package internal
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlers"
"git.threefold.info/herocode/heroagent/pkg/handlerfactory"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlers"
)
// ExampleHandler handles example actions

View File

@@ -7,7 +7,7 @@ import (
"os"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/cmd/herohandler/internal"
"git.threefold.info/herocode/heroagent/pkg/heroscript/cmd/herohandler/internal"
)
func main() {

View File

@@ -10,7 +10,7 @@ import (
"syscall"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory/herohandler"
"git.threefold.info/herocode/heroagent/pkg/handlerfactory/herohandler"
)
func main() {

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
const exampleScript = `

View File

@@ -6,7 +6,7 @@ import (
"os"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
"git.threefold.info/herocode/heroagent/pkg/handlerfactory"
)
// runTutorial runs an interactive tutorial demonstrating the VM handler

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
"git.threefold.info/herocode/heroagent/pkg/handlerfactory"
)
// VMHandler handles VM-related actions

View File

@@ -8,7 +8,7 @@ import (
"path/filepath"
"syscall"
"git.ourworld.tf/herocode/heroagent/pkg/handlerfactory"
"git.threefold.info/herocode/heroagent/pkg/handlerfactory"
)
// The tutorial functions are defined in tutorial.go

View File

@@ -5,8 +5,8 @@ import (
"reflect"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/paramsparser"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
// Handler interface defines methods that all handlers must implement

View File

@@ -5,7 +5,7 @@ import (
"reflect"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
// HandlerFactory manages a collection of handlers

View File

@@ -12,7 +12,7 @@ import (
"sync"
"syscall"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
// ANSI color codes for terminal output

View File

@@ -3,7 +3,7 @@ package handlers
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/core"
)
// AuthHandler handles authentication actions

View File

@@ -5,9 +5,9 @@ import (
"reflect"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.threefold.info/herocode/heroagent/pkg/heroscript/paramsparser"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
// BaseHandler provides common functionality for all handlers

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
// HandlerFactory manages a collection of handlers for processing HeroScript commands

View File

@@ -1,7 +1,7 @@
package herohandler
import (
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/core"
)
// GetFactory returns the handler factory

View File

@@ -4,7 +4,7 @@ import (
"log"
"sync"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/herohandler"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/herohandler"
)
func main() {

View File

@@ -4,10 +4,10 @@ import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/core"
// "git.ourworld.tf/herocode/heroagent/pkg/handlerfactory/heroscript/handlerfactory/fakehandler"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/processmanagerhandler"
// "git.threefold.info/herocode/heroagent/pkg/handlerfactory/heroscript/handlerfactory/fakehandler"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/processmanagerhandler"
)
// HeroHandler is the main handler factory that manages all registered handlers

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/playbook"
"git.threefold.info/herocode/heroagent/pkg/heroscript/playbook"
)
func main() {

View File

@@ -3,8 +3,8 @@ package processmanagerhandler
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.ourworld.tf/herocode/heroagent/pkg/processmanager"
"git.threefold.info/herocode/heroagent/pkg/heroscript/handlerfactory/core"
"git.threefold.info/herocode/heroagent/pkg/processmanager"
)
// ProcessManagerHandler handles process manager-related actions

View File

@@ -19,7 +19,7 @@ A Go package for parsing and manipulating parameters from text in a key-value fo
```go
import (
"git.ourworld.tf/herocode/heroagent/pkg/paramsparser"
"git.threefold.info/herocode/heroagent/pkg/paramsparser"
)
// Create a new parser

View File

@@ -4,7 +4,7 @@ package main
import (
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.threefold.info/herocode/heroagent/pkg/heroscript/paramsparser"
)
func main() {

View File

@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/tools"
)
// ParamsParser represents a parameter parser that can handle various parameter sources

View File

@@ -3,8 +3,8 @@ package playbook
import (
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/heroscript/paramsparser"
"git.threefold.info/herocode/heroagent/pkg/tools"
)
// State represents the parser state

View File

@@ -7,7 +7,7 @@ import (
"sort"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/heroscript/paramsparser"
"git.threefold.info/herocode/heroagent/pkg/heroscript/paramsparser"
)
// ActionType represents the type of action

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/heroservices/billing/models"
"git.threefold.info/herocode/heroagent/pkg/heroservices/billing/models"
)
const (

View File

@@ -7,9 +7,9 @@ import (
"os"
"path/filepath"
"git.ourworld.tf/herocode/heroagent/pkg/data/ourdb"
"git.ourworld.tf/herocode/heroagent/pkg/data/radixtree"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/data/ourdb"
"git.threefold.info/herocode/heroagent/pkg/data/radixtree"
"git.threefold.info/herocode/heroagent/pkg/tools"
)
// DBStore represents the central database store for all models

View File

@@ -9,7 +9,7 @@ import (
"syscall"
"time"
openaiproxy "git.ourworld.tf/herocode/heroagent/pkg/heroservices/openaiproxy"
openaiproxy "git.threefold.info/herocode/heroagent/pkg/heroservices/openaiproxy"
"github.com/openai/openai-go/option"
)

View File

@@ -10,7 +10,7 @@ import (
"strings"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/jobsmanager"
"git.threefold.info/herocode/heroagent/pkg/jobsmanager"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
)

View File

@@ -5,49 +5,77 @@ import (
"fmt"
"log"
"os"
"strconv"
"git.ourworld.tf/herocode/heroagent/pkg/heroagent"
"git.ourworld.tf/herocode/heroagent/pkg/servers/ui" // Import the new UI package
"git.threefold.info/herocode/heroagent/pkg/servers/heroagent"
)
func main() {
// Parse command-line flags
portFlag := flag.String("port", "", "Port to run the HeroLauncher on")
uiPortFlag := flag.String("uiport", "3000", "Port to run the UI server on") // New flag for UI port
redisPortFlag := flag.Int("redisport", 6378, "Port to run the Redis server on")
webdavPortFlag := flag.Int("webdavport", 9001, "Port to run the WebDAV server on")
uiPortFlag := flag.Int("uiport", 9002, "Port to run the UI server on")
// Flags to enable/disable specific servers
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()
// Initialize HeroLauncher with default configuration
// Initialize ServerFactory with default configuration
config := heroagent.DefaultConfig()
// Override with command-line flags if provided
if *portFlag != "" {
config.Port = *portFlag
}
config.Redis.TCPPort = *redisPortFlag
config.WebDAV.Config.TCPPort = *webdavPortFlag
config.UI.Port = strconv.Itoa(*uiPortFlag)
// Set server enable flags
config.EnableRedis = *enableRedisFlag
config.EnableWebDAV = *enableWebDAVFlag
config.EnableUI = *enableUIFlag
config.EnableJobs = *enableJobsFlag
// Override with environment variables if provided
if port := os.Getenv("PORT"); port != "" {
config.Port = port
if redisPortStr := os.Getenv("REDIS_PORT"); redisPortStr != "" {
if port, err := strconv.Atoi(redisPortStr); err == nil {
config.Redis.TCPPort = port
}
}
if webdavPortStr := os.Getenv("WEBDAV_PORT"); webdavPortStr != "" {
if port, err := strconv.Atoi(webdavPortStr); err == nil {
config.WebDAV.Config.TCPPort = port
}
}
if uiPort := os.Getenv("UI_PORT"); uiPort != "" {
config.UI.Port = uiPort
}
// Create HeroLauncher instance
launcher := heroagent.New(config)
// Create ServerFactory instance
factory := heroagent.New(config)
// Initialize and start the UI server in a new goroutine
go func() {
uiApp := ui.NewApp(ui.AppConfig{}) // Assuming default AppConfig is fine
uiPort := *uiPortFlag
if envUiPort := os.Getenv("UIPORT"); envUiPort != "" {
uiPort = envUiPort
}
fmt.Printf("Starting UI server on port %s...\n", uiPort)
if err := uiApp.Listen(":" + uiPort); err != nil {
log.Printf("Failed to start UI server: %v", err) // Use Printf to not exit main app
}
}()
// Start the main HeroLauncher server
fmt.Printf("Starting HeroLauncher on port %s...\n", config.Port)
if err := launcher.Start(); err != nil {
log.Fatalf("Failed to start HeroLauncher: %v", err)
// Start all servers
fmt.Println("Starting HeroAgent servers...")
if err := factory.Start(); err != nil {
log.Fatalf("Failed to start servers: %v", err)
}
fmt.Printf("All servers started successfully:\n")
if config.EnableRedis {
fmt.Printf("- Redis server running on port %d\n", config.Redis.TCPPort)
}
if config.EnableWebDAV {
fmt.Printf("- WebDAV server running on port %d\n", config.WebDAV.Config.TCPPort)
}
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 {}
}

83
cmd/jobtest/main.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"git.threefold.info/herocode/heroagent/pkg/servers/heroagent"
)
func main() {
log.Println("Starting job management test...")
// Create a configuration for the server factory
config := heroagent.DefaultConfig()
// Customize configuration if needed
config.Redis.TCPPort = 6379
config.Redis.UnixSocketPath = "" // Use TCP connection only
config.Jobs.OurDBPath = "/tmp/jobsdb"
config.Jobs.WorkerCount = 3
config.Jobs.QueuePollInterval = 200 * time.Millisecond
// Only enable Redis and Jobs for this test
config.EnableRedis = true
config.EnableWebDAV = false
config.EnableUI = false
config.EnableJobs = true
// Create server factory
factory := heroagent.New(config)
// Start servers
if err := factory.Start(); err != nil {
log.Fatalf("Failed to start servers: %v", err)
}
// Get job manager
jobManager := factory.GetJobManager()
if jobManager == nil {
log.Fatalf("Job manager not initialized")
}
// Create some test jobs
createTestJobs(jobManager)
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
// Stop servers
if err := factory.Stop(); err != nil {
log.Fatalf("Failed to stop servers: %v", err)
}
log.Println("Job management test completed")
}
func createTestJobs(jobManager *heroagent.JobManager) {
// Create a few test jobs with different topics
topics := []string{"email", "notification", "report"}
for i := 0; i < 5; i++ {
for _, topic := range topics {
// Create job
params := fmt.Sprintf(`{"action": "process", "data": "test data %d for %s"}`, i, topic)
job, err := jobManager.CreateJob(topic, params)
if err != nil {
log.Printf("Failed to create job: %v", err)
continue
}
log.Printf("Created job %d with topic %s", job.JobID, job.Topic)
}
// Sleep briefly between batches
time.Sleep(500 * time.Millisecond)
}
}

86
cmd/orpctest/main.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"git.threefold.info/herocode/heroagent/pkg/openrpc"
)
func main() {
// Parse command line flags
var (
specDir = flag.String("dir", "pkg/openrpc/services", "Directory containing OpenRPC specifications")
specName = flag.String("spec", "", "Name of the specification to display (optional)")
methodName = flag.String("method", "", "Name of the method to display (optional)")
)
flag.Parse()
// Create a new OpenRPC Manager
manager := openrpc.NewORPCManager()
// Ensure the specification directory exists
if _, err := os.Stat(*specDir); os.IsNotExist(err) {
log.Fatalf("Specification directory does not exist: %s", *specDir)
}
// Load all specifications from the directory
log.Printf("Loading specifications from %s...", *specDir)
if err := manager.LoadSpecs(*specDir); err != nil {
log.Fatalf("Failed to load specifications: %v", err)
}
// List all loaded specifications
specs := manager.ListSpecs()
if len(specs) == 0 {
log.Fatalf("No specifications found in %s", *specDir)
}
fmt.Println("Loaded specifications:")
for _, spec := range specs {
fmt.Printf("- %s\n", spec)
}
// If a specification name is provided, display its methods
if *specName != "" {
spec := manager.GetSpec(*specName)
if spec == nil {
log.Fatalf("Specification not found: %s", *specName)
}
fmt.Printf("\nMethods in %s specification:\n", *specName)
methods := manager.ListMethods(*specName)
for _, method := range methods {
fmt.Printf("- %s\n", method)
}
// If a method name is provided, display its details
if *methodName != "" {
method := manager.GetMethod(*specName, *methodName)
if method == nil {
log.Fatalf("Method not found: %s", *methodName)
}
fmt.Printf("\nDetails for method '%s':\n", *methodName)
fmt.Printf("Description: %s\n", method.Description)
fmt.Printf("Parameters: %d\n", len(method.Params))
if len(method.Params) > 0 {
fmt.Println("Parameter list:")
for _, param := range method.Params {
required := ""
if param.Required {
required = " (required)"
}
fmt.Printf(" - %s%s: %s\n", param.Name, required, param.Description)
}
}
fmt.Printf("Result: %s\n", method.Result.Name)
fmt.Printf("Examples: %d\n", len(method.Examples))
fmt.Printf("Errors: %d\n", len(method.Errors))
}
}
}

3
go.mod
View File

@@ -1,10 +1,11 @@
module git.ourworld.tf/herocode/heroagent
module git.threefold.info/herocode/heroagent
go 1.23.0
toolchain go1.23.6
require (
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.8
github.com/gofiber/template/jet/v2 v2.1.12
github.com/mholt/archiver/v3 v3.5.1

14
go.sum
View File

@@ -54,11 +54,15 @@ github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj6
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
@@ -141,6 +145,12 @@ github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode/v2 v2.0.0-beta.4 h1:sdiJxQdPjECn2lh9nLFFhgLCf+0ulDU5rODbtERTlUY=
github.com/nwaples/rardecode/v2 v2.0.0-beta.4/go.mod h1:yntwv/HfMc/Hbvtq9I19D1n58te3h6KsqCf3GxyfBGY=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -376,7 +386,11 @@ google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
heroagent Executable file

Binary file not shown.

239
openrpc_manager_plan.md Normal file
View File

@@ -0,0 +1,239 @@
# OpenRPC Manager Implementation Plan
## 1. Understanding the Requirements
The task requires us to:
- Create an OpenRPC Manager (ORPCManager)
- Read JSON files from services in pkg/openrpc
- Create a model for OpenRPC spec in a separate file
- Read the OpenRPC specs into the model
- Keep these models in memory in the ORPCManager
- Create supporting methods like list_methods
- Create a command in @cmd/ to test this behavior
## 2. Project Structure
Here's the proposed file structure for our implementation:
```
pkg/
openrpc/
models/
spec.go # OpenRPC specification model
manager.go # ORPCManager implementation
cmd/
orpctest/
main.go # Test command for the ORPCManager
```
## 3. Implementation Details
### 3.1 OpenRPC Specification Model (pkg/openrpc/models/spec.go)
We'll create a Go struct model that represents the OpenRPC specification based on the structure observed in zinit.json:
```mermaid
classDiagram
class OpenRPCSpec {
+string OpenRPC
+InfoObject Info
+Server[] Servers
+Method[] Methods
}
class InfoObject {
+string Version
+string Title
+string Description
+LicenseObject License
}
class LicenseObject {
+string Name
}
class Server {
+string Name
+string URL
}
class Method {
+string Name
+string Description
+Parameter[] Params
+ResultObject Result
+Example[] Examples
+ErrorObject[] Errors
}
class Parameter {
+string Name
+string Description
+bool Required
+SchemaObject Schema
}
class ResultObject {
+string Name
+string Description
+SchemaObject Schema
}
class SchemaObject {
+string Type
+map[string]interface{} Properties
+SchemaObject Items
+map[string]SchemaObject AdditionalProperties
}
class Example {
+string Name
+map[string]interface{}[] Params
+ExampleResultObject Result
}
class ExampleResultObject {
+string Name
+interface{} Value
}
class ErrorObject {
+int Code
+string Message
+string Data
}
OpenRPCSpec --> InfoObject
OpenRPCSpec --> Server
OpenRPCSpec --> Method
Method --> Parameter
Method --> ResultObject
Method --> Example
Method --> ErrorObject
Parameter --> SchemaObject
ResultObject --> SchemaObject
Example --> ExampleResultObject
```
### 3.2 OpenRPC Manager (pkg/openrpc/manager.go)
The ORPCManager will be responsible for:
- Loading OpenRPC specifications from JSON files
- Storing and managing these specifications in memory
- Providing methods to access and manipulate the specifications
```mermaid
classDiagram
class ORPCManager {
-map[string]*OpenRPCSpec specs
+NewORPCManager() *ORPCManager
+LoadSpecs(dir string) error
+LoadSpec(path string) error
+GetSpec(name string) *OpenRPCSpec
+ListSpecs() []string
+ListMethods(specName string) []string
+GetMethod(specName string, methodName string) *Method
}
ORPCManager --> OpenRPCSpec
```
### 3.3 Test Command (cmd/orpctest/main.go)
We'll create a command-line tool to test the ORPCManager functionality:
- Initialize the ORPCManager
- Load specifications from the pkg/openrpc/services directory
- List available specifications
- List methods for each specification
- Display details for specific methods
## 4. Implementation Steps
1. **Create the OpenRPC Specification Model**:
- Define the Go structs for the OpenRPC specification
- Implement JSON marshaling/unmarshaling
- Add validation functions
2. **Implement the ORPCManager**:
- Create the manager struct with a map to store specifications
- Implement methods to load specifications from files
- Implement methods to access and manipulate specifications
3. **Create the Test Command**:
- Implement a command-line interface to test the ORPCManager
- Add options to list specifications, methods, and display details
4. **Write Tests**:
- Write unit tests for the OpenRPC model
- Write unit tests for the ORPCManager
- Write integration tests for the entire system
## 5. Detailed Method Specifications
### 5.1 ORPCManager Methods
#### NewORPCManager()
- Creates a new instance of the ORPCManager
- Initializes the specs map
#### LoadSpecs(dir string) error
- Reads all JSON files in the specified directory
- For each file, calls LoadSpec()
- Returns an error if any file fails to load
#### LoadSpec(path string) error
- Reads the JSON file at the specified path
- Parses the JSON into an OpenRPCSpec struct
- Validates the specification
- Stores the specification in the specs map using the filename (without extension) as the key
- Returns an error if any step fails
#### GetSpec(name string) *OpenRPCSpec
- Returns the OpenRPCSpec with the specified name
- Returns nil if the specification doesn't exist
#### ListSpecs() []string
- Returns a list of all loaded specification names
#### ListMethods(specName string) []string
- Returns a list of all method names in the specified specification
- Returns an empty list if the specification doesn't exist
#### GetMethod(specName string, methodName string) *Method
- Returns the Method with the specified name from the specified specification
- Returns nil if the specification or method doesn't exist
## 6. Example Usage
```go
// Initialize the ORPCManager
manager := openrpc.NewORPCManager()
// Load all specifications from the services directory
err := manager.LoadSpecs("pkg/openrpc/services")
if err != nil {
log.Fatalf("Failed to load specifications: %v", err)
}
// List all loaded specifications
specs := manager.ListSpecs()
fmt.Println("Loaded specifications:")
for _, spec := range specs {
fmt.Printf("- %s\n", spec)
}
// List all methods in the zinit specification
methods := manager.ListMethods("zinit")
fmt.Println("\nMethods in zinit specification:")
for _, method := range methods {
fmt.Printf("- %s\n", method)
}
// Get details for a specific method
method := manager.GetMethod("zinit", "service_list")
if method != nil {
fmt.Printf("\nDetails for method 'service_list':\n")
fmt.Printf("Description: %s\n", method.Description)
fmt.Printf("Parameters: %d\n", len(method.Params))
fmt.Printf("Examples: %d\n", len(method.Examples))
}

BIN
orpctest Executable file

Binary file not shown.

View File

@@ -14,7 +14,7 @@ Dedupestor is a Go package that provides a key-value store with deduplication ba
```go
import (
"git.ourworld.tf/herocode/heroagent/pkg/dedupestor"
"git.threefold.info/herocode/heroagent/pkg/dedupestor"
)
// Create a new dedupe store

View File

@@ -7,8 +7,8 @@ import (
"errors"
"path/filepath"
"git.ourworld.tf/herocode/heroagent/pkg/data/ourdb"
"git.ourworld.tf/herocode/heroagent/pkg/data/radixtree"
"git.threefold.info/herocode/heroagent/pkg/data/ourdb"
"git.threefold.info/herocode/heroagent/pkg/data/radixtree"
)
// MaxValueSize is the maximum allowed size for values (1MB)

View File

@@ -18,7 +18,7 @@ The DocTree package provides functionality for managing collections of markdown
### Creating a DocTree
```go
import "git.ourworld.tf/herocode/heroagent/pkg/doctree"
import "git.threefold.info/herocode/heroagent/pkg/doctree"
// Create a new DocTree with a path and name
dt, err := doctree.New("/path/to/collection", "My Collection")

View File

@@ -7,7 +7,7 @@ import (
"path/filepath"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/tools"
)
// Collection represents a collection of markdown pages and files

View File

@@ -5,7 +5,7 @@ import (
"context"
"fmt"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/tools"
"github.com/redis/go-redis/v9"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/tools"
)
// Global variable to track the current DocTree instance

View File

@@ -34,7 +34,7 @@ import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/ourdb"
"git.threefold.info/herocode/heroagent/pkg/ourdb"
)
func main() {
@@ -78,7 +78,7 @@ import (
"fmt"
"log"
"git.ourworld.tf/herocode/heroagent/pkg/ourdb"
"git.threefold.info/herocode/heroagent/pkg/ourdb"
)
func main() {

View File

@@ -4,7 +4,7 @@ package radixtree
import (
"errors"
"git.ourworld.tf/herocode/heroagent/pkg/data/ourdb"
"git.threefold.info/herocode/heroagent/pkg/data/ourdb"
)
// Node represents a node in the radix tree

106
pkg/herojobs/factory.go Normal file
View File

@@ -0,0 +1,106 @@
package herojobs
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
const (
defaultRedisURL = "redis://localhost:6379/0"
)
// Factory manages job-related operations, including Redis connectivity and watchdog.
type Factory struct {
redisClient *redis.Client
// Add other fields as needed, e.g., for watchdog
}
// NewFactory creates a new Factory instance.
// It takes a redisURL string; if empty, it defaults to defaultRedisURL.
func NewFactory(redisURL string) (*Factory, error) {
if redisURL == "" {
redisURL = defaultRedisURL
}
opt, err := redis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("invalid redis URL: %w", err)
}
client := redis.NewClient(opt)
// Check connection to Redis
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = client.Ping(ctx).Result()
if err != nil {
return nil, fmt.Errorf("failed to connect to redis at %s: %w", redisURL, err)
}
fmt.Printf("Successfully connected to Redis at %s\n", redisURL)
factory := &Factory{
redisClient: client,
}
// TODO: Properly start the watchdog here
fmt.Println("Watchdog placeholder: Watchdog would be started here.")
return factory, nil
}
// Close closes the Redis client connection.
func (f *Factory) Close() error {
if f.redisClient != nil {
return f.redisClient.Close()
}
return nil
}
// GetJob retrieves a job by its ID from Redis.
func (f *Factory) GetJob(ctx context.Context, jobID string) (string, error) {
// Example: Assuming jobs are stored as string values
val, err := f.redisClient.Get(ctx, jobID).Result()
if err == redis.Nil {
return "", fmt.Errorf("job with ID %s not found", jobID)
} else if err != nil {
return "", fmt.Errorf("failed to get job %s from redis: %w", jobID, err)
}
return val, nil
}
// ListJobs lists all job IDs (or a subset) from Redis.
// This is a simplified example; real-world job listing might involve more complex data structures.
func (f *Factory) ListJobs(ctx context.Context) ([]string, error) {
// Example: List all keys that might represent jobs.
// In a real application, you'd likely use specific Redis data structures (e.g., sorted sets, hashes)
// to manage jobs more efficiently and avoid scanning all keys.
keys, err := f.redisClient.Keys(ctx, "job:*").Result() // Assuming job keys are prefixed with "job:"
if err != nil {
return nil, fmt.Errorf("failed to list jobs from redis: %w", err)
}
return keys, nil
}
// AddJob adds a new job to Redis.
func (f *Factory) AddJob(ctx context.Context, jobID string, jobData string) error {
// Example: Store job data as a string
err := f.redisClient.Set(ctx, jobID, jobData, 0).Err() // 0 for no expiration
if err != nil {
return fmt.Errorf("failed to add job %s to redis: %w", jobID, err)
}
return nil
}
// DeleteJob deletes a job from Redis.
func (f *Factory) DeleteJob(ctx context.Context, jobID string) error {
_, err := f.redisClient.Del(ctx, jobID).Result()
if err != nil {
return fmt.Errorf("failed to delete job %s from redis: %w", jobID, err)
}
return nil
}

View File

@@ -7,8 +7,8 @@ import (
"path/filepath"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/data/ourdb"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/data/ourdb"
"git.threefold.info/herocode/heroagent/pkg/tools"
)
// JobStatus represents the status of a job

View File

@@ -6,7 +6,7 @@ import (
"strings"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/tools"
"git.threefold.info/herocode/heroagent/pkg/tools"
"github.com/redis/go-redis/v9"
)

View File

@@ -6,7 +6,7 @@ import (
"path/filepath"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/logger"
"git.threefold.info/herocode/heroagent/pkg/logger"
)
func main() {

117
pkg/openrpc/manager.go Normal file
View File

@@ -0,0 +1,117 @@
package openrpc
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"git.threefold.info/herocode/heroagent/pkg/openrpc/models"
)
// ORPCManager manages OpenRPC specifications
type ORPCManager struct {
specs map[string]*models.OpenRPCSpec
}
// NewORPCManager creates a new OpenRPC Manager
func NewORPCManager() *ORPCManager {
return &ORPCManager{
specs: make(map[string]*models.OpenRPCSpec),
}
}
// LoadSpecs loads all OpenRPC specifications from a directory
func (m *ORPCManager) LoadSpecs(dir string) error {
files, err := ioutil.ReadDir(dir)
if err != nil {
return fmt.Errorf("failed to read directory: %w", err)
}
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.HasSuffix(file.Name(), ".json") {
continue
}
path := filepath.Join(dir, file.Name())
if err := m.LoadSpec(path); err != nil {
return fmt.Errorf("failed to load spec %s: %w", file.Name(), err)
}
}
return nil
}
// LoadSpec loads an OpenRPC specification from a file
func (m *ORPCManager) LoadSpec(path string) error {
// Read the file
data, err := ioutil.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Parse the JSON
var spec models.OpenRPCSpec
if err := json.Unmarshal(data, &spec); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
// Validate the specification
if err := spec.Validate(); err != nil {
return fmt.Errorf("invalid specification: %w", err)
}
// Store the specification
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
m.specs[name] = &spec
return nil
}
// GetSpec returns an OpenRPC specification by name
func (m *ORPCManager) GetSpec(name string) *models.OpenRPCSpec {
return m.specs[name]
}
// ListSpecs returns a list of all loaded specification names
func (m *ORPCManager) ListSpecs() []string {
var names []string
for name := range m.specs {
names = append(names, name)
}
return names
}
// ListMethods returns a list of all method names in a specification
func (m *ORPCManager) ListMethods(specName string) []string {
spec := m.GetSpec(specName)
if spec == nil {
return []string{}
}
var methods []string
for _, method := range spec.Methods {
methods = append(methods, method.Name)
}
return methods
}
// GetMethod returns a method from a specification
func (m *ORPCManager) GetMethod(specName, methodName string) *models.Method {
spec := m.GetSpec(specName)
if spec == nil {
return nil
}
for _, method := range spec.Methods {
if method.Name == methodName {
return &method
}
}
return nil
}

View File

@@ -0,0 +1,89 @@
package models
// OpenRPCSpec represents an OpenRPC specification document
type OpenRPCSpec struct {
OpenRPC string `json:"openrpc"`
Info InfoObject `json:"info"`
Servers []Server `json:"servers"`
Methods []Method `json:"methods"`
}
// InfoObject contains metadata about the API
type InfoObject struct {
Version string `json:"version"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
License *LicenseObject `json:"license,omitempty"`
}
// LicenseObject contains license information for the API
type LicenseObject struct {
Name string `json:"name"`
}
// Server represents a server that provides the API
type Server struct {
Name string `json:"name"`
URL string `json:"url"`
}
// Method represents a method in the API
type Method struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Params []Parameter `json:"params"`
Result ResultObject `json:"result"`
Examples []Example `json:"examples,omitempty"`
Errors []ErrorObject `json:"errors,omitempty"`
}
// Parameter represents a parameter for a method
type Parameter struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Required bool `json:"required"`
Schema SchemaObject `json:"schema"`
}
// ResultObject represents the result of a method
type ResultObject struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Schema SchemaObject `json:"schema"`
}
// SchemaObject represents a JSON Schema object
type SchemaObject struct {
Type string `json:"type,omitempty"`
Properties map[string]SchemaObject `json:"properties,omitempty"`
Items *SchemaObject `json:"items,omitempty"`
AdditionalProperties *SchemaObject `json:"additionalProperties,omitempty"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
}
// Example represents an example for a method
type Example struct {
Name string `json:"name"`
Params []map[string]interface{} `json:"params"`
Result ExampleResultObject `json:"result"`
}
// ExampleResultObject represents the result of an example
type ExampleResultObject struct {
Name string `json:"name"`
Value interface{} `json:"value"`
}
// ErrorObject represents an error that can be returned by a method
type ErrorObject struct {
Code int `json:"code"`
Message string `json:"message"`
Data string `json:"data,omitempty"`
}
// Validate validates the OpenRPC specification
func (spec *OpenRPCSpec) Validate() error {
// TODO: Implement validation logic
return nil
}

View File

@@ -0,0 +1,873 @@
{
"openrpc": "1.2.6",
"info": {
"version": "1.0.0",
"title": "Zinit JSON-RPC API",
"description": "JSON-RPC 2.0 API for controlling and querying Zinit services",
"license": {
"name": "MIT"
}
},
"servers": [
{
"name": "Unix Socket",
"url": "unix:///tmp/zinit.sock"
}
],
"methods": [
{
"name": "rpc_discover",
"description": "Returns the OpenRPC specification for the API",
"params": [],
"result": {
"name": "OpenRPCSpec",
"description": "The OpenRPC specification",
"schema": {
"type": "object"
}
},
"examples": [
{
"name": "Get API specification",
"params": [],
"result": {
"name": "OpenRPCSpecResult",
"value": {
"openrpc": "1.2.6",
"info": {
"version": "1.0.0",
"title": "Zinit JSON-RPC API"
}
}
}
}
]
},
{
"name": "service_list",
"description": "Lists all services managed by Zinit",
"params": [],
"result": {
"name": "ServiceList",
"description": "A map of service names to their current states",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string",
"description": "Service state (Running, Success, Error, etc.)"
}
}
},
"examples": [
{
"name": "List all services",
"params": [],
"result": {
"name": "ServiceListResult",
"value": {
"service1": "Running",
"service2": "Success",
"service3": "Error"
}
}
}
]
},
{
"name": "service_status",
"description": "Shows detailed status information for a specific service",
"params": [
{
"name": "name",
"description": "The name of the service",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "ServiceStatus",
"description": "Detailed status information for the service",
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Service name"
},
"pid": {
"type": "integer",
"description": "Process ID of the running service (if running)"
},
"state": {
"type": "string",
"description": "Current state of the service (Running, Success, Error, etc.)"
},
"target": {
"type": "string",
"description": "Target state of the service (Up, Down)"
},
"after": {
"type": "object",
"description": "Dependencies of the service and their states",
"additionalProperties": {
"type": "string",
"description": "State of the dependency"
}
}
}
}
},
"examples": [
{
"name": "Get status of redis service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "ServiceStatusResult",
"value": {
"name": "redis",
"pid": 1234,
"state": "Running",
"target": "Up",
"after": {
"dependency1": "Success",
"dependency2": "Running"
}
}
}
}
],
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "service name \"unknown\" unknown"
}
]
},
{
"name": "service_start",
"description": "Starts a service",
"params": [
{
"name": "name",
"description": "The name of the service to start",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "StartResult",
"description": "Result of the start operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Start redis service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "StartResult",
"value": null
}
}
],
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "service name \"unknown\" unknown"
}
]
},
{
"name": "service_stop",
"description": "Stops a service",
"params": [
{
"name": "name",
"description": "The name of the service to stop",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "StopResult",
"description": "Result of the stop operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Stop redis service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "StopResult",
"value": null
}
}
],
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "service name \"unknown\" unknown"
},
{
"code": -32003,
"message": "Service is down",
"data": "service \"redis\" is down"
}
]
},
{
"name": "service_monitor",
"description": "Starts monitoring a service. The service configuration is loaded from the config directory.",
"params": [
{
"name": "name",
"description": "The name of the service to monitor",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "MonitorResult",
"description": "Result of the monitor operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Monitor redis service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "MonitorResult",
"value": null
}
}
],
"errors": [
{
"code": -32001,
"message": "Service already monitored",
"data": "service \"redis\" already monitored"
},
{
"code": -32005,
"message": "Config error",
"data": "failed to load service configuration"
}
]
},
{
"name": "service_forget",
"description": "Stops monitoring a service. You can only forget a stopped service.",
"params": [
{
"name": "name",
"description": "The name of the service to forget",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "ForgetResult",
"description": "Result of the forget operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Forget redis service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "ForgetResult",
"value": null
}
}
],
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "service name \"unknown\" unknown"
},
{
"code": -32002,
"message": "Service is up",
"data": "service \"redis\" is up"
}
]
},
{
"name": "service_kill",
"description": "Sends a signal to a running service",
"params": [
{
"name": "name",
"description": "The name of the service to send the signal to",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "signal",
"description": "The signal to send (e.g., SIGTERM, SIGKILL)",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "KillResult",
"description": "Result of the kill operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Send SIGTERM to redis service",
"params": [
{
"name": "name",
"value": "redis"
},
{
"name": "signal",
"value": "SIGTERM"
}
],
"result": {
"name": "KillResult",
"value": null
}
}
],
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "service name \"unknown\" unknown"
},
{
"code": -32003,
"message": "Service is down",
"data": "service \"redis\" is down"
},
{
"code": -32004,
"message": "Invalid signal",
"data": "invalid signal: INVALID"
}
]
},
{
"name": "system_shutdown",
"description": "Stops all services and powers off the system",
"params": [],
"result": {
"name": "ShutdownResult",
"description": "Result of the shutdown operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Shutdown the system",
"params": [],
"result": {
"name": "ShutdownResult",
"value": null
}
}
],
"errors": [
{
"code": -32006,
"message": "Shutting down",
"data": "system is already shutting down"
}
]
},
{
"name": "system_reboot",
"description": "Stops all services and reboots the system",
"params": [],
"result": {
"name": "RebootResult",
"description": "Result of the reboot operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Reboot the system",
"params": [],
"result": {
"name": "RebootResult",
"value": null
}
}
],
"errors": [
{
"code": -32006,
"message": "Shutting down",
"data": "system is already shutting down"
}
]
},
{
"name": "service_create",
"description": "Creates a new service configuration file",
"params": [
{
"name": "name",
"description": "The name of the service to create",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "content",
"description": "The service configuration content",
"required": true,
"schema": {
"type": "object",
"properties": {
"exec": {
"type": "string",
"description": "Command to run"
},
"oneshot": {
"type": "boolean",
"description": "Whether the service should be restarted"
},
"after": {
"type": "array",
"items": {
"type": "string"
},
"description": "Services that must be running before this one starts"
},
"log": {
"type": "string",
"enum": ["null", "ring", "stdout"],
"description": "How to handle service output"
},
"env": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Environment variables for the service"
},
"shutdown_timeout": {
"type": "integer",
"description": "Maximum time to wait for service to stop during shutdown"
}
}
}
}
],
"result": {
"name": "CreateServiceResult",
"description": "Result of the create operation",
"schema": {
"type": "string"
}
},
"errors": [
{
"code": -32007,
"message": "Service already exists",
"data": "Service 'name' already exists"
},
{
"code": -32008,
"message": "Service file error",
"data": "Failed to create service file"
}
]
},
{
"name": "service_delete",
"description": "Deletes a service configuration file",
"params": [
{
"name": "name",
"description": "The name of the service to delete",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "DeleteServiceResult",
"description": "Result of the delete operation",
"schema": {
"type": "string"
}
},
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "Service 'name' not found"
},
{
"code": -32008,
"message": "Service file error",
"data": "Failed to delete service file"
}
]
},
{
"name": "service_get",
"description": "Gets a service configuration file",
"params": [
{
"name": "name",
"description": "The name of the service to get",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "GetServiceResult",
"description": "The service configuration",
"schema": {
"type": "object"
}
},
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "Service 'name' not found"
},
{
"code": -32008,
"message": "Service file error",
"data": "Failed to read service file"
}
]
},
{
"name": "service_stats",
"description": "Get memory and CPU usage statistics for a service",
"params": [
{
"name": "name",
"description": "The name of the service to get stats for",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "ServiceStats",
"description": "Memory and CPU usage statistics for the service",
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Service name"
},
"pid": {
"type": "integer",
"description": "Process ID of the service"
},
"memory_usage": {
"type": "integer",
"description": "Memory usage in bytes"
},
"cpu_usage": {
"type": "number",
"description": "CPU usage as a percentage (0-100)"
},
"children": {
"type": "array",
"description": "Stats for child processes",
"items": {
"type": "object",
"properties": {
"pid": {
"type": "integer",
"description": "Process ID of the child process"
},
"memory_usage": {
"type": "integer",
"description": "Memory usage in bytes"
},
"cpu_usage": {
"type": "number",
"description": "CPU usage as a percentage (0-100)"
}
}
}
}
}
}
},
"examples": [
{
"name": "Get stats for redis service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "ServiceStatsResult",
"value": {
"name": "redis",
"pid": 1234,
"memory_usage": 10485760,
"cpu_usage": 2.5,
"children": [
{
"pid": 1235,
"memory_usage": 5242880,
"cpu_usage": 1.2
}
]
}
}
}
],
"errors": [
{
"code": -32000,
"message": "Service not found",
"data": "service name \"unknown\" unknown"
},
{
"code": -32003,
"message": "Service is down",
"data": "service \"redis\" is down"
}
]
},
{
"name": "system_start_http_server",
"description": "Start an HTTP/RPC server at the specified address",
"params": [
{
"name": "address",
"description": "The network address to bind the server to (e.g., '127.0.0.1:8080')",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "StartHttpServerResult",
"description": "Result of the start HTTP server operation",
"schema": {
"type": "string"
}
},
"examples": [
{
"name": "Start HTTP server on localhost:8080",
"params": [
{
"name": "address",
"value": "127.0.0.1:8080"
}
],
"result": {
"name": "StartHttpServerResult",
"value": "HTTP server started at 127.0.0.1:8080"
}
}
],
"errors": [
{
"code": -32602,
"message": "Invalid address",
"data": "Invalid network address format"
}
]
},
{
"name": "system_stop_http_server",
"description": "Stop the HTTP/RPC server if running",
"params": [],
"result": {
"name": "StopHttpServerResult",
"description": "Result of the stop HTTP server operation",
"schema": {
"type": "null"
}
},
"examples": [
{
"name": "Stop the HTTP server",
"params": [],
"result": {
"name": "StopHttpServerResult",
"value": null
}
}
],
"errors": [
{
"code": -32602,
"message": "Server not running",
"data": "No HTTP server is currently running"
}
]
},
{
"name": "stream_currentLogs",
"description": "Get current logs from zinit and monitored services",
"params": [
{
"name": "name",
"description": "Optional service name filter. If provided, only logs from this service will be returned",
"required": false,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "LogsResult",
"description": "Array of log strings",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"examples": [
{
"name": "Get all logs",
"params": [],
"result": {
"name": "LogsResult",
"value": [
"2023-01-01T12:00:00 redis: Starting service",
"2023-01-01T12:00:01 nginx: Starting service"
]
}
},
{
"name": "Get logs for a specific service",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "LogsResult",
"value": [
"2023-01-01T12:00:00 redis: Starting service",
"2023-01-01T12:00:02 redis: Service started"
]
}
}
]
},
{
"name": "stream_subscribeLogs",
"description": "Subscribe to log messages generated by zinit and monitored services",
"params": [
{
"name": "name",
"description": "Optional service name filter. If provided, only logs from this service will be returned",
"required": false,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "LogSubscription",
"description": "A subscription to log messages",
"schema": {
"type": "string"
}
},
"examples": [
{
"name": "Subscribe to all logs",
"params": [],
"result": {
"name": "LogSubscription",
"value": "2023-01-01T12:00:00 redis: Service started"
}
},
{
"name": "Subscribe to filtered logs",
"params": [
{
"name": "name",
"value": "redis"
}
],
"result": {
"name": "LogSubscription",
"value": "2023-01-01T12:00:00 redis: Service started"
}
}
]
}
]
}

View File

@@ -0,0 +1,208 @@
# HeroAgent Server Factory
The HeroAgent Server Factory is a comprehensive server management system that integrates multiple services:
- Redis Server
- WebDAV Server
- UI Server
- Job Management System
## Overview
The server factory provides a unified interface for starting, managing, and stopping these services. Each service can be enabled or disabled independently through configuration.
## Job Management System
The job management system provides a robust solution for handling asynchronous tasks with persistence and reliability. It combines the strengths of OurDB for persistent storage and Redis for active job queuing.
### Architecture
The job system follows a specific flow:
1. **Job Creation**:
- When a job is created, it's stored in both OurDB and Redis
- OurDB provides persistent storage with history tracking
- Redis stores the job data and adds the job ID to a queue for processing
- Each job is stored in Redis using a key pattern: `herojobs:<topic>:<jobID>`
- Each job ID is added to a queue using a key pattern: `heroqueue:<topic>`
2. **Job Processing**:
- Workers continuously poll Redis queues for new jobs
- When a job is found, it's fetched from Redis and updated to "active" status
- The updated job is stored in both OurDB and Redis
- The job is processed based on its parameters
3. **Job Completion**:
- When a job completes (success or error), it's updated in OurDB
- The job is removed from Redis to keep only active jobs in memory
- This approach ensures efficient memory usage while maintaining a complete history
### Data Flow Diagram
```
Job Creation:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │────▶│ OurDB │ │ Redis │
└─────────┘ └────┬────┘ └────┬────┘
│ │
│ Store Job │ Store Job
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Job Data│ │ Job Data│
└─────────┘ └─────────┘
│ Add to Queue
┌─────────┐
│ Queue │
└─────────┘
Job Processing:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Worker │────▶│ Redis │────▶│ OurDB │
└─────────┘ └────┬────┘ └────┬────┘
│ │
│ Fetch Job │ Update Job
│ from Queue │
▼ ▼
┌─────────┐ ┌─────────┐
│ Job Data│ │ Job Data│
└─────────┘ └─────────┘
│ Process Job
┌─────────┐
│ Result │
└─────────┘
Job Completion:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Worker │────▶│ OurDB │ │ Redis │
└─────────┘ └────┬────┘ └────┬────┘
│ │
│ Update Job │ Remove Job
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Job Data│ │ ✓ │
└─────────┘ └─────────┘
```
### Components
- **JobManager**: Coordinates job operations between OurDB and Redis
- **RedisJobManager**: Handles Redis-specific operations for jobs
- **JobWorker**: Processes jobs from Redis queues
- **OurDB**: Provides persistent storage for all jobs
### Job States
Jobs can be in one of four states:
- **New**: Job has been created but not yet processed
- **Active**: Job is currently being processed
- **Done**: Job has completed successfully
- **Error**: Job encountered an error during processing
## Usage
### Configuration
```go
config := heroagent.DefaultConfig()
// Configure Redis
config.Redis.TCPPort = 6379
config.Redis.UnixSocketPath = "/tmp/redis.sock"
// Configure job system
config.Jobs.OurDBPath = "./data/jobsdb"
config.Jobs.WorkerCount = 5
config.Jobs.QueuePollInterval = 100 * time.Millisecond
// Enable/disable services
config.EnableRedis = true
config.EnableWebDAV = true
config.EnableUI = true
config.EnableJobs = true
```
### Starting the Server Factory
```go
// Create server factory
factory := heroagent.New(config)
// Start servers
if err := factory.Start(); err != nil {
log.Fatalf("Failed to start servers: %v", err)
}
```
### Creating and Managing Jobs
```go
// Get job manager
jobManager := factory.GetJobManager()
// Create a job
job, err := jobManager.CreateJob("email", `{"to": "user@example.com", "subject": "Hello"}`)
if err != nil {
log.Fatalf("Failed to create job: %v", err)
}
// Get job status
job, err = jobManager.GetJob(job.JobID)
if err != nil {
log.Fatalf("Failed to get job: %v", err)
}
// Update job status
err = jobManager.UpdateJobStatus(job.JobID, heroagent.JobStatusActive)
if err != nil {
log.Fatalf("Failed to update job status: %v", err)
}
// Complete a job
err = jobManager.CompleteJob(job.JobID, "Job completed successfully")
if err != nil {
log.Fatalf("Failed to complete job: %v", err)
}
// Mark a job as failed
err = jobManager.FailJob(job.JobID, "Job failed due to network error")
if err != nil {
log.Fatalf("Failed to mark job as failed: %v", err)
}
```
### Stopping the Server Factory
```go
// Stop servers
if err := factory.Stop(); err != nil {
log.Fatalf("Failed to stop servers: %v", err)
}
```
## Implementation Details
### OurDB Integration
OurDB provides persistent storage for all jobs, including their complete history. It uses an auto-incrementing ID system to assign unique IDs to jobs.
### Redis Integration
Redis is used for active job queuing and temporary storage. Jobs are stored in Redis using the following key patterns:
- Queue keys: `heroqueue:<topic>`
- Job storage keys: `herojobs:<topic>:<jobID>`
When a job reaches a terminal state (done or error), it's removed from Redis but remains in OurDB for historical reference.
### Worker Pool
The job system uses a configurable worker pool to process jobs concurrently. Each worker polls Redis queues for new jobs and processes them independently.

View File

@@ -0,0 +1,85 @@
package heroagent
import (
"time"
"git.threefold.info/herocode/heroagent/pkg/servers/ui"
"git.threefold.info/herocode/heroagent/pkg/servers/webdavserver"
)
// Config holds the configuration for the HeroAgent server factory
type Config struct {
// Redis server configuration
Redis RedisConfig
// WebDAV server configuration
WebDAV WebDAVConfig
// UI server configuration
UI UIConfig
// Job management configuration
Jobs JobsConfig
// Enable/disable specific servers
EnableRedis bool
EnableWebDAV bool
EnableUI bool
EnableJobs bool
}
// RedisConfig holds the configuration for the Redis server
type RedisConfig struct {
TCPPort int
UnixSocketPath string
}
// WebDAVConfig holds the configuration for the WebDAV server
type WebDAVConfig struct {
// Use webdavserver.Config directly
Config webdavserver.Config
}
// UIConfig holds the configuration for the UI server
type UIConfig struct {
// UI server configuration
Port string
// Any additional UI-specific configuration
AppConfig ui.AppConfig
}
// JobsConfig holds the configuration for the job management system
type JobsConfig struct {
// OurDB configuration
OurDBPath string
// Job processing configuration
WorkerCount int
QueuePollInterval time.Duration
}
// DefaultConfig returns the default configuration for the HeroAgent
func DefaultConfig() Config {
return Config{
Redis: RedisConfig{
TCPPort: 6379,
UnixSocketPath: "", // Empty string means use TCP only
},
WebDAV: WebDAVConfig{
Config: webdavserver.DefaultConfig(),
},
UI: UIConfig{
Port: "9001", // Port is a string in UIConfig
AppConfig: ui.AppConfig{},
},
Jobs: JobsConfig{
OurDBPath: "./data/ourdb",
WorkerCount: 5,
QueuePollInterval: 100 * time.Millisecond,
},
EnableRedis: true,
EnableWebDAV: true,
EnableUI: true,
EnableJobs: true,
}
}

View File

@@ -0,0 +1,226 @@
package heroagent
import (
"fmt"
"log"
"sync"
"git.threefold.info/herocode/heroagent/pkg/servers/redisserver"
"git.threefold.info/herocode/heroagent/pkg/servers/ui"
"git.threefold.info/herocode/heroagent/pkg/servers/webdavserver"
"github.com/gofiber/fiber/v2"
)
// ServerFactory manages the lifecycle of all servers
type ServerFactory struct {
config Config
// Server instances
redisServer *redisserver.Server
webdavServer *webdavserver.Server
uiApp *AppInstance
jobManager *JobManager
// Control channels
stopCh chan struct{}
wg sync.WaitGroup
}
// AppInstance wraps the UI app and its listening status
type AppInstance struct {
App *fiber.App
Port string
}
// New creates a new ServerFactory with the given configuration
func New(config Config) *ServerFactory {
return &ServerFactory{
config: config,
stopCh: make(chan struct{}),
}
}
// Start initializes and starts all enabled servers
func (f *ServerFactory) Start() error {
log.Println("Starting HeroAgent ServerFactory...")
// Start Redis server if enabled
if f.config.EnableRedis {
if err := f.startRedisServer(); err != nil {
return fmt.Errorf("failed to start Redis server: %w", err)
}
}
// Start WebDAV server if enabled
if f.config.EnableWebDAV {
if err := f.startWebDAVServer(); err != nil {
return fmt.Errorf("failed to start WebDAV server: %w", err)
}
}
// Start UI server if enabled
if f.config.EnableUI {
if err := f.startUIServer(); err != nil {
return fmt.Errorf("failed to start UI server: %w", err)
}
}
// Start job manager if enabled
if f.config.EnableJobs {
if err := f.startJobManager(); err != nil {
return fmt.Errorf("failed to start job manager: %w", err)
}
}
log.Println("All servers started successfully")
return nil
}
// Stop gracefully stops all running servers
func (f *ServerFactory) Stop() error {
log.Println("Stopping all servers...")
// Signal all goroutines to stop
close(f.stopCh)
// Stop WebDAV server if it's running
if f.webdavServer != nil {
if err := f.webdavServer.Stop(); err != nil {
log.Printf("Error stopping WebDAV server: %v", err)
}
}
// Stop job manager if it's running
if f.jobManager != nil {
if err := f.jobManager.Stop(); err != nil {
log.Printf("Error stopping job manager: %v", err)
}
}
// Wait for all goroutines to finish
f.wg.Wait()
log.Println("All servers stopped")
return nil
}
// startRedisServer initializes and starts the Redis server
func (f *ServerFactory) startRedisServer() error {
log.Println("Starting Redis server...")
// Create Redis server configuration
redisConfig := redisserver.ServerConfig{
TCPPort: f.config.Redis.TCPPort,
UnixSocketPath: f.config.Redis.UnixSocketPath,
}
// Create and start Redis server
f.redisServer = redisserver.NewServer(redisConfig)
log.Printf("Redis server started on port %d and socket %s",
redisConfig.TCPPort, redisConfig.UnixSocketPath)
return nil
}
// startWebDAVServer initializes and starts the WebDAV server
func (f *ServerFactory) startWebDAVServer() error {
log.Println("Starting WebDAV server...")
// Create WebDAV server
webdavServer, err := webdavserver.NewServer(f.config.WebDAV.Config)
if err != nil {
return fmt.Errorf("failed to create WebDAV server: %w", err)
}
f.webdavServer = webdavServer
// Start WebDAV server in a goroutine
f.wg.Add(1)
go func() {
defer f.wg.Done()
// Start the server
if err := webdavServer.Start(); err != nil {
log.Printf("WebDAV server error: %v", err)
}
}()
log.Printf("WebDAV server started on port %d", f.config.WebDAV.Config.TCPPort)
return nil
}
// startUIServer initializes and starts the UI server
func (f *ServerFactory) startUIServer() error {
log.Println("Starting UI server...")
// Create UI app
uiApp := ui.NewApp(f.config.UI.AppConfig)
// Store UI app instance
f.uiApp = &AppInstance{
App: uiApp,
Port: f.config.UI.Port,
}
// Start UI server in a goroutine
f.wg.Add(1)
go func() {
defer f.wg.Done()
// Start the server
addr := ":" + f.config.UI.Port
log.Printf("UI server listening on %s", addr)
if err := uiApp.Listen(addr); err != nil {
log.Printf("UI server error: %v", err)
}
}()
return nil
}
// GetRedisServer returns the Redis server instance
func (f *ServerFactory) GetRedisServer() *redisserver.Server {
return f.redisServer
}
// GetWebDAVServer returns the WebDAV server instance
func (f *ServerFactory) GetWebDAVServer() *webdavserver.Server {
return f.webdavServer
}
// GetUIApp returns the UI app instance
func (f *ServerFactory) GetUIApp() *AppInstance {
return f.uiApp
}
// startJobManager initializes and starts the job manager
func (f *ServerFactory) startJobManager() error {
log.Println("Starting job manager...")
// Create Redis connection for job manager
redisConn := &RedisConnection{
TCPPort: f.config.Redis.TCPPort,
UnixSocketPath: f.config.Redis.UnixSocketPath,
}
// Create job manager
jobManager, err := NewJobManager(f.config.Jobs, redisConn)
if err != nil {
return fmt.Errorf("failed to create job manager: %w", err)
}
f.jobManager = jobManager
// Start job manager
if err := jobManager.Start(); err != nil {
return fmt.Errorf("failed to start job manager: %w", err)
}
log.Println("Job manager started")
return nil
}
// GetJobManager returns the job manager instance
func (f *ServerFactory) GetJobManager() *JobManager {
return f.jobManager
}

View File

@@ -0,0 +1,351 @@
package heroagent
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"sync"
"time"
"git.threefold.info/herocode/heroagent/pkg/data/ourdb"
)
// JobManager handles job management between OurDB and Redis
type JobManager struct {
config JobsConfig
ourDB *ourdb.OurDB
redisConn *RedisConnection
redisMgr *RedisJobManager
workers []*JobWorker
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewJobManager creates a new job manager
func NewJobManager(config JobsConfig, redisConn *RedisConnection) (*JobManager, error) {
// Create OurDB directory if it doesn't exist
if err := os.MkdirAll(config.OurDBPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create OurDB directory: %w", err)
}
// Initialize OurDB
ourDBConfig := ourdb.DefaultConfig()
ourDBConfig.Path = config.OurDBPath
ourDBConfig.IncrementalMode = true
db, err := ourdb.New(ourDBConfig)
if err != nil {
return nil, fmt.Errorf("failed to create OurDB: %w", err)
}
// Initialize Redis job manager
redisMgr, err := NewRedisJobManager(redisConn.TCPPort, redisConn.UnixSocketPath)
if err != nil {
// Close OurDB before returning error
if closeErr := db.Close(); closeErr != nil {
log.Printf("Warning: failed to close OurDB: %v", closeErr)
}
return nil, fmt.Errorf("failed to create Redis job manager: %w", err)
}
// Create context with cancel
ctx, cancel := context.WithCancel(context.Background())
// Create job manager
jobMgr := &JobManager{
config: config,
ourDB: db,
redisConn: redisConn,
redisMgr: redisMgr,
ctx: ctx,
cancel: cancel,
}
return jobMgr, nil
}
// Start starts the job manager
func (jm *JobManager) Start() error {
log.Println("Starting job manager...")
// Start workers
jm.workers = make([]*JobWorker, jm.config.WorkerCount)
for i := 0; i < jm.config.WorkerCount; i++ {
worker := &JobWorker{
id: i,
jobMgr: jm,
ctx: jm.ctx,
wg: &jm.wg,
}
jm.workers[i] = worker
jm.startWorker(worker)
}
log.Printf("Job manager started with %d workers", jm.config.WorkerCount)
return nil
}
// Stop stops the job manager
func (jm *JobManager) Stop() error {
log.Println("Stopping job manager...")
// Signal all workers to stop
jm.cancel()
// Wait for all workers to finish
jm.wg.Wait()
// Close Redis job manager
if jm.redisMgr != nil {
if err := jm.redisMgr.Close(); err != nil {
log.Printf("Warning: failed to close Redis job manager: %v", err)
}
}
// Close OurDB
if err := jm.ourDB.Close(); err != nil {
// Log the error but don't fail the shutdown
log.Printf("Warning: failed to close OurDB: %v", err)
}
log.Println("Job manager stopped")
return nil
}
// startWorker starts a worker
func (jm *JobManager) startWorker(worker *JobWorker) {
jm.wg.Add(1)
go func() {
defer jm.wg.Done()
worker.run()
}()
}
// CreateJob creates a new job
func (jm *JobManager) CreateJob(topic, params string) (*Job, error) {
// Create new job
job := &Job{
Topic: topic,
Params: params,
Status: JobStatusNew,
TimeScheduled: time.Now().Unix(),
}
// Store job in OurDB
jobData, err := json.Marshal(job)
if err != nil {
return nil, fmt.Errorf("failed to marshal job: %w", err)
}
// Add job to OurDB with auto-incremented ID
id, err := jm.ourDB.Set(ourdb.OurDBSetArgs{
Data: jobData,
})
if err != nil {
return nil, fmt.Errorf("failed to store job in OurDB: %w", err)
}
// Update job with assigned ID
job.JobID = id
// Store job in Redis and add to queue
if err := jm.redisMgr.EnqueueJob(job); err != nil {
return nil, fmt.Errorf("failed to store job in Redis: %w", err)
}
log.Printf("Job %d created and stored in both OurDB and Redis", job.JobID)
return job, nil
}
// GetJob retrieves a job by ID
func (jm *JobManager) GetJob(jobID uint32) (*Job, error) {
// Get job from OurDB
jobData, err := jm.ourDB.Get(jobID)
if err != nil {
return nil, fmt.Errorf("failed to get job from OurDB: %w", err)
}
// Parse job data
job := &Job{}
if err := json.Unmarshal(jobData, job); err != nil {
return nil, fmt.Errorf("failed to unmarshal job data: %w", err)
}
return job, nil
}
// UpdateJobStatus updates the status of a job
func (jm *JobManager) UpdateJobStatus(jobID uint32, status JobStatus) error {
// Get job from OurDB
job, err := jm.GetJob(jobID)
if err != nil {
return err
}
// Update status
job.Status = status
// Update timestamps based on status
now := time.Now().Unix()
if status == JobStatusActive && job.TimeStart == 0 {
job.TimeStart = now
} else if (status == JobStatusDone || status == JobStatusError) && job.TimeEnd == 0 {
job.TimeEnd = now
}
// Store updated job in OurDB
jobData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("failed to marshal job: %w", err)
}
// Update job in OurDB
_, err = jm.ourDB.Set(ourdb.OurDBSetArgs{
ID: &jobID,
Data: jobData,
})
if err != nil {
return fmt.Errorf("failed to update job in OurDB: %w", err)
}
// If job is done or has error, remove from Redis
if status == JobStatusDone || status == JobStatusError {
if err := jm.redisMgr.DeleteJob(jobID, job.Topic); err != nil {
log.Printf("Warning: failed to remove job %d from Redis: %v", jobID, err)
}
} else {
// Otherwise, update in Redis
if err := jm.redisMgr.UpdateJobStatus(job); err != nil {
log.Printf("Warning: failed to update job %d in Redis: %v", jobID, err)
}
}
return nil
}
// CompleteJob marks a job as completed
func (jm *JobManager) CompleteJob(jobID uint32, result string) error {
// Get job from OurDB
job, err := jm.GetJob(jobID)
if err != nil {
return err
}
// Update job
job.Status = JobStatusDone
job.TimeEnd = time.Now().Unix()
job.Result = result
// Store updated job in OurDB
jobData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("failed to marshal job: %w", err)
}
// Update job in OurDB
_, err = jm.ourDB.Set(ourdb.OurDBSetArgs{
ID: &jobID,
Data: jobData,
})
if err != nil {
return fmt.Errorf("failed to update job in OurDB: %w", err)
}
// Remove from Redis
if err := jm.redisMgr.DeleteJob(jobID, job.Topic); err != nil {
log.Printf("Warning: failed to remove job %d from Redis: %v", jobID, err)
}
log.Printf("Job %d completed and removed from Redis", jobID)
return nil
}
// FailJob marks a job as failed
func (jm *JobManager) FailJob(jobID uint32, errorMsg string) error {
// Get job from OurDB
job, err := jm.GetJob(jobID)
if err != nil {
return err
}
// Update job
job.Status = JobStatusError
job.TimeEnd = time.Now().Unix()
job.Error = errorMsg
// Store updated job in OurDB
jobData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("failed to marshal job: %w", err)
}
// Update job in OurDB
_, err = jm.ourDB.Set(ourdb.OurDBSetArgs{
ID: &jobID,
Data: jobData,
})
if err != nil {
return fmt.Errorf("failed to update job in OurDB: %w", err)
}
// Remove from Redis
if err := jm.redisMgr.DeleteJob(jobID, job.Topic); err != nil {
log.Printf("Warning: failed to remove job %d from Redis: %v", jobID, err)
}
log.Printf("Job %d failed and removed from Redis", jobID)
return nil
}
// updateJobInBothStores updates a job in both OurDB and Redis
func (jm *JobManager) updateJobInBothStores(job *Job) error {
// Store job in OurDB
jobData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("failed to marshal job: %w", err)
}
// Update job in OurDB
_, err = jm.ourDB.Set(ourdb.OurDBSetArgs{
ID: &job.JobID,
Data: jobData,
})
if err != nil {
return fmt.Errorf("failed to update job in OurDB: %w", err)
}
// Update job in Redis
if err := jm.redisMgr.UpdateJobStatus(job); err != nil {
return fmt.Errorf("failed to update job in Redis: %w", err)
}
return nil
}
// completeJobProcessing updates a completed job in OurDB and removes it from Redis
func (jm *JobManager) completeJobProcessing(job *Job) error {
// Store job in OurDB
jobData, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("failed to marshal job: %w", err)
}
// Update job in OurDB
_, err = jm.ourDB.Set(ourdb.OurDBSetArgs{
ID: &job.JobID,
Data: jobData,
})
if err != nil {
return fmt.Errorf("failed to update job in OurDB: %w", err)
}
// Remove from Redis
if err := jm.redisMgr.DeleteJob(job.JobID, job.Topic); err != nil {
return fmt.Errorf("failed to remove job from Redis: %w", err)
}
return nil
}

View File

@@ -0,0 +1,131 @@
package heroagent
import (
"context"
"fmt"
"log"
"sync"
"time"
)
// JobStatus represents the status of a job
type JobStatus string
const (
// JobStatusNew indicates a newly created job
JobStatusNew JobStatus = "new"
// JobStatusActive indicates a job that is currently being processed
JobStatusActive JobStatus = "active"
// JobStatusError indicates a job that encountered an error
JobStatusError JobStatus = "error"
// JobStatusDone indicates a job that has been completed successfully
JobStatusDone JobStatus = "done"
)
// Job represents a job to be processed
type Job struct {
JobID uint32 `json:"jobid"`
Topic string `json:"topic"`
Params string `json:"params"`
Status JobStatus `json:"status"`
TimeScheduled int64 `json:"time_scheduled"`
TimeStart int64 `json:"time_start"`
TimeEnd int64 `json:"time_end"`
Error string `json:"error"`
Result string `json:"result"`
}
// RedisConnection wraps Redis connection details
type RedisConnection struct {
TCPPort int
UnixSocketPath string
}
// JobWorker represents a worker that processes jobs
type JobWorker struct {
id int
jobMgr *JobManager
ctx context.Context
wg *sync.WaitGroup
}
// run is the main worker loop
func (w *JobWorker) run() {
log.Printf("Worker %d started", w.id)
ticker := time.NewTicker(w.jobMgr.config.QueuePollInterval)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
log.Printf("Worker %d stopping", w.id)
return
case <-ticker.C:
// Check for jobs in Redis
if err := w.checkForJobs(); err != nil {
log.Printf("Worker %d error checking for jobs: %v", w.id, err)
}
}
}
}
// checkForJobs checks for jobs in Redis
func (w *JobWorker) checkForJobs() error {
// Get list of queues
queues, err := w.jobMgr.redisMgr.ListQueues()
if err != nil {
return fmt.Errorf("failed to list queues: %w", err)
}
// Check each queue for jobs
for _, topic := range queues {
// Try to fetch a job from the queue
job, err := w.jobMgr.redisMgr.FetchNextJob(topic)
if err != nil {
// If queue is empty, continue to next queue
continue
}
// Process the job
if err := w.processJob(job); err != nil {
log.Printf("Error processing job %d: %v", job.JobID, err)
}
// Only process one job at a time
return nil
}
return nil
}
// processJob processes a job
func (w *JobWorker) processJob(job *Job) error {
log.Printf("Worker %d processing job %d", w.id, job.JobID)
// Update job status to active
job.Status = JobStatusActive
job.TimeStart = time.Now().Unix()
// Update job in both OurDB and Redis
if err := w.jobMgr.updateJobInBothStores(job); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Simulate job processing
// In a real implementation, this would execute the job based on its parameters
time.Sleep(1 * time.Second)
// Complete the job
job.Status = JobStatusDone
job.TimeEnd = time.Now().Unix()
job.Result = fmt.Sprintf("Job %d processed successfully", job.JobID)
// Update job in OurDB and remove from Redis
if err := w.jobMgr.completeJobProcessing(job); err != nil {
return fmt.Errorf("failed to complete job: %w", err)
}
log.Printf("Worker %d completed job %d", w.id, job.JobID)
return nil
}

View File

@@ -0,0 +1,219 @@
package heroagent
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// RedisJobManager handles Redis operations for jobs
type RedisJobManager struct {
client *redis.Client
ctx context.Context
}
// NewRedisJobManager creates a new Redis job manager
func NewRedisJobManager(tcpPort int, unixSocketPath string) (*RedisJobManager, error) {
var client *redis.Client
var err error
// Try Unix socket first if provided
if unixSocketPath != "" {
log.Printf("Attempting to connect to Redis via Unix socket: %s", unixSocketPath)
client = redis.NewClient(&redis.Options{
Network: "unix",
Addr: unixSocketPath,
DB: 0,
DialTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
})
// Test connection
ctx := context.Background()
_, pingErr := client.Ping(ctx).Result()
if pingErr != nil {
log.Printf("Failed to connect to Redis via Unix socket: %v, falling back to TCP", pingErr)
// Close the failed client
client.Close()
err = pingErr // Update the outer err variable
}
}
// If Unix socket connection failed or wasn't provided, use TCP
if unixSocketPath == "" || err != nil {
tcpAddr := fmt.Sprintf("localhost:%d", tcpPort)
log.Printf("Connecting to Redis via TCP: %s", tcpAddr)
client = redis.NewClient(&redis.Options{
Network: "tcp",
Addr: tcpAddr,
DB: 0,
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
})
}
// Test connection
ctx := context.Background()
_, pingErr := client.Ping(ctx).Result()
if pingErr != nil {
return nil, fmt.Errorf("failed to connect to Redis: %w", pingErr)
}
return &RedisJobManager{
client: client,
ctx: ctx,
}, nil
}
// Close closes the Redis client
func (r *RedisJobManager) Close() error {
return r.client.Close()
}
// QueueKey returns the Redis queue key for a topic
func QueueKey(topic string) string {
return fmt.Sprintf("heroqueue:%s", topic)
}
// StorageKey returns the Redis storage key for a job
func StorageKey(jobID uint32, topic string) string {
return fmt.Sprintf("herojobs:%s:%d", topic, jobID)
}
// StoreJob stores a job in Redis
func (r *RedisJobManager) StoreJob(job *Job) error {
// Convert job to JSON
jobJSON, err := json.Marshal(job)
if err != nil {
return fmt.Errorf("failed to marshal job: %w", err)
}
// Store job in Redis
storageKey := StorageKey(job.JobID, job.Topic)
err = r.client.Set(r.ctx, storageKey, jobJSON, 0).Err()
if err != nil {
return fmt.Errorf("failed to store job in Redis: %w", err)
}
return nil
}
// EnqueueJob adds a job to its queue
func (r *RedisJobManager) EnqueueJob(job *Job) error {
// Store the job first
if err := r.StoreJob(job); err != nil {
return err
}
// Add job ID to queue
queueKey := QueueKey(job.Topic)
err := r.client.RPush(r.ctx, queueKey, job.JobID).Err()
if err != nil {
return fmt.Errorf("failed to enqueue job: %w", err)
}
log.Printf("Job %d enqueued in Redis queue %s", job.JobID, queueKey)
return nil
}
// GetJob retrieves a job from Redis
func (r *RedisJobManager) GetJob(jobID uint32, topic string) (*Job, error) {
// Get job from Redis
storageKey := StorageKey(jobID, topic)
jobJSON, err := r.client.Get(r.ctx, storageKey).Result()
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("job not found: %d", jobID)
}
return nil, fmt.Errorf("failed to get job from Redis: %w", err)
}
// Parse job JSON
job := &Job{}
if err := json.Unmarshal([]byte(jobJSON), job); err != nil {
return nil, fmt.Errorf("failed to unmarshal job: %w", err)
}
return job, nil
}
// DeleteJob deletes a job from Redis
func (r *RedisJobManager) DeleteJob(jobID uint32, topic string) error {
// Delete job from Redis
storageKey := StorageKey(jobID, topic)
err := r.client.Del(r.ctx, storageKey).Err()
if err != nil {
return fmt.Errorf("failed to delete job from Redis: %w", err)
}
log.Printf("Job %d deleted from Redis", jobID)
return nil
}
// FetchNextJob fetches the next job from a queue
func (r *RedisJobManager) FetchNextJob(topic string) (*Job, error) {
queueKey := QueueKey(topic)
// Get and remove first job ID from queue
jobIDStr, err := r.client.LPop(r.ctx, queueKey).Result()
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("queue is empty")
}
return nil, fmt.Errorf("failed to fetch job ID from queue: %w", err)
}
// Convert job ID to uint32
jobID, err := strconv.ParseUint(jobIDStr, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid job ID: %s", jobIDStr)
}
// Get job from Redis
return r.GetJob(uint32(jobID), topic)
}
// ListQueues lists all job queues
func (r *RedisJobManager) ListQueues() ([]string, error) {
// Get all queue keys
queueKeys, err := r.client.Keys(r.ctx, "heroqueue:*").Result()
if err != nil {
return nil, fmt.Errorf("failed to list queues: %w", err)
}
// Extract topic names from queue keys
topics := make([]string, 0, len(queueKeys))
for _, queueKey := range queueKeys {
// Extract topic from queue key (format: heroqueue:<topic>)
topic := queueKey[10:] // Skip "heroqueue:"
topics = append(topics, topic)
}
return topics, nil
}
// QueueSize returns the size of a queue
func (r *RedisJobManager) QueueSize(topic string) (int64, error) {
queueKey := QueueKey(topic)
// Get queue size
size, err := r.client.LLen(r.ctx, queueKey).Result()
if err != nil {
return 0, fmt.Errorf("failed to get queue size: %w", err)
}
return size, nil
}
// UpdateJobStatus updates the status of a job in Redis
func (r *RedisJobManager) UpdateJobStatus(job *Job) error {
// Update job in Redis
return r.StoreJob(job)
}

View File

@@ -24,7 +24,7 @@ The server implements the following Redis commands:
### Basic Usage
```go
import "git.ourworld.tf/herocode/heroagent/pkg/redisserver"
import "git.threefold.info/herocode/heroagent/pkg/redisserver"
// Create a new server with default configuration
server := redisserver.NewServer(redisserver.ServerConfig{

View File

@@ -6,17 +6,18 @@ import (
"fmt"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/servers/redisserver"
"git.threefold.info/herocode/heroagent/pkg/servers/redisserver"
"github.com/redis/go-redis/v9"
)
func main() {
// Parse command line flags
tcpPort := flag.String("tcp-port", "7777", "Redis server TCP port")
tcpPortStr := flag.String("tcp-port", "7777", "Redis server TCP port")
unixSocket := flag.String("unix-socket", "/tmp/redis-test.sock", "Redis server Unix domain socket path")
username := flag.String("user", "jan", "Username to check")
mailbox := flag.String("mailbox", "inbox", "Mailbox to check")
@@ -24,8 +25,13 @@ func main() {
dbNum := flag.Int("db", 0, "Redis database number")
flag.Parse()
tcpPort, err := strconv.Atoi(*tcpPortStr)
if err != nil {
log.Fatalf("Invalid TCP port: %v", err)
}
// Start Redis server in a goroutine
log.Printf("Starting Redis server on TCP port %s and Unix socket %s", *tcpPort, *unixSocket)
log.Printf("Starting Redis server on TCP port %d and Unix socket %s", tcpPort, *unixSocket)
// Create a wait group to ensure the server is started before testing
var wg sync.WaitGroup
@@ -44,7 +50,7 @@ func main() {
// Start the Redis server in a goroutine
go func() {
// Create a new server instance
_ = redisserver.NewServer(redisserver.ServerConfig{TCPPort: *tcpPort, UnixSocketPath: *unixSocket})
_ = redisserver.NewServer(redisserver.ServerConfig{TCPPort: tcpPort, UnixSocketPath: *unixSocket})
// Signal that the server is ready
wg.Done()
@@ -61,7 +67,7 @@ func main() {
// Test TCP connection
log.Println("Testing TCP connection")
tcpAddr := fmt.Sprintf("localhost:%s", *tcpPort)
tcpAddr := fmt.Sprintf("localhost:%d", tcpPort)
testRedisConnection(tcpAddr, username, mailbox, debug, dbNum)
// Test Unix socket connection if supported

View File

@@ -10,7 +10,7 @@ import (
"sync"
"time"
"git.ourworld.tf/herocode/heroagent/pkg/servers/redisserver"
"git.threefold.info/herocode/heroagent/pkg/servers/redisserver"
"github.com/redis/go-redis/v9"
)
@@ -62,14 +62,19 @@ func (ts *TestSuite) PrintResults() {
func main() {
// Parse command line flags
tcpPort := flag.String("tcp-port", "7777", "Redis server TCP port")
tcpPortStr := flag.String("tcp-port", "7777", "Redis server TCP port")
unixSocket := flag.String("unix-socket", "/tmp/redis-test.sock", "Redis server Unix domain socket path")
debug := flag.Bool("debug", false, "Enable debug output")
dbNum := flag.Int("db", 0, "Redis database number")
flag.Parse()
tcpPortInt, err := strconv.Atoi(*tcpPortStr)
if err != nil {
log.Fatalf("Invalid TCP port: %v", err)
}
// Start Redis server in a goroutine
log.Printf("Starting Redis server on TCP port %s and Unix socket %s", *tcpPort, *unixSocket)
log.Printf("Starting Redis server on TCP port %d and Unix socket %s", tcpPortInt, *unixSocket)
// Create a wait group to ensure the server is started before testing
var wg sync.WaitGroup
@@ -88,7 +93,7 @@ func main() {
// Start the Redis server in a goroutine
go func() {
// Create a new server instance
_ = redisserver.NewServer(redisserver.ServerConfig{TCPPort: *tcpPort, UnixSocketPath: *unixSocket})
_ = redisserver.NewServer(redisserver.ServerConfig{TCPPort: tcpPortInt, UnixSocketPath: *unixSocket})
// Signal that the server is ready
wg.Done()
@@ -105,7 +110,7 @@ func main() {
// Test TCP connection
log.Println("Testing TCP connection")
tcpAddr := fmt.Sprintf("localhost:%s", *tcpPort)
tcpAddr := fmt.Sprintf("localhost:%d", tcpPortInt)
runTests(tcpAddr, *debug, *dbNum)
// Test Unix socket connection if supported

View File

@@ -1,6 +1,7 @@
package redisserver
import (
"strconv"
"sync"
"time"
)
@@ -20,7 +21,7 @@ type Server struct {
}
type ServerConfig struct {
TCPPort string
TCPPort int
UnixSocketPath string
}
@@ -38,8 +39,8 @@ func NewServer(config ServerConfig) *Server {
go s.cleanupExpiredKeys()
// Start TCP server if port is provided
if config.TCPPort != "" {
tcpAddr := ":" + config.TCPPort
if config.TCPPort != 0 {
tcpAddr := ":" + strconv.Itoa(config.TCPPort)
go s.startRedisServer(tcpAddr, "")
}

View File

@@ -1,10 +1,14 @@
package ui
import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"git.ourworld.tf/herocode/heroagent/pkg/servers/ui/routes" // Import the routes package
"git.threefold.info/herocode/heroagent/pkg/servers/ui/routes" // Import the routes package
"github.com/gofiber/fiber/v2"
jetadapter "github.com/gofiber/template/jet/v2" // Aliased for clarity
)
@@ -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.threefold.info/herocode/heroagent/pkg/herojobs"
"git.threefold.info/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.threefold.info/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,173 @@
package controllers
import (
"encoding/json"
"log"
orpcmodels "git.threefold.info/herocode/heroagent/pkg/openrpc/models"
uimodels "git.threefold.info/herocode/heroagent/pkg/servers/ui/models"
"github.com/gofiber/fiber/v2"
)
// OpenRPCController handles requests related to OpenRPC specifications
type OpenRPCController struct {
openrpcManager uimodels.OpenRPCUIManager
}
// NewOpenRPCController creates a new instance of OpenRPCController
func NewOpenRPCController(openrpcManager uimodels.OpenRPCUIManager) *OpenRPCController {
return &OpenRPCController{
openrpcManager: openrpcManager,
}
}
// OpenRPCPageData represents the data needed for the OpenRPC UI pages
type OpenRPCPageData struct {
Title string
Specs []string
SelectedSpec string
Methods []string
SelectedMethod string
Method *orpcmodels.Method
SocketPath string
ExampleParams string
Result string
Error string
}
// ShowOpenRPCUI renders the OpenRPC UI page
func (c *OpenRPCController) ShowOpenRPCUI(ctx *fiber.Ctx) error {
// Get query parameters
selectedSpec := ctx.Query("spec", "")
selectedMethod := ctx.Query("method", "")
socketPath := ctx.Query("socketPath", "")
// Get all specs
specs := c.openrpcManager.ListSpecs()
// Initialize page data using fiber.Map instead of struct
pageData := fiber.Map{
"Title": "OpenRPC UI",
"SpecList": specs,
"SelectedSpec": selectedSpec,
"SocketPath": socketPath,
}
// If a spec is selected, get its methods
if selectedSpec != "" {
methods := c.openrpcManager.ListMethods(selectedSpec)
pageData["Methods"] = methods
pageData["SelectedMethod"] = selectedMethod
// If a method is selected, get its details
if selectedMethod != "" {
method := c.openrpcManager.GetMethod(selectedSpec, selectedMethod)
if method != nil {
pageData["Method"] = method
// Generate example parameters if available
if len(method.Examples) > 0 {
exampleParams, err := json.MarshalIndent(method.Examples[0].Params, "", " ")
if err == nil {
pageData["ExampleParams"] = string(exampleParams)
}
} else if len(method.Params) > 0 {
// Generate example from parameter schema
exampleParams := generateExampleParams(method.Params)
jsonParams, err := json.MarshalIndent(exampleParams, "", " ")
if err == nil {
pageData["ExampleParams"] = string(jsonParams)
}
}
}
}
}
return ctx.Render("pages/rpcui", pageData)
}
// ExecuteRPC handles RPC execution requests
func (c *OpenRPCController) ExecuteRPC(ctx *fiber.Ctx) error {
// Parse request
var request struct {
Spec string `json:"spec"`
Method string `json:"method"`
SocketPath string `json:"socketPath"`
Params json.RawMessage `json:"params"`
}
if err := ctx.BodyParser(&request); err != nil {
return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid request: " + err.Error(),
})
}
// Validate request
if request.Spec == "" || request.Method == "" || request.SocketPath == "" {
return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Missing required fields: spec, method, or socketPath",
})
}
// Parse params
var params interface{}
if len(request.Params) > 0 {
if err := json.Unmarshal(request.Params, &params); err != nil {
return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid parameters: " + err.Error(),
})
}
}
// Execute RPC
result, err := c.openrpcManager.ExecuteRPC(request.Spec, request.Method, request.SocketPath, params)
if err != nil {
log.Printf("Error executing RPC: %v", err)
return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
// Return result
return ctx.JSON(fiber.Map{
"result": result,
})
}
// generateExampleParams generates example parameters from parameter schemas
func generateExampleParams(params []orpcmodels.Parameter) map[string]interface{} {
example := make(map[string]interface{})
for _, param := range params {
example[param.Name] = generateExampleValue(param.Schema)
}
return example
}
// generateExampleValue generates an example value from a schema
func generateExampleValue(schema orpcmodels.SchemaObject) interface{} {
switch schema.Type {
case "string":
return "example"
case "number":
return 0
case "integer":
return 0
case "boolean":
return false
case "array":
if schema.Items != nil {
return []interface{}{generateExampleValue(*schema.Items)}
}
return []interface{}{}
case "object":
obj := make(map[string]interface{})
for name, propSchema := range schema.Properties {
obj[name] = generateExampleValue(propSchema)
}
return obj
default:
return nil
}
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strconv"
"git.ourworld.tf/herocode/heroagent/pkg/servers/ui/models"
"git.threefold.info/herocode/heroagent/pkg/servers/ui/models"
"github.com/gofiber/fiber/v2"
)

View File

@@ -0,0 +1,348 @@
package models
import (
"context"
"fmt"
"log"
"time"
"git.threefold.info/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

@@ -0,0 +1,190 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
"git.threefold.info/herocode/heroagent/pkg/openrpc"
"git.threefold.info/herocode/heroagent/pkg/openrpc/models"
)
// OpenRPCUIManager is the interface for managing OpenRPC specifications in the UI
type OpenRPCUIManager interface {
// ListSpecs returns a list of all loaded specification names
ListSpecs() []string
// GetSpec returns an OpenRPC specification by name
GetSpec(name string) *models.OpenRPCSpec
// ListMethods returns a list of all method names in a specification
ListMethods(specName string) []string
// GetMethod returns a method from a specification
GetMethod(specName, methodName string) *models.Method
// ExecuteRPC executes an RPC call
ExecuteRPC(specName, methodName, socketPath string, params interface{}) (interface{}, error)
}
// OpenRPCManager implements the OpenRPCUIManager interface
type OpenRPCManager struct {
manager *openrpc.ORPCManager
}
// NewOpenRPCManager creates a new OpenRPCUIManager
func NewOpenRPCManager() OpenRPCUIManager {
manager := openrpc.NewORPCManager()
// Try to load specs from the default directory
specDirs := []string{
"./pkg/openrpc/services",
"./pkg/openrpc/specs",
"./specs/openrpc",
}
for _, dir := range specDirs {
err := manager.LoadSpecs(dir)
if err == nil {
// Successfully loaded specs from this directory
break
}
}
return &OpenRPCManager{
manager: manager,
}
}
// ListSpecs returns a list of all loaded specification names
func (m *OpenRPCManager) ListSpecs() []string {
return m.manager.ListSpecs()
}
// GetSpec returns an OpenRPC specification by name
func (m *OpenRPCManager) GetSpec(name string) *models.OpenRPCSpec {
return m.manager.GetSpec(name)
}
// ListMethods returns a list of all method names in a specification
func (m *OpenRPCManager) ListMethods(specName string) []string {
return m.manager.ListMethods(specName)
}
// GetMethod returns a method from a specification
func (m *OpenRPCManager) GetMethod(specName, methodName string) *models.Method {
return m.manager.GetMethod(specName, methodName)
}
// ExecuteRPC executes an RPC call
func (m *OpenRPCManager) ExecuteRPC(specName, methodName, socketPath string, params interface{}) (interface{}, error) {
// Create JSON-RPC request
request := map[string]interface{}{
"jsonrpc": "2.0",
"method": methodName,
"params": params,
"id": 1,
}
// Marshal request to JSON
requestJSON, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Check if socket path is a Unix socket or HTTP endpoint
if socketPath[:1] == "/" {
// Unix socket
return executeUnixSocketRPC(socketPath, requestJSON)
} else {
// HTTP endpoint
return executeHTTPRPC(socketPath, requestJSON)
}
}
// executeUnixSocketRPC executes an RPC call over a Unix socket
func executeUnixSocketRPC(socketPath string, requestJSON []byte) (interface{}, error) {
// Connect to Unix socket
conn, err := net.Dial("unix", socketPath)
if err != nil {
return nil, fmt.Errorf("failed to connect to socket %s: %w", socketPath, err)
}
defer conn.Close()
// Set timeout
deadline := time.Now().Add(10 * time.Second)
if err := conn.SetDeadline(deadline); err != nil {
return nil, fmt.Errorf("failed to set deadline: %w", err)
}
// Send request
if _, err := conn.Write(requestJSON); err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
// Read response
var buf bytes.Buffer
if _, err := buf.ReadFrom(conn); err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Parse response
var response map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &response); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check for error
if errObj, ok := response["error"]; ok {
return nil, fmt.Errorf("RPC error: %v", errObj)
}
// Return result
return response["result"], nil
}
// executeHTTPRPC executes an RPC call over HTTP
func executeHTTPRPC(endpoint string, requestJSON []byte) (interface{}, error) {
// Create HTTP request
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(requestJSON))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
// Create HTTP client with timeout
client := &http.Client{
Timeout: 10 * time.Second,
}
// Send request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send HTTP request: %w", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP error: %s", resp.Status)
}
// Parse response
var response map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check for error
if errObj, ok := response["error"]; ok {
return nil, fmt.Errorf("RPC error: %v", errObj)
}
// Return result
return response["result"], nil
}

View File

@@ -1,8 +1,8 @@
package routes
import (
"git.ourworld.tf/herocode/heroagent/pkg/servers/ui/controllers"
"git.ourworld.tf/herocode/heroagent/pkg/servers/ui/models"
"git.threefold.info/herocode/heroagent/pkg/servers/ui/controllers"
"git.threefold.info/herocode/heroagent/pkg/servers/ui/models"
"github.com/gofiber/fiber/v2"
)
@@ -11,10 +11,14 @@ func SetupRoutes(app *fiber.App) {
// Initialize services and controllers
// For now, using the mock process manager
processManagerService := models.NewMockProcessManager()
jobManagerService := models.NewMockJobManager()
openrpcManagerService := models.NewOpenRPCManager()
dashboardController := controllers.NewDashboardController()
processController := controllers.NewProcessController(processManagerService)
jobController := controllers.NewJobController(jobManagerService)
authController := controllers.NewAuthController()
openrpcController := controllers.NewOpenRPCController(openrpcManagerService)
// --- Public Routes ---
// Login and Logout
@@ -32,9 +36,40 @@ 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)
// OpenRPC UI routes
app.Get("/rpcui", openrpcController.ShowOpenRPCUI)
app.Post("/api/rpcui/execute", openrpcController.ExecuteRPC)
// 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

@@ -0,0 +1,144 @@
/* OpenRPC UI Styles */
.method-tree {
max-height: 600px;
overflow-y: auto;
border-right: 1px solid #dee2e6;
}
.method-item {
cursor: pointer;
padding: 8px 15px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.method-item:hover {
background-color: #f8f9fa;
}
.method-item.active {
background-color: #e9ecef;
font-weight: bold;
border-left: 3px solid #0d6efd;
}
.param-card {
margin-bottom: 15px;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.result-container {
max-height: 400px;
overflow-y: auto;
margin-top: 20px;
padding: 15px;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.code-editor {
font-family: 'Courier New', Courier, monospace;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
white-space: pre;
font-size: 14px;
line-height: 1.5;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
background-color: #f8f9fa;
}
.schema-table {
font-size: 0.9rem;
width: 100%;
margin-bottom: 1rem;
}
.schema-table th {
font-weight: 600;
background-color: #f8f9fa;
}
.schema-required {
color: #dc3545;
font-weight: bold;
}
.schema-optional {
color: #6c757d;
}
.method-description {
font-size: 0.9rem;
color: #6c757d;
margin-bottom: 15px;
}
.section-header {
font-size: 1.1rem;
font-weight: 600;
margin-top: 20px;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #dee2e6;
}
.example-container {
margin-bottom: 15px;
}
.example-header {
font-weight: 600;
margin-bottom: 5px;
}
.example-content {
background-color: #f8f9fa;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
/* Socket path input styling */
.socket-path-container {
margin-bottom: 15px;
}
.socket-path-label {
font-weight: 600;
margin-bottom: 5px;
}
/* Execute button styling */
.execute-button {
margin-top: 10px;
}
/* Response styling */
.response-success {
border-left: 4px solid #28a745;
}
.response-error {
border-left: 4px solid #dc3545;
}
/* Loading indicator */
.loading-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 0.2em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spinner-border .75s linear infinite;
}
@keyframes spinner-border {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,141 @@
/**
* OpenRPC UI JavaScript
* Handles the interactive functionality of the OpenRPC UI
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize form elements
const specForm = document.getElementById('specForm');
const rpcForm = document.getElementById('rpcForm');
const paramsEditor = document.getElementById('paramsEditor');
const resultContainer = document.getElementById('resultContainer');
const resultOutput = document.getElementById('resultOutput');
const errorContainer = document.getElementById('errorContainer');
const errorOutput = document.getElementById('errorOutput');
// Format JSON in the parameters editor
if (paramsEditor && paramsEditor.value) {
try {
const params = JSON.parse(paramsEditor.value);
paramsEditor.value = JSON.stringify(params, null, 2);
} catch (e) {
// If not valid JSON, leave as is
console.warn('Could not format parameters as JSON:', e);
}
}
// Handle RPC execution
if (rpcForm) {
rpcForm.addEventListener('submit', function(e) {
e.preventDefault();
// Hide previous results
if (resultContainer) resultContainer.classList.add('d-none');
if (errorContainer) errorContainer.classList.add('d-none');
// Get form data
const spec = document.getElementById('spec').value;
const method = document.querySelector('input[name="selectedMethod"]').value;
const socketPath = document.getElementById('socketPath').value;
const paramsText = paramsEditor.value;
// Show loading indicator
const submitButton = rpcForm.querySelector('button[type="submit"]');
const originalButtonText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = '<span class="loading-spinner me-2"></span>Executing...';
// Validate
if (!spec || !method || !socketPath) {
showError('Missing required fields: spec, method, or socketPath');
resetButton();
return;
}
// Parse params
let params;
try {
params = JSON.parse(paramsText);
} catch (e) {
showError('Invalid JSON parameters: ' + e.message);
resetButton();
return;
}
// Execute RPC
fetch('/api/rpcui/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
spec: spec,
method: method,
socketPath: socketPath,
params: params
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
showError(data.error);
} else {
showResult(data.result);
}
})
.catch(error => {
showError('Request failed: ' + error.message);
})
.finally(() => {
resetButton();
});
function resetButton() {
submitButton.disabled = false;
submitButton.innerHTML = originalButtonText;
}
function showError(message) {
if (errorContainer && errorOutput) {
errorContainer.classList.remove('d-none');
errorOutput.textContent = message;
}
}
function showResult(result) {
if (resultContainer && resultOutput) {
resultContainer.classList.remove('d-none');
resultOutput.textContent = JSON.stringify(result, null, 2);
}
}
});
}
// Method tree navigation
const methodItems = document.querySelectorAll('.method-item');
methodItems.forEach(item => {
item.addEventListener('click', function(e) {
// Already handled by href, but could add additional functionality here
});
});
// Format JSON examples
const jsonExamples = document.querySelectorAll('pre code');
jsonExamples.forEach(example => {
try {
const content = example.textContent;
const json = JSON.parse(content);
example.textContent = JSON.stringify(json, null, 2);
} catch (e) {
// If not valid JSON, leave as is
console.warn('Could not format example as JSON:', e);
}
});
// Add syntax highlighting if a library like highlight.js is available
if (typeof hljs !== 'undefined') {
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
});
}
});

View File

@@ -12,6 +12,18 @@
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>
<li class="nav-item">
<a class="nav-link" href="/rpcui">
<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-code"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>
OpenRPC UI
</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 }}

View File

@@ -26,15 +26,15 @@
</thead>
<tbody>
{{ if len(Processes) > 0 }}
{{ range process := Processes }}
{{ range pid, process := Processes }}
<tr>
<td>{{ process.PID }}</td>
<td>{{ pid }}</td>
<td>{{ process.Name }}</td>
<td>{{ printf "%.2f" process.CPU }}</td>
<td>{{ printf "%.2f" process.Memory }}</td>
<td>{{ process.CPU }}</td>
<td>{{ process.Memory }}</td>
<td>
<form action="/processes/kill/{{ process.PID }}" method="POST" style="display:inline;">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to kill process {{ process.PID }}?');">Kill</button>
<form action="/processes/kill/{{ pid }}" method="POST" style="display:inline;">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to kill process {{ pid }}?');">Kill</button>
</form>
</td>
</tr>

View File

@@ -0,0 +1,157 @@
{{extends "../layouts/base"}}
{{block title()}}
OpenRPC UI
{{end}}
{{block css()}}
<link rel="stylesheet" href="/static/css/rpcui.css">
{{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">OpenRPC UI</h1>
</div>
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5>Select OpenRPC Specification</h5>
</div>
<div class="card-body">
<form id="specForm" action="/rpcui" method="get" class="row g-3">
<div class="col-md-4">
<label for="spec" class="form-label">Specification</label>
<select class="form-select" id="spec" name="spec" onchange="this.form.submit()">
<option value="">Select a specification</option>
{{ if SpecList }}
{{ range spec := SpecList }}
<option value="{{ spec }}" >
{{ end }}
{{ else }}
<option value="" disabled>No specifications available</option>
{{ end }}
</select>
</div>
<div class="col-md-4">
<label for="socketPath" class="form-label">Socket Path</label>
<input type="text" class="form-control" id="socketPath" name="socketPath" value="{{ SocketPath }}" placeholder="e.g., /tmp/rpc.sock">
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary">Apply</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="alert alert-info">
<p>This is the OpenRPC UI page. It allows you to interact with OpenRPC specifications.</p>
<p>Currently available specs: {{ if SpecList }}{{ len(SpecList) }}{{ else }}0{{ end }}</p>
</div>
</div>
</div>
{{ if SelectedSpec }}
<div class="row">
<!-- Method Tree -->
<div class="col-md-3">
<div class="card">
<div class="card-header"><h5>Methods</h5></div>
<div class="card-body p-0">
<div class="method-tree list-group list-group-flush">
{{ if Methods }}
{{ range m := Methods }}
<a href="/rpcui?spec={{ SelectedSpec }}&method={{ m }}&socketPath={{ SocketPath }}"
class="list-group-item list-group-item-action method-item {{ if eq(m, SelectedMethod) }}active{{ end }}">
{{ m }}
</a>
{{ end }}
{{ else }}
<div class="list-group-item">No methods available</div>
{{ end }}
</div>
</div>
</div>
</div>
<!-- Method Details -->
<div class="col-md-9">
{{ if Method }}
<div class="card mb-4">
<div class="card-header">
<h5>{{ Method.Name }}</h5>
{{ if Method.Description }}<p class="text-muted mb-0">{{ Method.Description }}</p>{{ end }}
</div>
<div class="card-body">
<!-- Parameters -->
<h6>Parameters</h6>
<table class="table table-sm schema-table">
<thead><tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr></thead>
<tbody>
{{ if Method.Params }}
{{ range p := Method.Params }}
<tr>
<td>{{ p.Name }}</td>
<td><code>{{ p.Schema.Type }}</code></td>
<td>{{ if p.Required }}<span class="schema-required">Yes</span>{{ else }}<span class="schema-optional">No</span>{{ end }}</td>
<td>{{ p.Description }}</td>
</tr>
{{ end }}
{{ else }}
<tr><td colspan="4">No parameters</td></tr>
{{ end }}
</tbody>
</table>
<!-- Result -->
<h6 class="mt-4">Result</h6>
<table class="table table-sm schema-table">
<thead><tr><th>Name</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td>{{ Method.Result.Name }}</td>
<td><code>{{ Method.Result.Schema.Type }}</code></td>
<td>{{ Method.Result.Description }}</td>
</tr>
</tbody>
</table>
<!-- Try It -->
<h6 class="mt-4">Try It</h6>
<form id="rpcForm" class="mb-3">
<input type="hidden" name="selectedMethod" value="{{ SelectedMethod }}">
<div class="mb-3">
<label for="paramsEditor" class="form-label">Parameters:</label>
<textarea class="form-control code-editor" id="paramsEditor" rows="10">{{ ExampleParams }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Execute</button>
</form>
<div id="resultContainer" class="result-container d-none">
<h6>Result:</h6>
<pre id="resultOutput" class="bg-light p-2 rounded"></pre>
</div>
<div id="errorContainer" class="result-container d-none">
<h6>Error:</h6>
<pre id="errorOutput" class="bg-light p-2 rounded text-danger"></pre>
</div>
</div>
</div>
{{ else if SelectedMethod }}
<div class="alert alert-warning">Method not found: {{ SelectedMethod }}</div>
{{ else }}
<div class="alert alert-info">Select a method from the list to view details.</div>
{{ end }}
</div>
</div>
{{ end }}
{{end}}
{{block scripts()}}
<script src="/static/js/rpcui.js"></script>
{{end}}

View File

@@ -26,22 +26,22 @@ import (
// Config holds the configuration for the WebDAV server
type Config struct {
Host string
Port int
BasePath string
FileSystem string
ReadTimeout time.Duration
WriteTimeout time.Duration
DebugMode bool
UseAuth bool
Username string
Password string
UseHTTPS bool
CertFile string
KeyFile string
AutoGenerateCerts bool
CertValidityDays int
CertOrganization string
Host string
TCPPort int
BasePath string
FileSystem string
ReadTimeout time.Duration
WriteTimeout time.Duration
DebugMode bool
UseAuth bool
Username string
Password string
UseHTTPS bool
CertFile string
KeyFile string
AutoGenerateCerts bool
CertValidityDays int
CertOrganization string
}
// Server represents the WebDAV server
@@ -74,18 +74,18 @@ func (rw *responseWrapper) Write(b []byte) (int, error) {
// NewServer creates a new WebDAV server
func NewServer(config Config) (*Server, error) {
log.Printf("Creating new WebDAV server with config: host=%s, port=%d, basePath=%s, fileSystem=%s, debug=%v, auth=%v, https=%v",
config.Host, config.Port, config.BasePath, config.FileSystem, config.DebugMode, config.UseAuth, config.UseHTTPS)
log.Printf("Creating new WebDAV server with config: host=%s, TCPPort=%d, basePath=%s, fileSystem=%s, debug=%v, auth=%v, https=%v",
config.Host, config.TCPPort, config.BasePath, config.FileSystem, config.DebugMode, config.UseAuth, config.UseHTTPS)
// Ensure the file system directory exists
if err := os.MkdirAll(config.FileSystem, 0755); err != nil {
log.Printf("ERROR: Failed to create file system directory %s: %v", config.FileSystem, err)
return nil, fmt.Errorf("failed to create file system directory: %w", err)
}
// Log the file system path
log.Printf("Using file system path: %s", config.FileSystem)
// Create debug logger function
debugLog := func(format string, v ...interface{}) {
if config.DebugMode {
@@ -103,7 +103,7 @@ func NewServer(config Config) (*Server, error) {
} else {
log.Printf("WebDAV: %s %s", r.Method, r.URL.Path)
}
// Additional debug logging
if config.DebugMode {
log.Printf("[WebDAV DEBUG] Request Headers: %v", r.Header)
@@ -115,7 +115,7 @@ func NewServer(config Config) (*Server, error) {
// Create HTTP server
httpServer := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
Addr: fmt.Sprintf("%s:%d", config.Host, config.TCPPort),
ReadTimeout: config.ReadTimeout,
WriteTimeout: config.WriteTimeout,
}
@@ -141,15 +141,15 @@ func (s *Server) Start() error {
s.debugLog("Received request: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
s.debugLog("Request Protocol: %s", r.Proto)
s.debugLog("User-Agent: %s", r.UserAgent())
// Log all request headers
for name, values := range r.Header {
s.debugLog("Header: %s = %s", name, values)
}
// Log request depth (important for WebDAV)
s.debugLog("Depth header: %s", r.Header.Get("Depth"))
// Add CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE")
@@ -162,32 +162,32 @@ func (s *Server) Start() error {
w.Header().Set("DAV", "1, 2")
w.Header().Set("MS-Author-Via", "DAV")
w.Header().Set("Allow", "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE")
// Check if this is a macOS WebDAV client
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
strings.Contains(r.UserAgent(), "Darwin")
if isMacOSClient {
s.debugLog("Detected macOS WebDAV client OPTIONS request, adding macOS-specific headers")
// These headers help macOS Finder with WebDAV compatibility
w.Header().Set("X-Dav-Server", "HeroLauncher WebDAV Server")
}
w.WriteHeader(http.StatusOK)
return
}
// Handle authentication if enabled
if s.config.UseAuth {
s.debugLog("Authentication required for request")
auth := r.Header.Get("Authorization")
// Check if this is a macOS WebDAV client
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
strings.Contains(r.UserAgent(), "Darwin")
// Special handling for OPTIONS requests from macOS clients
if r.Method == "OPTIONS" && isMacOSClient {
s.debugLog("Detected macOS WebDAV client OPTIONS request, allowing without auth")
@@ -196,28 +196,28 @@ func (s *Server) Start() error {
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
return
}
if auth == "" {
s.debugLog("No Authorization header provided for non-OPTIONS request")
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse the authentication header
if !strings.HasPrefix(auth, "Basic ") {
s.debugLog("Invalid Authorization header format: %s", auth)
http.Error(w, "Invalid authorization header", http.StatusBadRequest)
return
}
payload, err := base64.StdEncoding.DecodeString(auth[6:])
if err != nil {
s.debugLog("Failed to decode Authorization header: %v, raw header: %s", err, auth)
http.Error(w, "Invalid authorization header", http.StatusBadRequest)
return
}
pair := strings.SplitN(string(payload), ":", 2)
if len(pair) != 2 {
s.debugLog("Invalid credential format: could not split into username:password")
@@ -225,17 +225,17 @@ func (s *Server) Start() error {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Log username for debugging (don't log password)
s.debugLog("Received credentials for user: %s", pair[0])
if pair[0] != s.config.Username || pair[1] != s.config.Password {
s.debugLog("Invalid credentials provided, expected user: %s", s.config.Username)
w.Header().Set("WWW-Authenticate", "Basic realm=\"WebDAV Server\"")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
s.debugLog("Authentication successful for user: %s", pair[0])
}
@@ -252,17 +252,17 @@ func (s *Server) Start() error {
}
// Add macOS-specific headers for better compatibility
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
isMacOSClient := strings.Contains(r.UserAgent(), "WebDAVFS") ||
strings.Contains(r.UserAgent(), "WebDAVLib") ||
strings.Contains(r.UserAgent(), "Darwin")
if isMacOSClient {
s.debugLog("Adding macOS-specific headers for better compatibility")
// These headers help macOS Finder with WebDAV compatibility
w.Header().Set("MS-Author-Via", "DAV")
w.Header().Set("X-Dav-Server", "HeroLauncher WebDAV Server")
w.Header().Set("DAV", "1, 2")
// Special handling for PROPFIND requests from macOS
if r.Method == "PROPFIND" {
s.debugLog("Handling macOS PROPFIND request with special compatibility")
@@ -281,7 +281,7 @@ func (s *Server) Start() error {
// Log response details
s.debugLog("Response status: %d", responseWrapper.statusCode)
s.debugLog("Response content type: %s", w.Header().Get("Content-Type"))
// Log detailed information for debugging connection issues
if responseWrapper.statusCode >= 400 {
s.debugLog("ERROR: WebDAV request failed with status %d", responseWrapper.statusCode)
@@ -303,24 +303,24 @@ func (s *Server) Start() error {
log.Printf("ERROR: HTTPS enabled but certificate or key file not provided and auto-generation is disabled")
return fmt.Errorf("HTTPS enabled but certificate or key file not provided and auto-generation is disabled")
}
// Auto-generate certificates if needed
if (s.config.CertFile == "" || s.config.KeyFile == "" ||
!fileExists(s.config.CertFile) || !fileExists(s.config.KeyFile)) &&
if (s.config.CertFile == "" || s.config.KeyFile == "" ||
!fileExists(s.config.CertFile) || !fileExists(s.config.KeyFile)) &&
s.config.AutoGenerateCerts {
s.debugLog("Certificate files not found, auto-generating...")
// Get base directory from the file system path
baseDir := filepath.Dir(s.config.FileSystem)
// Create certificates directory if it doesn't exist
certsDir := filepath.Join(baseDir, "certificates")
if err := os.MkdirAll(certsDir, 0755); err != nil {
log.Printf("ERROR: Failed to create certificates directory: %v", err)
return fmt.Errorf("failed to create certificates directory: %w", err)
}
// Set default certificate paths if not provided
if s.config.CertFile == "" {
s.config.CertFile = filepath.Join(certsDir, "webdav.crt")
@@ -328,44 +328,44 @@ func (s *Server) Start() error {
if s.config.KeyFile == "" {
s.config.KeyFile = filepath.Join(certsDir, "webdav.key")
}
// Generate certificates
if err := generateCertificate(
s.config.CertFile,
s.config.KeyFile,
s.config.CertOrganization,
s.config.CertFile,
s.config.KeyFile,
s.config.CertOrganization,
s.config.CertValidityDays,
s.debugLog,
); err != nil {
log.Printf("ERROR: Failed to generate certificates: %v", err)
return fmt.Errorf("failed to generate certificates: %w", err)
}
log.Printf("Successfully generated self-signed certificates at %s and %s",
log.Printf("Successfully generated self-signed certificates at %s and %s",
s.config.CertFile, s.config.KeyFile)
}
// Verify certificate files exist
if !fileExists(s.config.CertFile) || !fileExists(s.config.KeyFile) {
log.Printf("ERROR: Certificate files not found at %s and/or %s",
log.Printf("ERROR: Certificate files not found at %s and/or %s",
s.config.CertFile, s.config.KeyFile)
return fmt.Errorf("certificate files not found")
}
// Configure TLS
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
s.httpServer.TLSConfig = tlsConfig
log.Printf("Starting WebDAV server with HTTPS on %s using certificates: %s, %s",
log.Printf("Starting WebDAV server with HTTPS on %s using certificates: %s, %s",
s.httpServer.Addr, s.config.CertFile, s.config.KeyFile)
err = s.httpServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
} else {
log.Printf("Starting WebDAV server with HTTP on %s", s.httpServer.Addr)
err = s.httpServer.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
log.Printf("ERROR: WebDAV server failed to start: %v", err)
return err
@@ -389,10 +389,10 @@ func (s *Server) Stop() error {
func DefaultConfig() Config {
// Use system temp directory as default base path
defaultBasePath := filepath.Join(os.TempDir(), "heroagent")
return Config{
Host: "0.0.0.0",
Port: 9999,
TCPPort: 9999,
BasePath: "/",
FileSystem: defaultBasePath,
ReadTimeout: 30 * time.Second,
@@ -421,24 +421,24 @@ func fileExists(filename string) bool {
// generateCertificate creates a self-signed TLS certificate and key
func generateCertificate(certFile, keyFile, organization string, validityDays int, debugLog func(format string, args ...interface{})) error {
debugLog("Generating self-signed certificate: certFile=%s, keyFile=%s, organization=%s, validityDays=%d",
debugLog("Generating self-signed certificate: certFile=%s, keyFile=%s, organization=%s, validityDays=%d",
certFile, keyFile, organization, validityDays)
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
// Prepare certificate template
notBefore := time.Now()
notAfter := notBefore.Add(time.Duration(validityDays) * 24 * time.Hour)
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
@@ -453,36 +453,36 @@ func generateCertificate(certFile, keyFile, organization string, validityDays in
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
DNSNames: []string{"localhost"},
}
// Create certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Write certificate to file
certOut, err := os.Create(certFile)
if err != nil {
return fmt.Errorf("failed to open %s for writing: %w", certFile, err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return fmt.Errorf("failed to write certificate to file: %w", err)
}
// Write private key to file
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open %s for writing: %w", keyFile, err)
}
defer keyOut.Close()
privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}
if err := pem.Encode(keyOut, privateKeyPEM); err != nil {
return fmt.Errorf("failed to write private key to file: %w", err)
}
debugLog("Successfully generated self-signed certificate valid for %d days", validityDays)
return nil
}

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"os"
"git.ourworld.tf/herocode/heroagent/pkg/system/builders/hetznerinstall"
"git.threefold.info/herocode/heroagent/pkg/system/builders/hetznerinstall"
)
func main() {

View File

@@ -6,10 +6,10 @@ import (
"os/exec"
"path/filepath"
"git.ourworld.tf/herocode/heroagent/pkg/system/builders/postgresql/dependencies"
"git.ourworld.tf/herocode/heroagent/pkg/system/builders/postgresql/gosp"
"git.ourworld.tf/herocode/heroagent/pkg/system/builders/postgresql/postgres"
"git.ourworld.tf/herocode/heroagent/pkg/system/builders/postgresql/verification"
"git.threefold.info/herocode/heroagent/pkg/system/builders/postgresql/dependencies"
"git.threefold.info/herocode/heroagent/pkg/system/builders/postgresql/gosp"
"git.threefold.info/herocode/heroagent/pkg/system/builders/postgresql/postgres"
"git.threefold.info/herocode/heroagent/pkg/system/builders/postgresql/verification"
)
// Constants for PostgreSQL installation

Some files were not shown because too many files have changed in this diff Show More