Compare commits

...

11 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
2bfe4161b2 ... 2025-05-23 22:09:57 +04:00
0b1d9907a7 ... 2025-05-23 16:30:10 +04:00
113 changed files with 5316 additions and 450 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,213 +0,0 @@
# Project Plan: Bootstrap UI with Fiber and Jet
**Goal:** Develop a new UI module using Go (Fiber framework), Jet templates, and Bootstrap 5, following an MVC pattern. The UI will include a dashboard and a process manager page.
**Location:** `pkg/servers/ui`
---
## 1. Directory Structure (MVC)
We'll establish a clear MVC structure within `pkg/servers/ui`:
```mermaid
graph TD
A[pkg/servers/ui] --> B(app.go);
A --> C(controllers);
A --> M(models);
A --> V(views);
A --> S(static);
A --> R(routes);
C --> C1(auth_controller.go);
C --> C2(dashboard_controller.go);
C --> C3(process_controller.go);
M --> M1(process_model.go);
M --> M2(user_model.go);
V --> VL(layouts);
V --> VP(pages);
V --> VC(components);
VL --> VLL(base.jet);
VP --> VPD(dashboard.jet);
VP --> VPP(process_manager.jet);
VP --> VPL(login.jet);
VC --> VCN(navbar.jet);
VC --> VCS(sidebar.jet);
S --> SCSS(css);
S --> SJS(js);
S --> SIMG(img);
SCSS --> custom.css; // For any custom styles
SJS --> custom.js; // For any custom JavaScript
R --> R1(router.go);
```
**Detailed Breakdown:**
* **`pkg/servers/ui/app.go`:** Main application setup for this UI module. Initializes Fiber, Jet, routes, and middleware.
* **`pkg/servers/ui/controllers/`**: Handles incoming requests, interacts with models, and selects views.
* `auth_controller.go`: Handles login/logout (stubs for now).
* `dashboard_controller.go`: Handles the dashboard page.
* `process_controller.go`: Handles the process manager page.
* **`pkg/servers/ui/models/`**: Business logic and data interaction.
* `process_model.go`: Interacts with `pkg/system/processmanager` to fetch and manage process data.
* `user_model.go`: (Placeholder for basic auth stubs).
* **`pkg/servers/ui/views/`**: Jet templates.
* **`layouts/`**: Base layout templates.
* `base.jet`: Main site layout (includes Bootstrap, navbar, sidebar, main content area).
* **`pages/`**: Specific page templates.
* `dashboard.jet`: Dashboard content.
* `process_manager.jet`: Process manager table and controls.
* `login.jet`: (Placeholder login page).
* **`components/`**: Reusable UI components.
* `navbar.jet`: Top navigation bar.
* `sidebar.jet`: Left tree menu.
* **`pkg/servers/ui/static/`**: Local static assets.
* **`css/`**: Custom CSS files.
* `custom.css`
* **`js/`**: Custom JavaScript files.
* `custom.js`
* **`img/`**: Image assets.
* **`pkg/servers/ui/routes/router.go`:** Defines URL routes and maps them to controller actions.
---
## 2. Core UI Components
* **Base Layout (`views/layouts/base.jet`):**
* HTML5 boilerplate.
* Include Bootstrap 5 CSS from CDN:
```html
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4Q6Gf2aSP4eDXB8Miphtr37CMZZQ5oXLH2yaXMJ2w8e2ZtHTl7GptT4jmndRuHDT" crossorigin="anonymous">
```
* Include Bootstrap 5 JS Bundle from CDN (typically placed before the closing `</body>` tag):
```html
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js" integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO" crossorigin="anonymous"></script>
```
* Include local custom CSS (e.g., `/static/css/custom.css`).
* Include local custom JS (e.g., `/static/js/custom.js`).
* Structure:
* Navbar (include `components/navbar.jet`)
* Main container (Bootstrap `container-fluid` or similar)
* Sidebar (include `components/sidebar.jet`)
* Content area (where page-specific content will be injected)
* **Navbar (`views/components/navbar.jet`):**
* Bootstrap Navbar component.
* Site title/logo.
* Login/Logout buttons (stubs, basic links for now).
* **Sidebar/Tree Menu (`views/components/sidebar.jet`):**
* Bootstrap navigation component (e.g., Navs, List group).
* Links:
* Dashboard
* Process Manager
---
## 3. Pages
* **Dashboard Page:**
* **Controller (`controllers/dashboard_controller.go`):**
* `ShowDashboard()`: Renders the dashboard page.
* **View (`views/pages/dashboard.jet`):**
* Extends `layouts/base.jet`.
* Simple placeholder content for now (e.g., "Welcome to the Dashboard!").
* **Process Manager Page:**
* **Model (`models/process_model.go`):**
* `GetProcesses()`: Function to call `pkg/system/processmanager` to get a list of running processes. Will need to define a struct for process information (PID, Name, CPU, Memory).
* `KillProcess(pid string)`: Function to call `pkg/system/processmanager` to terminate a process.
* **Controller (`controllers/process_controller.go`):**
* `ShowProcessManager()`:
* Calls `models.GetProcesses()`.
* Passes process data to the view.
* Renders `views/pages/process_manager.jet`.
* `HandleKillProcess()`: (Handles POST request to kill a process)
* Extracts PID from request.
* Calls `models.KillProcess(pid)`.
* Redirects back to the process manager page or returns a status.
* **View (`views/pages/process_manager.jet`):**
* Extends `layouts/base.jet`.
* Displays processes in a Bootstrap table:
* Columns: PID, Name, CPU, Memory, Actions.
* Actions column: "Kill" button for each process (linking to `HandleKillProcess` or using JS for an AJAX call).
---
## 4. Fiber & Jet Integration (`app.go` and `routes/router.go`)
* **`pkg/servers/ui/app.go`:**
* `NewApp()` function:
* Initialize Fiber app: `fiber.New()`.
* Initialize Jet template engine:
* `jet.NewSet(jet.NewOSFileSystemLoader("./pkg/servers/ui/views"), jet.InDevelopmentMode())` (or adjust path as needed).
* Pass Jet views to Fiber: `app.Settings.Views = views`.
* Setup static file serving: `app.Static("/static", "./pkg/servers/ui/static")`.
* Setup routes: Call a function from `routes/router.go`.
* Return the Fiber app instance.
* This `app.go` can then be imported and run from your main application entry point (e.g., in `cmd/heroagent/main.go`).
* **`pkg/servers/ui/routes/router.go`:**
* `SetupRoutes(app *fiber.App, processController *controllers.ProcessController, ...)` function:
* `app.Get("/", dashboardController.ShowDashboard)`
* `app.Get("/processes", processController.ShowProcessManager)`
* `app.Post("/processes/kill/:pid", processController.HandleKillProcess)` (or similar for kill action)
* `app.Get("/login", authController.ShowLoginPage)` (stub)
* `app.Post("/login", authController.HandleLogin)` (stub)
* `app.Get("/logout", authController.HandleLogout)` (stub)
---
## 5. Authentication Stubs
* **`controllers/auth_controller.go`:**
* `ShowLoginPage()`: Renders a simple login form.
* `HandleLogin()`: Placeholder logic (e.g., always "logs in" or checks a hardcoded credential). Sets a dummy session/cookie.
* `HandleLogout()`: Placeholder logic. Clears dummy session/cookie.
* **`views/pages/login.jet`:**
* Simple Bootstrap form for username/password.
---
## 6. Dependencies (go.mod)
Ensure these are added to your `go.mod` file:
* `github.com/gofiber/fiber/v2`
* `github.com/CloudyKit/jet/v6`
---
## Request Flow Example: Process Manager Page
```mermaid
sequenceDiagram
participant User
participant Browser
participant FiberApp [Fiber App (pkg/servers/ui/app.go)]
participant Router [routes/router.go]
participant ProcessCtrl [controllers/process_controller.go]
participant ProcessMdl [models/process_model.go]
participant SysProcMgr [pkg/system/processmanager]
participant JetEngine [CloudyKit/jet/v6]
participant View [views/pages/process_manager.jet]
User->>Browser: Navigates to /processes
Browser->>FiberApp: GET /processes
FiberApp->>Router: Route request
Router->>ProcessCtrl: Calls ShowProcessManager()
ProcessCtrl->>ProcessMdl: Calls GetProcesses()
ProcessMdl->>SysProcMgr: Fetches process list
SysProcMgr-->>ProcessMdl: Returns process data
ProcessMdl-->>ProcessCtrl: Returns process data
ProcessCtrl->>JetEngine: Renders process_manager.jet with data
JetEngine->>View: Populates template
View-->>JetEngine: Rendered HTML
JetEngine-->>ProcessCtrl: Rendered HTML
ProcessCtrl-->>FiberApp: Returns HTML response
FiberApp-->>Browser: Sends HTML response
Browser->>User: Displays Process Manager page

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,17 +8,17 @@
<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>
{{ import "components/navbar.jet" }}
{{ yield navbar() }}
{{ include "../components/navbar" }}
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3">
{{ import "components/sidebar.jet" }}
{{ yield sidebar() }}
{{ include "../components/sidebar" }}
</div>
</nav>

View File

@@ -1,4 +1,4 @@
{{ extends "layouts/base.jet" }}
{{ extends "../layouts/base" }}
{{ block title() }}Dashboard - HeroApp UI{{ end }}

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

@@ -1,4 +1,4 @@
{{ extends "layouts/base.jet" }}
{{ extends "../layouts/base" }}
{{ block title() }}Login - HeroApp UI{{ end }}

View File

@@ -1,4 +1,4 @@
{{ extends "layouts/base.jet" }}
{{ extends "../layouts/base" }}
{{ block title() }}Process Manager - HeroApp UI{{ end }}
@@ -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}}

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