Implement comprehensive admin UI with job management and API key display

Admin UI Features:
- Complete job lifecycle: create, run, view status, view output, delete
- Job table with sorting, filtering, and real-time status updates
- Status polling with countdown timers for running jobs
- Job output modal with result/error display
- API keys management: create keys, list keys with secrets visible
- Sidebar toggle between runners and keys views
- Toast notifications for errors
- Modern dark theme UI with responsive design

Supervisor Improvements:
- Fixed job status persistence using client methods
- Refactored get_job_result to use client.get_status, get_result, get_error
- Changed runner_rust dependency from git to local path
- Authentication system with API key scopes (admin, user, register)
- Job listing with status fetching from Redis
- Services module for job and auth operations

OpenRPC Client:
- Added auth_list_keys method for fetching API keys
- WASM bindings for browser usage
- Proper error handling and type conversions

Build Status:  All components build successfully
This commit is contained in:
Timur Gordon
2025-10-28 03:32:25 +01:00
parent 5f5dd35dbc
commit f249c8b49b
36 changed files with 4811 additions and 6421 deletions

View File

@@ -5,15 +5,20 @@
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response, Headers};
use web_sys::{Headers, Request, RequestInit, RequestMode, Response};
use serde_json::json;
use secp256k1::{Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature};
use sha2::{Sha256, Digest};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
/// WASM-compatible client for communicating with Hero Supervisor OpenRPC server
#[wasm_bindgen]
#[derive(Clone)]
pub struct WasmSupervisorClient {
server_url: String,
secret: Option<String>,
}
/// Error types for WASM client operations
@@ -38,6 +43,14 @@ pub enum WasmClientError {
/// Result type for WASM client operations
pub type WasmClientResult<T> = Result<T, WasmClientError>;
/// Auth verification response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthVerifyResponse {
pub valid: bool,
pub name: String,
pub scope: String,
}
/// JSON-RPC request structure
#[derive(Serialize)]
struct JsonRpcRequest {
@@ -199,11 +212,24 @@ pub struct WasmJob {
#[wasm_bindgen]
impl WasmSupervisorClient {
/// Create a new WASM supervisor client
/// Create a new WASM supervisor client without authentication
#[wasm_bindgen(constructor)]
pub fn new(server_url: String) -> Self {
console_log::init_with_level(log::Level::Info).ok();
Self { server_url }
Self {
server_url,
secret: None,
}
}
/// Create a new WASM supervisor client with authentication secret
#[wasm_bindgen]
pub fn with_secret(server_url: String, secret: String) -> Self {
console_log::init_with_level(log::Level::Info).ok();
Self {
server_url,
secret: Some(secret),
}
}
/// Get the server URL
@@ -221,13 +247,88 @@ impl WasmSupervisorClient {
}
}
/// Register a new runner to the supervisor with secret authentication
pub async fn register_runner(&self, secret: &str, name: &str, queue: &str) -> Result<String, JsValue> {
let params = serde_json::json!([{
"secret": secret,
/// Verify an API key and return its metadata as JSON
/// The key is sent via Authorization header (Bearer token)
pub async fn auth_verify(&self, key: String) -> Result<JsValue, JsValue> {
// Create a temporary client with the key to verify
let temp_client = WasmSupervisorClient::with_secret(self.server_url.clone(), key);
// Send empty object as params - the key is in the Authorization header
let params = serde_json::json!({});
match temp_client.call_method("auth.verify", params).await {
Ok(result) => {
// Parse to AuthVerifyResponse to validate, then convert to JsValue
let auth_response: AuthVerifyResponse = serde_json::from_value(result)
.map_err(|e| JsValue::from_str(&format!("Failed to parse auth response: {}", e)))?;
// Convert to JsValue
serde_wasm_bindgen::to_value(&auth_response)
.map_err(|e| JsValue::from_str(&format!("Failed to convert to JsValue: {}", e)))
}
Err(e) => Err(JsValue::from_str(&format!("Failed to verify auth: {}", e))),
}
}
/// Verify the client's stored API key
/// Uses the secret that was set when creating the client with with_secret()
pub async fn auth_verify_self(&self) -> Result<JsValue, JsValue> {
let key = self.secret.as_ref()
.ok_or_else(|| JsValue::from_str("Client not authenticated - use with_secret() to create authenticated client"))?;
self.auth_verify(key.clone()).await
}
/// Create a new API key (admin only)
/// Returns the created API key with its key string
pub async fn auth_create_key(&self, name: String, scope: String) -> Result<JsValue, JsValue> {
let params = serde_json::json!({
"name": name,
"queue": queue
}]);
"scope": scope
});
match self.call_method("auth.create_key", params).await {
Ok(result) => Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?),
Err(e) => Err(JsValue::from_str(&format!("Failed to create key: {}", e))),
}
}
/// List all API keys (admin only)
pub async fn auth_list_keys(&self) -> Result<JsValue, JsValue> {
match self.call_method("auth.list_keys", serde_json::Value::Null).await {
Ok(result) => Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?),
Err(e) => Err(JsValue::from_str(&format!("Failed to list keys: {}", e))),
}
}
/// Remove an API key (admin only)
pub async fn auth_remove_key(&self, key: String) -> Result<bool, JsValue> {
let params = serde_json::json!({
"key": key
});
match self.call_method("auth.remove_key", params).await {
Ok(result) => {
if let Some(success) = result.as_bool() {
Ok(success)
} else {
Err(JsValue::from_str("Invalid response format: expected boolean"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to remove key: {}", e))),
}
}
/// Register a new runner to the supervisor
/// The queue name is automatically set to match the runner name
/// Authentication uses the secret from Authorization header (set during client creation)
pub async fn register_runner(&self, name: String) -> Result<String, JsValue> {
// Secret is sent via Authorization header, not in params
let params = serde_json::json!({
"name": name
});
match self.call_method("register_runner", params).await {
Ok(result) => {
@@ -238,13 +339,13 @@ impl WasmSupervisorClient {
Err(JsValue::from_str("Invalid response format: expected runner name"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
Err(e) => Err(JsValue::from_str(&format!("Failed to register runner: {}", e))),
}
}
/// Create a job (fire-and-forget, non-blocking)
/// Create a job (fire-and-forget, non-blocking) - DEPRECATED: Use create_job with API key auth
#[wasm_bindgen]
pub async fn create_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
pub async fn create_job_with_secret(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
let params = serde_json::json!([{
"secret": secret,
@@ -326,17 +427,65 @@ impl WasmSupervisorClient {
}
}
/// List all job IDs from Redis
pub async fn list_jobs(&self) -> Result<Vec<String>, JsValue> {
match self.call_method("jobs.list", serde_json::Value::Null).await {
/// Create a job from a JsValue (full Job object)
pub async fn create_job(&self, job: JsValue) -> Result<String, JsValue> {
// Convert JsValue to serde_json::Value
let job_value: serde_json::Value = serde_wasm_bindgen::from_value(job)
.map_err(|e| JsValue::from_str(&format!("Failed to parse job: {}", e)))?;
// Wrap in RunJobParams structure and pass as positional parameter
let params = serde_json::json!([{
"job": job_value
}]);
match self.call_method("jobs.create", params).await {
Ok(result) => {
if let Ok(jobs) = serde_json::from_value::<Vec<String>>(result) {
Ok(jobs)
if let Some(job_id) = result.as_str() {
Ok(job_id.to_string())
} else {
Err(JsValue::from_str("Invalid response format for list_jobs"))
Err(JsValue::from_str("Invalid response format: expected job ID"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))),
}
}
/// Create a job with basic parameters (simplified version)
pub async fn create_simple_job(
&self,
runner: String,
caller_id: String,
context_id: String,
payload: String,
executor: String,
) -> Result<String, JsValue> {
// Generate a unique job ID
let job_id = format!("job-{}", uuid::Uuid::new_v4());
let job = serde_json::json!({
"id": job_id,
"runner": runner,
"caller_id": caller_id,
"context_id": context_id,
"payload": payload,
"executor": executor,
"timeout": 30,
"env": {}
});
let params = serde_json::json!({
"job": job
});
match self.call_method("jobs.create", params).await {
Ok(result) => {
if let Some(job_id) = result.as_str() {
Ok(job_id.to_string())
} else {
Err(JsValue::from_str("Invalid response format: expected job ID"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))),
}
}
@@ -410,9 +559,11 @@ impl WasmSupervisorClient {
/// Delete a job by ID
#[wasm_bindgen]
pub async fn delete_job(&self, job_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([job_id]);
let params = serde_json::json!([{
"job_id": job_id
}]);
match self.call_method("delete_job", params).await {
match self.call_method("job.delete", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e)))
}
@@ -679,6 +830,54 @@ impl WasmJob {
}
impl WasmSupervisorClient {
/// List all jobs (returns full job objects as Vec<serde_json::Value>)
/// This is not exposed to WASM directly due to type limitations
pub async fn list_jobs(&self) -> Result<Vec<serde_json::Value>, JsValue> {
let params = serde_json::json!([]);
match self.call_method("jobs.list", params).await {
Ok(result) => {
if let Ok(jobs) = serde_json::from_value::<Vec<serde_json::Value>>(result) {
Ok(jobs)
} else {
Err(JsValue::from_str("Invalid response format for jobs.list"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Start a previously created job by queuing it to its assigned runner
pub async fn start_job(&self, job_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([{
"job_id": job_id
}]);
match self.call_method("job.start", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Get the status of a job
pub async fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value, JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("job.status", params).await {
Ok(result) => Ok(result),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Get the result of a completed job
pub async fn get_job_result(&self, job_id: &str) -> Result<serde_json::Value, JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("job.result", params).await {
Ok(result) => Ok(result),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Internal method to make JSON-RPC calls
async fn call_method(&self, method: &str, params: serde_json::Value) -> WasmClientResult<serde_json::Value> {
let request = JsonRpcRequest {
@@ -694,6 +893,12 @@ impl WasmSupervisorClient {
let headers = Headers::new().map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
headers.set("Content-Type", "application/json")
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
// Add Authorization header if secret is present
if let Some(secret) = &self.secret {
headers.set("Authorization", &format!("Bearer {}", secret))
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
}
// Create request init
let opts = RequestInit::new();
@@ -760,3 +965,82 @@ pub fn create_job(id: String, payload: String, executor: String, runner: String)
pub fn create_client(server_url: String) -> WasmSupervisorClient {
WasmSupervisorClient::new(server_url)
}
/// Sign a job's canonical representation with a private key
/// Returns a tuple of (public_key_hex, signature_hex)
#[wasm_bindgen]
pub fn sign_job_canonical(
canonical_repr: String,
private_key_hex: String,
) -> Result<JsValue, JsValue> {
// Decode private key from hex
let secret_bytes = hex::decode(&private_key_hex)
.map_err(|e| JsValue::from_str(&format!("Invalid private key hex: {}", e)))?;
let secret_key = SecretKey::from_slice(&secret_bytes)
.map_err(|e| JsValue::from_str(&format!("Invalid private key: {}", e)))?;
// Get the public key
let secp = Secp256k1::new();
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
let public_key_hex = hex::encode(public_key.serialize());
// Hash the canonical representation
let mut hasher = Sha256::new();
hasher.update(canonical_repr.as_bytes());
let hash = hasher.finalize();
// Create message from hash
let message = Message::from_digest_slice(&hash)
.map_err(|e| JsValue::from_str(&format!("Invalid message: {}", e)))?;
// Sign the message
let signature = secp.sign_ecdsa(&message, &secret_key);
let signature_hex = hex::encode(signature.serialize_compact());
// Return as JS object
let result = serde_json::json!({
"public_key": public_key_hex,
"signature": signature_hex
});
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to serialize result: {}", e)))
}
/// Create canonical representation of a job for signing
/// This matches the format used in runner_rust Job::canonical_representation
#[wasm_bindgen]
pub fn create_job_canonical_repr(
id: String,
caller_id: String,
context_id: String,
payload: String,
runner: String,
executor: String,
timeout: u64,
env_vars_json: String,
) -> Result<String, JsValue> {
// Parse env_vars from JSON
let env_vars: std::collections::HashMap<String, String> = serde_json::from_str(&env_vars_json)
.map_err(|e| JsValue::from_str(&format!("Invalid env_vars JSON: {}", e)))?;
// Sort env_vars keys for deterministic ordering
let mut env_vars_sorted: Vec<_> = env_vars.iter().collect();
env_vars_sorted.sort_by_key(|&(k, _)| k);
// Create canonical representation (matches Job::canonical_representation in runner_rust)
let canonical = format!(
"{}:{}:{}:{}:{}:{}:{}:{:?}",
id,
caller_id,
context_id,
payload,
runner,
executor,
timeout,
env_vars_sorted
);
Ok(canonical)
}