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

@@ -13,7 +13,7 @@ use log::{debug, info, error};
use crate::supervisor::Supervisor;
use crate::runner::{Runner, RunnerError};
use crate::runner::{ProcessManagerError, ProcessStatus, LogInfo};
use crate::runner::{ProcessStatus, LogInfo};
use crate::job::Job;
use crate::ProcessManagerType;
use serde::{Deserialize, Serialize};
@@ -69,12 +69,10 @@ fn invalid_params_error(msg: &str) -> ErrorObject<'static> {
}
/// Request parameters for registering a new runner
/// TODO: Move secret to HTTP Authorization header for better security
#[derive(Debug, Deserialize, Serialize)]
/// The secret is extracted from Authorization header
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RegisterRunnerParams {
pub secret: String,
pub name: String,
// Note: queue is derived from name (name = queue)
}
/// Request parameters for runner management operations
@@ -112,17 +110,15 @@ pub struct RunnerConfig {
}
/// Request parameters for running a job
/// TODO: Move secret to HTTP Authorization header for better security
/// The secret is extracted from Authorization header
#[derive(Debug, Deserialize, Serialize)]
pub struct RunJobParams {
pub secret: String,
pub job: Job,
}
/// Request parameters for starting a job
#[derive(Debug, Deserialize, Serialize)]
pub struct StartJobParams {
pub secret: String,
pub job_id: String,
}
@@ -139,9 +135,6 @@ pub enum JobResult {
pub struct JobStatusResponse {
pub job_id: String,
pub status: String,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
}
/// Request parameters for queuing a job
@@ -169,7 +162,6 @@ pub struct StopJobParams {
/// Request parameters for deleting a job
#[derive(Debug, Deserialize, Serialize)]
pub struct DeleteJobParams {
pub secret: String,
pub job_id: String,
}
@@ -257,6 +249,23 @@ pub struct LogInfoWrapper {
pub message: String,
}
/// Thread-local storage for the current request's API key
thread_local! {
static CURRENT_API_KEY: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
}
/// Set the current API key for this request
pub fn set_current_api_key(key: Option<String>) {
CURRENT_API_KEY.with(|k| {
*k.borrow_mut() = key;
});
}
/// Get the current API key for this request
pub fn get_current_api_key() -> Option<String> {
CURRENT_API_KEY.with(|k| k.borrow().clone())
}
impl From<LogInfo> for LogInfoWrapper {
fn from(log: crate::runner::LogInfo) -> Self {
LogInfoWrapper {
@@ -284,12 +293,34 @@ pub struct SupervisorInfoResponse {
pub runners_count: usize,
}
/// Request parameters for auth verification
/// Empty - the key is extracted from Authorization header
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct AuthVerifyParams {}
/// Request parameters for creating API keys
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateApiKeyParams {
pub name: String,
pub scope: String, // "admin", "registrar", or "user"
}
/// Request parameters for removing API keys
#[derive(Debug, Deserialize, Serialize)]
pub struct RemoveApiKeyParams {
pub key: String,
}
/// Request parameters for listing API keys - empty, uses header auth
#[derive(Debug, Deserialize, Serialize, Default)]
pub struct ListApiKeysParams {}
/// OpenRPC trait defining all supervisor methods
#[rpc(server)]
pub trait SupervisorRpc {
/// Register a new runner with secret-based authentication
#[method(name = "register_runner")]
async fn register_runner(&self, params: RegisterRunnerParams) -> RpcResult<String>;
async fn register_runner(&self, name: String) -> RpcResult<String>;
/// Create a job without queuing it to a runner
#[method(name = "jobs.create")]
@@ -423,6 +454,22 @@ pub trait SupervisorRpc {
#[method(name = "get_supervisor_info")]
async fn get_supervisor_info(&self, admin_secret: String) -> RpcResult<SupervisorInfoResponse>;
/// Verify an API key and return its metadata
#[method(name = "auth.verify")]
async fn auth_verify(&self) -> RpcResult<crate::auth::AuthVerifyResponse>;
/// Create a new API key (admin only)
#[method(name = "auth.create_key")]
async fn auth_create_key(&self, name: String, scope: String) -> RpcResult<crate::auth::ApiKey>;
/// Remove an API key (admin only)
#[method(name = "auth.remove_key")]
async fn auth_remove_key(&self, key: String) -> RpcResult<bool>;
/// List all API keys (admin only)
#[method(name = "auth.list_keys")]
async fn auth_list_keys(&self) -> RpcResult<Vec<crate::auth::ApiKey>>;
/// OpenRPC discovery method - returns the OpenRPC document describing this API
#[method(name = "rpc.discover")]
async fn rpc_discover(&self) -> RpcResult<serde_json::Value>;
@@ -447,26 +494,35 @@ fn parse_process_manager_type(pm_type: &str, session_name: Option<String>) -> Re
/// This eliminates the need for a wrapper struct
#[async_trait]
impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
async fn register_runner(&self, params: RegisterRunnerParams) -> RpcResult<String> {
debug!("OpenRPC request: register_runner with params: {:?}", params);
async fn register_runner(&self, name: String) -> RpcResult<String> {
debug!("OpenRPC request: register_runner with name: {}", name);
// Get API key from Authorization header
let key = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let mut supervisor = self.lock().await;
// Queue name is the same as runner name
// register_runner now handles API key verification internally
supervisor
.register_runner(&params.secret, &params.name, &params.name)
.register_runner(&key, &name, &name)
.await
.map_err(runner_error_to_rpc_error)?;
// Return the runner name that was registered
Ok(params.name)
Ok(name)
}
async fn jobs_create(&self, params: RunJobParams) -> RpcResult<String> {
debug!("OpenRPC request: jobs.create with params: {:?}", params);
// Get secret from Authorization header
let secret = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let mut supervisor = self.lock().await;
let job_id = supervisor
.create_job(&params.secret, params.job)
.create_job(&secret, params.job)
.await
.map_err(runner_error_to_rpc_error)?;
@@ -485,9 +541,13 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
async fn job_run(&self, params: RunJobParams) -> RpcResult<JobResult> {
debug!("OpenRPC request: job.run with params: {:?}", params);
// Get secret from Authorization header
let secret = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let mut supervisor = self.lock().await;
match supervisor
.run_job(&params.secret, params.job)
.run_job(&secret, params.job)
.await
.map_err(runner_error_to_rpc_error)? {
Some(output) => Ok(JobResult::Success { success: output }),
@@ -498,9 +558,13 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
async fn job_start(&self, params: StartJobParams) -> RpcResult<()> {
debug!("OpenRPC request: job.start with params: {:?}", params);
// Get secret from Authorization header
let secret = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let mut supervisor = self.lock().await;
supervisor
.start_job(&params.secret, &params.job_id)
.start_job(&secret, &params.job_id)
.await
.map_err(runner_error_to_rpc_error)
}
@@ -549,9 +613,13 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
async fn job_delete(&self, params: DeleteJobParams) -> RpcResult<()> {
debug!("OpenRPC request: job.delete with params: {:?}", params);
// Get secret from Authorization header
let secret = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let mut supervisor = self.lock().await;
supervisor
.delete_job(&params.job_id)
.delete_job_with_auth(&secret, &params.job_id)
.await
.map_err(runner_error_to_rpc_error)
}
@@ -875,6 +943,92 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
})
}
async fn auth_verify(&self) -> RpcResult<crate::auth::AuthVerifyResponse> {
debug!("OpenRPC request: auth.verify");
let supervisor = self.lock().await;
// Get key from thread-local (set by middleware from Authorization header)
let key = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
match supervisor.verify_api_key(&key).await {
Some(api_key) => {
Ok(crate::auth::AuthVerifyResponse {
valid: true,
name: api_key.name,
scope: api_key.scope.as_str().to_string(),
})
}
None => {
Ok(crate::auth::AuthVerifyResponse {
valid: false,
name: String::new(),
scope: String::new(),
})
}
}
}
async fn auth_create_key(&self, name: String, scope: String) -> RpcResult<crate::auth::ApiKey> {
debug!("OpenRPC request: auth.create_key");
// Get API key from Authorization header
let key = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let supervisor = self.lock().await;
// Verify admin key
if !supervisor.is_admin_key(&key).await {
return Err(ErrorObject::owned(-32603, "Admin permissions required", None::<()>));
}
// Parse scope
let api_scope = match scope.to_lowercase().as_str() {
"admin" => crate::auth::ApiKeyScope::Admin,
"registrar" => crate::auth::ApiKeyScope::Registrar,
"user" => crate::auth::ApiKeyScope::User,
_ => return Err(ErrorObject::owned(-32602, "Invalid scope. Must be 'admin', 'registrar', or 'user'", None::<()>)),
};
let api_key = supervisor.create_api_key(name, api_scope).await;
Ok(api_key)
}
async fn auth_remove_key(&self, key_to_remove: String) -> RpcResult<bool> {
debug!("OpenRPC request: auth.remove_key");
// Get API key from Authorization header
let key = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let supervisor = self.lock().await;
// Verify admin key
if !supervisor.is_admin_key(&key).await {
return Err(ErrorObject::owned(-32603, "Admin permissions required", None::<()>));
}
Ok(supervisor.remove_api_key(&key_to_remove).await.is_some())
}
async fn auth_list_keys(&self) -> RpcResult<Vec<crate::auth::ApiKey>> {
debug!("OpenRPC request: auth.list_keys");
// Get API key from Authorization header
let key = get_current_api_key()
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
let supervisor = self.lock().await;
// Verify admin key
if !supervisor.is_admin_key(&key).await {
return Err(ErrorObject::owned(-32603, "Admin permissions required", None::<()>));
}
Ok(supervisor.list_api_keys().await)
}
async fn rpc_discover(&self) -> RpcResult<serde_json::Value> {
debug!("OpenRPC request: rpc.discover");
@@ -915,6 +1069,55 @@ pub async fn start_server_with_supervisor(
Ok(handle)
}
/// HTTP middleware layer to extract Authorization header
#[derive(Clone)]
struct AuthExtractLayer;
impl<S> tower::Layer<S> for AuthExtractLayer {
type Service = AuthExtractService<S>;
fn layer(&self, inner: S) -> Self::Service {
AuthExtractService { inner }
}
}
#[derive(Clone)]
struct AuthExtractService<S> {
inner: S,
}
impl<S, B> tower::Service<hyper::Request<B>> for AuthExtractService<S>
where
S: tower::Service<hyper::Request<B>> + Clone + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: hyper::Request<B>) -> Self::Future {
// Extract Authorization header
let api_key = req.headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(|s| s.to_string());
// Store in thread-local
set_current_api_key(api_key);
let mut inner = self.inner.clone();
Box::pin(async move {
inner.call(req).await
})
}
}
/// Start HTTP OpenRPC server (Unix socket support would require additional dependencies)
pub async fn start_http_openrpc_server(
supervisor: Arc<Mutex<Supervisor>>,
@@ -929,9 +1132,14 @@ pub async fn start_http_openrpc_server(
.allow_headers(Any)
.allow_methods(Any);
// Start HTTP server with CORS
// Build HTTP middleware stack with auth extraction
let http_middleware = tower::ServiceBuilder::new()
.layer(AuthExtractLayer)
.layer(cors);
// Start HTTP server with middleware
let http_server = Server::builder()
.set_http_middleware(tower::ServiceBuilder::new().layer(cors))
.set_http_middleware(http_middleware)
.build(http_addr)
.await?;
let http_handle = http_server.start(supervisor.into_rpc());
@@ -1015,9 +1223,11 @@ mod tests {
.unwrap();
let params = RunJobParams {
secret: "test-secret".to_string(),
job: job.clone(),
};
// Set the API key in thread-local for the test
set_current_api_key(Some("test-secret".to_string()));
let result = supervisor.jobs_create(params).await;
// Should work or fail gracefully without Redis
@@ -1025,7 +1235,6 @@ mod tests {
// Test job.start
let start_params = StartJobParams {
secret: "test-secret".to_string(),
job_id: "test-job".to_string(),
};
@@ -1035,7 +1244,6 @@ mod tests {
// Test invalid secret
let invalid_params = StartJobParams {
secret: "invalid".to_string(),
job_id: "test-job".to_string(),
};