diff --git a/heroagent b/heroagent index 4a32c51..b766308 100755 Binary files a/heroagent and b/heroagent differ diff --git a/pkg/servers/ui/controllers/openrpc_controller.go b/pkg/servers/ui/controllers/openrpc_controller.go new file mode 100644 index 0000000..58d0331 --- /dev/null +++ b/pkg/servers/ui/controllers/openrpc_controller.go @@ -0,0 +1,173 @@ +package controllers + +import ( + "encoding/json" + "log" + + orpcmodels "git.ourworld.tf/herocode/heroagent/pkg/openrpc/models" + uimodels "git.ourworld.tf/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, ¶ms); 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 + } +} diff --git a/pkg/servers/ui/models/openrpc_manager.go b/pkg/servers/ui/models/openrpc_manager.go new file mode 100644 index 0000000..90cae25 --- /dev/null +++ b/pkg/servers/ui/models/openrpc_manager.go @@ -0,0 +1,190 @@ +package models + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "time" + + "git.ourworld.tf/herocode/heroagent/pkg/openrpc" + "git.ourworld.tf/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 +} diff --git a/pkg/servers/ui/routes/router.go b/pkg/servers/ui/routes/router.go index 1fdfd54..e59fd1b 100644 --- a/pkg/servers/ui/routes/router.go +++ b/pkg/servers/ui/routes/router.go @@ -12,11 +12,13 @@ func SetupRoutes(app *fiber.App) { // 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 @@ -43,6 +45,10 @@ func SetupRoutes(app *fiber.App) { 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 diff --git a/pkg/servers/ui/static/css/rpcui.css b/pkg/servers/ui/static/css/rpcui.css new file mode 100644 index 0000000..9930bb8 --- /dev/null +++ b/pkg/servers/ui/static/css/rpcui.css @@ -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); } +} \ No newline at end of file diff --git a/pkg/servers/ui/static/js/rpcui.js b/pkg/servers/ui/static/js/rpcui.js new file mode 100644 index 0000000..9efb9b1 --- /dev/null +++ b/pkg/servers/ui/static/js/rpcui.js @@ -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 = '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); + }); + } +}); \ No newline at end of file diff --git a/pkg/servers/ui/views/components/sidebar.jet b/pkg/servers/ui/views/components/sidebar.jet index 0139671..9c7dfba 100644 --- a/pkg/servers/ui/views/components/sidebar.jet +++ b/pkg/servers/ui/views/components/sidebar.jet @@ -18,6 +18,12 @@ Job Manager + {{ end }} \ No newline at end of file diff --git a/pkg/servers/ui/views/pages/rpcui.jet b/pkg/servers/ui/views/pages/rpcui.jet new file mode 100644 index 0000000..137f261 --- /dev/null +++ b/pkg/servers/ui/views/pages/rpcui.jet @@ -0,0 +1,185 @@ +{{ extends "../layouts/base" }} + +{{ block title() }}OpenRPC UI - HeroApp UI{{ end }} + +{{ block css() }} + +{{ end }} + +{{ block body() }} +
+

OpenRPC UI

+
+ +
+
+
+
+
Select OpenRPC Specification
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
+
+

This is the OpenRPC UI page. It allows you to interact with OpenRPC specifications.

+

Currently available specs: {{ if .SpecList }}{{ len(.SpecList) }}{{ else }}0{{ end }}

+
+
+
+ +{{ if .SelectedSpec }} +
+ +
+
+
+
Methods
+
+
+
+ {{ if .Methods }} + {{ range .Methods }} + + {{ . }} + + {{ end }} + {{ else }} +
No methods available
+ {{ end }} +
+
+
+
+ + +
+ {{ if .Method }} +
+
+
{{ .Method.Name }}
+ {{ if .Method.Description }} +

{{ .Method.Description }}

+ {{ end }} +
+
+ +
Parameters
+ + + + + + + + + + + {{ if .Method.Params }} + {{ range .Method.Params }} + + + + + + + {{ end }} + {{ else }} + + + + {{ end }} + +
NameTypeRequiredDescription
{{ .Name }}{{ .Schema.Type }} + {{ if .Required }} + Yes + {{ else }} + No + {{ end }} + {{ .Description }}
No parameters
+ + +
Result
+ + + + + + + + + + + + + + + +
NameTypeDescription
{{ .Method.Result.Name }}{{ .Method.Result.Schema.Type }}{{ .Method.Result.Description }}
+ + +
Try It
+
+ +
+ + +
+ +
+ +
+
Result:
+

+        
+ +
+
Error:
+

+        
+
+
+ {{ else if .SelectedMethod }} +
+ Method not found: {{ .SelectedMethod }} +
+ {{ else }} +
+ Select a method from the list to view details. +
+ {{ end }} +
+
+{{ end }} +{{ end }} + +{{ block scripts() }} + +{{ end }} \ No newline at end of file