Update admin UI with real API integration and secret management

This commit is contained in:
Timur Gordon
2025-10-31 02:29:29 +01:00
parent e493085892
commit 49d36485d0
12 changed files with 827 additions and 115 deletions

View File

@@ -1838,7 +1838,7 @@ dependencies = [
"jsonrpsee",
"log",
"redis 0.25.4",
"runner_rust 0.1.0",
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git)",
"serde",
"serde_json",
"thiserror 1.0.69",
@@ -3155,26 +3155,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "osiris"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"env_logger 0.10.2",
"osiris_derive 0.1.0",
"redis 0.24.0",
"rhai",
"serde",
"serde_json",
"time",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "osiris"
version = "0.1.0"
@@ -3183,7 +3163,7 @@ dependencies = [
"anyhow",
"clap",
"env_logger 0.10.2",
"osiris_derive 0.1.0 (git+https://git.ourworld.tf/herocode/osiris.git)",
"osiris_derive",
"redis 0.24.0",
"serde",
"serde_json",
@@ -3195,15 +3175,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "osiris_derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "osiris_derive"
version = "0.1.0"
@@ -4133,6 +4104,7 @@ checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
[[package]]
name = "runner_rust"
version = "0.1.0"
source = "git+https://git.ourworld.tf/herocode/runner_rust.git?branch=main#268128f7fd53e9586288efd95f9288595c4a74e9"
dependencies = [
"anyhow",
"async-trait",
@@ -4146,7 +4118,7 @@ dependencies = [
"heromodels_core",
"hex",
"log",
"osiris 0.1.0",
"osiris",
"rand 0.8.5",
"ratatui",
"redis 0.25.4",
@@ -4179,7 +4151,7 @@ dependencies = [
[[package]]
name = "runner_rust"
version = "0.1.0"
source = "git+https://git.ourworld.tf/herocode/runner_rust.git?branch=main#268128f7fd53e9586288efd95f9288595c4a74e9"
source = "git+https://git.ourworld.tf/herocode/runner_rust.git#df8fc696785fbf26ed832443bd9404ee6192d116"
dependencies = [
"anyhow",
"async-trait",
@@ -4193,7 +4165,7 @@ dependencies = [
"heromodels_core",
"hex",
"log",
"osiris 0.1.0 (git+https://git.ourworld.tf/herocode/osiris.git)",
"osiris",
"rand 0.8.5",
"ratatui",
"redis 0.25.4",

View File

@@ -18,6 +18,8 @@ web-sys = { version = "0.3", features = [
"HtmlSelectElement",
"HtmlTextAreaElement",
"Window",
"Navigator",
"Clipboard",
] }
js-sys = "0.3"
serde = { version = "1.0", features = ["derive"] }

View File

@@ -12,6 +12,12 @@ enum AppState {
Dashboard,
}
#[derive(Clone, PartialEq)]
enum ContentView {
JobsList,
JobDetail(String), // job_id
}
#[derive(Clone, PartialEq)]
enum SidebarView {
Runners,
@@ -64,6 +70,10 @@ pub struct App {
job_sort_column: String,
job_sort_ascending: bool,
job_filter: String,
// Content view
content_view: ContentView,
// Job detail view
job_detail_logs: Option<String>,
}
pub enum Msg {
@@ -87,6 +97,7 @@ pub enum Msg {
DeleteJob(String),
JobDeleted(Result<String, String>),
ViewJobLogs(String),
JobLogsLoaded(Result<Vec<String>, String>),
PollJobStatus,
JobStatusUpdated(String, String), // (job_id, status)
JobStatusError(String, String), // (job_id, error)
@@ -100,6 +111,9 @@ pub enum Msg {
UpdateNewKeyScope(String),
AddKey,
KeyAdded(Result<(), String>),
DeleteKey(String),
KeyDeleted(Result<String, String>),
CopyKeyToClipboard(String),
ShowJobForm,
HideJobForm,
UpdateJobId(String),
@@ -114,6 +128,8 @@ pub enum Msg {
ToggleSidebarView(SidebarView),
SelectRunner(Option<String>),
SelectKey(Option<String>),
ViewJobDetail(String), // job_id
BackToJobsList,
}
impl Component for App {
@@ -158,6 +174,8 @@ impl Component for App {
job_sort_column: "created_at".to_string(),
job_sort_ascending: false,
job_filter: String::new(),
content_view: ContentView::JobsList,
job_detail_logs: None,
}
}
@@ -539,6 +557,62 @@ impl Component for App {
}
true
}
Msg::DeleteKey(key) => {
if let Some(client) = &self.client {
let client = client.clone();
let link = ctx.link().clone();
let key_clone = key.clone();
spawn_local(async move {
match client.auth_remove_key(key_clone.clone()).await {
Ok(_) => {
link.send_message(Msg::KeyDeleted(Ok(key_clone)));
}
Err(e) => {
link.send_message(Msg::KeyDeleted(Err(format!("{:?}", e))));
}
}
});
}
true
}
Msg::KeyDeleted(result) => {
match result {
Ok(key) => {
self.error = Some(format!("✅ Key deleted successfully"));
self.start_toast_timeout(ctx);
// Reload keys list
ctx.link().send_message(Msg::LoadKeys);
}
Err(e) => {
self.error = Some(format!("Failed to delete key: {}", e));
self.start_toast_timeout(ctx);
}
}
true
}
Msg::CopyKeyToClipboard(key) => {
// Use the Clipboard API to copy the key
let window = web_sys::window().expect("no global window exists");
let navigator = window.navigator();
let clipboard = navigator.clipboard();
let promise = clipboard.write_text(&key);
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(_) => {
link.send_message(Msg::SetError("✅ Key copied to clipboard".to_string()));
}
Err(_) => {
link.send_message(Msg::SetError("Failed to copy key".to_string()));
}
}
});
self.start_toast_timeout(ctx);
false
}
Msg::ShowJobForm => {
self.show_job_form = true;
// Generate a new job ID
@@ -848,9 +922,52 @@ impl Component for App {
}
Msg::ViewJobLogs(job_id) => {
log::info!("View logs for job: {}", job_id);
// TODO: Implement view logs
self.error = Some(format!("View logs for {} - Not implemented yet", job_id));
self.start_toast_timeout(ctx);
self.viewing_job_output = Some(job_id.clone());
self.job_output = None; // Clear previous output
if let Some(client) = &self.client {
let client = client.clone();
let link = ctx.link().clone();
spawn_local(async move {
match client.get_job_logs(&job_id, Some(1000)).await {
Ok(logs_js) => {
// Convert JsValue to Vec<String>
match serde_wasm_bindgen::from_value::<Vec<String>>(logs_js) {
Ok(logs) => {
link.send_message(Msg::JobLogsLoaded(Ok(logs)));
}
Err(e) => {
link.send_message(Msg::JobLogsLoaded(Err(format!("Failed to parse logs: {}", e))));
}
}
}
Err(e) => {
link.send_message(Msg::JobLogsLoaded(Err(format!("{:?}", e))));
}
}
});
}
true
}
Msg::JobLogsLoaded(result) => {
match result {
Ok(logs) => {
let logs_text = logs.join("\n");
// If viewing job output modal, use job_output
if self.viewing_job_output.is_some() {
self.job_output = Some(logs_text);
} else {
// Otherwise, it's for the job detail view
self.job_detail_logs = Some(logs_text);
}
}
Err(e) => {
self.error = Some(format!("Failed to load job logs: {}", e));
self.start_toast_timeout(ctx);
self.viewing_job_output = None;
}
}
true
}
Msg::PollJobStatus => {
@@ -987,6 +1104,41 @@ impl Component for App {
self.job_output = None;
true
}
Msg::ViewJobDetail(job_id) => {
self.content_view = ContentView::JobDetail(job_id.clone());
self.job_detail_logs = None;
// Load job logs
if let Some(client) = &self.client {
let client = client.clone();
let link = ctx.link().clone();
let job_id_clone = job_id.clone();
spawn_local(async move {
match client.get_job_logs(&job_id_clone, Some(1000)).await {
Ok(logs_js) => {
match serde_wasm_bindgen::from_value::<Vec<String>>(logs_js) {
Ok(logs) => {
link.send_message(Msg::JobLogsLoaded(Ok(logs)));
}
Err(e) => {
link.send_message(Msg::JobLogsLoaded(Err(format!("Failed to parse logs: {}", e))));
}
}
}
Err(e) => {
link.send_message(Msg::JobLogsLoaded(Err(format!("{:?}", e))));
}
}
});
}
true
}
Msg::BackToJobsList => {
self.content_view = ContentView::JobsList;
self.job_detail_logs = None;
true
}
}
}
@@ -1074,6 +1226,20 @@ impl App {
matches!(status, "finished" | "error" | "stopped")
}
fn format_timestamp(timestamp: &str) -> String {
if timestamp.is_empty() {
return String::new();
}
// Parse RFC3339 timestamp and format as "Day Month Year"
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
dt.format("%d %b %Y").to_string()
} else {
// Fallback to original timestamp if parsing fails
timestamp.to_string()
}
}
fn start_status_polling(&mut self, ctx: &Context<Self>) {
let link = ctx.link().clone();
self._status_poll_timeout = Some(Timeout::new(2000, move || {
@@ -1190,44 +1356,12 @@ impl App {
}
</div>
// Main Content Island - Jobs
// Main Content Island
<div class="content-island">
<div class="content-header">
<h2>{ "Jobs" }</h2>
<input
type="text"
placeholder="Filter jobs..."
class="filter-input"
value={self.job_filter.clone()}
oninput={ctx.link().callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Msg::FilterJobs(input.value())
})}
/>
<button
class="btn-primary"
style="margin-left: auto;"
onclick={ctx.link().callback(|_| Msg::ShowJobForm)}
>
{ "+ Create Job" }
</button>
</div>
{
if self.loading {
html! {
<div class="empty-state">
{ "Loading jobs..." }
</div>
}
} else if self.jobs.is_empty() {
html! {
<div class="empty-state">
{ "No jobs yet" }
</div>
}
} else {
self.view_jobs_table(ctx)
match &self.content_view {
ContentView::JobsList => self.view_jobs_list_content(ctx),
ContentView::JobDetail(job_id) => self.view_job_detail_content(ctx, job_id.clone()),
}
}
</div>
@@ -1499,29 +1633,49 @@ impl App {
self.keys.iter().map(|key| {
let name = key.get("name").and_then(|v| v.as_str()).unwrap_or("unknown");
let scope = key.get("scope").and_then(|v| v.as_str()).unwrap_or("unknown");
let secret = key.get("secret").and_then(|v| v.as_str()).unwrap_or("");
let secret = key.get("key").and_then(|v| v.as_str()).unwrap_or("");
let created_at = key.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
// Format timestamp nicely
let formatted_time = Self::format_timestamp(created_at);
let is_selected = self.selected_key.as_ref().map(|s| s == name).unwrap_or(false);
let key_name = name.to_string();
let secret_for_copy = secret.to_string();
let secret_for_delete = secret.to_string();
html! {
<div
class={if is_selected { "key-card selected" } else { "key-card" }}
onclick={ctx.link().callback(move |_| Msg::SelectKey(Some(key_name.clone())))}
>
<div class="key-header">
<span class="key-name">{ name }</span>
<span class={format!("key-scope scope-{}", scope)}>{ scope }</span>
<button
class="key-delete-btn"
title="Delete key"
onclick={ctx.link().callback(move |e: MouseEvent| {
e.stop_propagation();
Msg::DeleteKey(secret_for_delete.clone())
})}
>
{ "×" }
</button>
</div>
<div class="key-secret">
<div
class="key-secret clickable"
title="Click to copy"
onclick={ctx.link().callback(move |e: MouseEvent| {
e.stop_propagation();
Msg::CopyKeyToClipboard(secret_for_copy.clone())
})}
>
<code>{ secret }</code>
</div>
{
if !created_at.is_empty() {
html! {
<div class="key-meta">
<span>{ format!("Created: {}", created_at) }</span>
<span>{ formatted_time }</span>
</div>
}
} else {
@@ -1661,9 +1815,14 @@ impl App {
let job_id_logs = job_id.to_string();
let job_id_delete = job_id.to_string();
let job_id_output = job_id.to_string();
let job_id_detail = job_id.to_string();
html! {
<tr class={if is_polling { "polling-row" } else { "" }}>
<tr
class={if is_polling { "polling-row" } else { "" }}
onclick={ctx.link().callback(move |_| Msg::ViewJobDetail(job_id_detail.clone()))}
style="cursor: pointer;"
>
<td>
{
if is_complete {
@@ -1796,4 +1955,161 @@ impl App {
</div>
}
}
fn view_jobs_list_content(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<div class="content-header">
<h2>{ "Jobs" }</h2>
<input
type="text"
placeholder="Filter jobs..."
class="filter-input"
value={self.job_filter.clone()}
oninput={ctx.link().callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Msg::FilterJobs(input.value())
})}
/>
<button
class="btn-primary"
style="margin-left: auto;"
onclick={ctx.link().callback(|_| Msg::ShowJobForm)}
>
{ "+ Create Job" }
</button>
</div>
{
if self.loading {
html! {
<div class="empty-state">
{ "Loading jobs..." }
</div>
}
} else if self.jobs.is_empty() {
html! {
<div class="empty-state">
{ "No jobs yet" }
</div>
}
} else {
self.view_jobs_table(ctx)
}
}
</>
}
}
fn view_job_detail_content(&self, ctx: &Context<Self>, job_id: String) -> Html {
let job = self.jobs.iter().find(|j| {
j.get("id").and_then(|v| v.as_str()).unwrap_or("") == job_id
});
let job_id_run = job_id.clone();
let job_id_delete = job_id.clone();
html! {
<>
<div class="content-header">
<button
class="btn-secondary"
onclick={ctx.link().callback(|_| Msg::BackToJobsList)}
>
{ "← Back to Jobs" }
</button>
<h2>{ format!("Job {}", &job_id[..12.min(job_id.len())]) }</h2>
</div>
<div style="padding: 1.5rem;">
{
if let Some(job) = job {
let status = job.get("status").and_then(|v| v.as_str()).unwrap_or("unknown");
let runner = job.get("runner").and_then(|v| v.as_str()).unwrap_or("unknown");
let payload = job.get("payload").and_then(|v| v.as_str()).unwrap_or("");
let timeout = job.get("timeout").and_then(|v| v.as_u64()).unwrap_or(0);
let created_at = job.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
let status_class = match status {
"dispatched" | "queued" => "status-queued",
"started" => "status-running",
"finished" => "status-completed",
"error" => "status-failed",
"stopped" | "stopping" => "status-failed",
_ => "status-unknown",
};
html! {
<>
<div style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem;">{ "Job Details" }</h3>
<div style="display: grid; grid-template-columns: 150px 1fr; gap: 0.75rem; background: var(--bg-primary); padding: 1.5rem; border-radius: 8px;">
<div style="color: var(--text-secondary); font-weight: 600;">{ "Status:" }</div>
<div>
<span class={format!("status-badge {}", status_class)}>
{ status }
</span>
</div>
<div style="color: var(--text-secondary); font-weight: 600;">{ "Job ID:" }</div>
<div style="font-family: monospace; color: var(--accent);">{ &job_id }</div>
<div style="color: var(--text-secondary); font-weight: 600;">{ "Runner:" }</div>
<div>{ runner }</div>
<div style="color: var(--text-secondary); font-weight: 600;">{ "Timeout:" }</div>
<div>{ format!("{} seconds", timeout) }</div>
<div style="color: var(--text-secondary); font-weight: 600;">{ "Created:" }</div>
<div>{ created_at }</div>
<div style="color: var(--text-secondary); font-weight: 600;">{ "Payload:" }</div>
<div style="font-family: monospace; word-break: break-all;">{ payload }</div>
</div>
</div>
<div>
<h3 style="margin-bottom: 1rem;">{ "Logs" }</h3>
{
if let Some(logs) = &self.job_detail_logs {
html! {
<pre class="job-output">{ logs }</pre>
}
} else {
html! {
<div class="loading-spinner">{ "Loading logs..." }</div>
}
}
}
</div>
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
<button
class="btn-primary"
onclick={ctx.link().callback(move |_| Msg::RunJob(job_id_run.clone()))}
>
{ "▶ Run Job" }
</button>
<button
class="btn-secondary"
onclick={ctx.link().callback(move |_| Msg::DeleteJob(job_id_delete.clone()))}
style="border-color: var(--error); color: var(--error);"
>
{ "🗑 Delete Job" }
</button>
</div>
</>
}
} else {
html! {
<div class="empty-state">
{ "Job not found" }
</div>
}
}
}
</div>
</>
}
}
}

View File

@@ -1225,6 +1225,17 @@ button:disabled {
padding: 0.5rem;
margin-bottom: 0.5rem;
overflow-x: auto;
transition: all 0.2s ease;
}
.key-secret.clickable {
cursor: pointer;
}
.key-secret.clickable:hover {
background: rgba(16, 185, 129, 0.1);
border-color: var(--accent);
transform: translateY(-1px);
}
.key-secret code {
@@ -1238,3 +1249,24 @@ button:disabled {
font-size: 0.75rem;
color: var(--text-muted);
}
.key-delete-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
margin-left: auto;
transition: color 0.2s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.key-delete-btn:hover {
color: #ef4444;
}

View File

@@ -0,0 +1,78 @@
/// Generate test secp256k1 keypairs for supervisor authentication testing
///
/// Run with: cargo run --example generate_test_keys
use secp256k1::{Secp256k1, SecretKey, PublicKey};
use hex;
fn main() {
let secp = Secp256k1::new();
println!("\n╔════════════════════════════════════════════════════════════╗");
println!("║ Test Keypairs for Supervisor Auth ║");
println!("╚════════════════════════════════════════════════════════════╝\n");
println!("⚠️ WARNING: These are TEST keypairs only! Never use in production!\n");
// Generate 5 keypairs with simple private keys for testing
let test_keys = vec![
("Alice (Admin)", "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"),
("Bob (User)", "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"),
("Charlie (Register)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
("Dave (Test)", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
("Eve (Test)", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
];
for (i, (name, privkey_hex)) in test_keys.iter().enumerate() {
println!("## Keypair {} - {}", i + 1, name);
println!("─────────────────────────────────────────────────────────────");
// Parse private key
let privkey_bytes = hex::decode(privkey_hex).expect("Invalid hex");
let secret_key = SecretKey::from_slice(&privkey_bytes).expect("Invalid private key");
// Derive public key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Serialize keys
let pubkey_uncompressed = hex::encode(public_key.serialize_uncompressed());
let pubkey_compressed = hex::encode(public_key.serialize());
println!("Private Key (hex): 0x{}", privkey_hex);
println!("Public Key (uncomp): 0x{}", pubkey_uncompressed);
println!("Public Key (comp): 0x{}", pubkey_compressed);
println!();
}
println!("\n╔════════════════════════════════════════════════════════════╗");
println!("║ Usage Examples ║");
println!("╚════════════════════════════════════════════════════════════╝\n");
println!("### Using with OpenRPC Client (Rust)\n");
println!("```rust");
println!("use secp256k1::{{Secp256k1, SecretKey}};");
println!("use hex;");
println!();
println!("// Alice's private key for admin access");
println!("let privkey_hex = \"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\";");
println!("let privkey_bytes = hex::decode(privkey_hex).unwrap();");
println!("let secret_key = SecretKey::from_slice(&privkey_bytes).unwrap();");
println!();
println!("// Use with client");
println!("let client = SupervisorClient::new_with_keypair(");
println!(" \"http://127.0.0.1:3030\",");
println!(" secret_key");
println!(");");
println!("```\n");
println!("### Testing Different Scopes\n");
println!("1. **Admin Scope** - Use Alice's keypair for full admin access");
println!("2. **User Scope** - Use Bob's keypair for limited user access");
println!("3. **Register Scope** - Use Charlie's keypair for runner registration\n");
println!("### Quick Copy-Paste Keys\n");
println!("Alice (Admin): 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
println!("Bob (User): fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321");
println!("Charlie (Reg): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
println!("Dave (Test): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
println!("Eve (Test): cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\n");
}

View File

@@ -569,6 +569,25 @@ impl WasmSupervisorClient {
}
}
/// Get logs for a specific job
#[wasm_bindgen]
pub async fn get_job_logs(&self, job_id: &str, lines: Option<usize>) -> Result<JsValue, JsValue> {
let params = if let Some(n) = lines {
serde_json::json!([job_id, n])
} else {
serde_json::json!([job_id, serde_json::Value::Null])
};
match self.call_method("get_job_logs", params).await {
Ok(result) => {
// Convert Vec<String> to JsValue
Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert logs: {}", e)))?)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to get job logs: {:?}", e)))
}
}
/// Remove a runner from the supervisor
pub async fn remove_runner(&self, actor_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([actor_id]);
@@ -980,10 +999,10 @@ pub fn sign_job_canonical(
let secret_key = SecretKey::from_slice(&secret_bytes)
.map_err(|e| JsValue::from_str(&format!("Invalid private key: {}", e)))?;
// Get the public key
// Get the public key (uncompressed format)
let secp = Secp256k1::new();
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
let public_key_hex = hex::encode(public_key.serialize());
let public_key_hex = hex::encode(public_key.serialize_uncompressed());
// Hash the canonical representation
let mut hasher = Sha256::new();