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:
262
src/openrpc.rs
262
src/openrpc.rs
@@ -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(¶ms.secret, ¶ms.name, ¶ms.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(¶ms.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(¶ms.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(¶ms.secret, ¶ms.job_id)
|
||||
.start_job(&secret, ¶ms.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(¶ms.job_id)
|
||||
.delete_job_with_auth(&secret, ¶ms.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(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user