This commit is contained in:
despiegk 2025-05-24 10:33:50 +04:00
parent 2ee8a95a90
commit c9b14730ad
8 changed files with 845 additions and 0 deletions

BIN
heroagent

Binary file not shown.

View File

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

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

View File

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

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

@ -18,6 +18,12 @@
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

@ -0,0 +1,185 @@
{{ extends "../layouts/base" }}
{{ block title() }}OpenRPC UI - HeroApp 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 .SpecList }}
<option value="{{ . }}" {{ if eq . $.SelectedSpec }}selected{{ end }}>{{ . }}</option>
{{ 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 .Methods }}
<a href="/rpcui?spec={{ $.SelectedSpec }}&method={{ . }}&socketPath={{ $.SocketPath }}"
class="list-group-item list-group-item-action method-item {{ if eq . $.SelectedMethod }}active{{ end }}">
{{ . }}
</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 .Method.Params }}
<tr>
<td>{{ .Name }}</td>
<td><code>{{ .Schema.Type }}</code></td>
<td>
{{ if .Required }}
<span class="schema-required">Yes</span>
{{ else }}
<span class="schema-optional">No</span>
{{ end }}
</td>
<td>{{ .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 }}