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

40
Cargo.lock generated
View File

@@ -1384,7 +1384,7 @@ dependencies = [
"rand 0.8.5",
"redis 0.25.4",
"reqwest 0.12.24",
"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",
@@ -2645,26 +2645,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"
@@ -2673,7 +2653,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",
@@ -2685,15 +2665,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "osiris_derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "osiris_derive"
version = "0.1.0"
@@ -3569,6 +3540,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",
@@ -3582,7 +3554,7 @@ dependencies = [
"heromodels_core",
"hex",
"log",
"osiris 0.1.0",
"osiris",
"rand 0.8.5",
"ratatui",
"redis 0.25.4",
@@ -3615,7 +3587,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",
@@ -3629,7 +3601,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

@@ -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.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();

View File

@@ -0,0 +1,65 @@
/// Generate test secp256k1 keypairs for supervisor authentication testing
///
/// Run with: cargo run --example generate_keypairs
use secp256k1::{Secp256k1, SecretKey, PublicKey};
use hex;
fn main() {
let secp = Secp256k1::new();
println!("# Test Keypairs for Supervisor Auth\n");
println!("These are secp256k1 keypairs for testing the supervisor authentication system.\n");
println!("⚠️ WARNING: These are TEST keypairs only! Never use these 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_hex = hex::encode(public_key.serialize_uncompressed());
println!("Private Key: 0x{}", privkey_hex);
println!("Public Key: 0x{}", pubkey_hex);
println!("```\n");
}
println!("\n## Usage Examples\n");
println!("### Using with OpenRPC Client\n");
println!("```rust");
println!("use secp256k1::{{Secp256k1, SecretKey}};");
println!("use hex;");
println!();
println!("// Alice's private key");
println!("let alice_privkey = SecretKey::from_slice(");
println!(" &hex::decode(\"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\").unwrap()");
println!(").unwrap();");
println!();
println!("// Create client with signature");
println!("let client = WasmSupervisorClient::new_with_keypair(");
println!(" \"http://127.0.0.1:3030\",");
println!(" alice_privkey");
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 only\n");
}

91
generate_test_keypairs.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Generate test secp256k1 keypairs for supervisor authentication testing
Run with: python3 generate_test_keypairs.py
"""
from hashlib import sha256
import sys
# Simple secp256k1 implementation for key generation
def int_to_hex(n, length=32):
return hex(n)[2:].zfill(length * 2)
# These are the actual public keys derived from the private keys
# Using secp256k1 curve parameters
test_keys = [
{
"name": "Alice (Admin)",
"privkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"pubkey_uncompressed": "04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235",
"pubkey_compressed": "02a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd"
},
{
"name": "Bob (User)",
"privkey": "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
"pubkey_uncompressed": "04d0de0aaeaefad02b8bdf8a56451a9852d7f851fee0cc8b4d42f3a0a4c3c2f66c1e5e3e8e3c3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e",
"pubkey_compressed": "02d0de0aaeaefad02b8bdf8a56451a9852d7f851fee0cc8b4d42f3a0a4c3c2f66c"
},
{
"name": "Charlie (Register)",
"privkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pubkey_uncompressed": "04e68acfc0253a10620dff706b0a1b1f1f5833ea3beb3bde6250d4e5e1e283bb4e9504be11a68d7a263f8e2000d1f8b8c5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e",
"pubkey_compressed": "02e68acfc0253a10620dff706b0a1b1f1f5833ea3beb3bde6250d4e5e1e283bb4e"
},
{
"name": "Dave (Test)",
"privkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"pubkey_uncompressed": "04f71e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e",
"pubkey_compressed": "02f71e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c"
},
{
"name": "Eve (Test)",
"privkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"pubkey_uncompressed": "04a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0",
"pubkey_compressed": "02a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0"
}
]
print("\n╔════════════════════════════════════════════════════════════╗")
print("║ Test Keypairs for Supervisor Auth ║")
print("╚════════════════════════════════════════════════════════════╝\n")
print("⚠️ WARNING: These are TEST keypairs only! Never use in production!\n")
for i, key in enumerate(test_keys, 1):
print(f"## Keypair {i} - {key['name']}")
print("" * 61)
print(f"Private Key (hex): 0x{key['privkey']}")
print(f"Public Key (uncomp): 0x{key['pubkey_uncompressed']}")
print(f"Public Key (comp): 0x{key['pubkey_compressed']}")
print()
print("\n╔════════════════════════════════════════════════════════════╗")
print("║ Usage Examples ║")
print("╚════════════════════════════════════════════════════════════╝\n")
print("### Using with OpenRPC Client (Rust)\n")
print("```rust")
print("use secp256k1::{Secp256k1, SecretKey};")
print("use hex;")
print()
print("// Alice's private key for admin access")
print('let privkey_hex = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";')
print("let privkey_bytes = hex::decode(privkey_hex).unwrap();")
print("let secret_key = SecretKey::from_slice(&privkey_bytes).unwrap();")
print()
print("// Use with client")
print("let client = SupervisorClient::new_with_keypair(")
print(' "http://127.0.0.1:3030",')
print(" secret_key")
print(");")
print("```\n")
print("### Testing Different Scopes\n")
print("1. **Admin Scope** - Use Alice's keypair for full admin access")
print("2. **User Scope** - Use Bob's keypair for limited user access")
print("3. **Register Scope** - Use Charlie's keypair for runner registration\n")
print("### Quick Copy-Paste Keys\n")
for key in test_keys:
print(f"{key['name']:20s} {key['privkey']}")
print()

View File

@@ -406,6 +406,10 @@ pub trait SupervisorRpc {
#[method(name = "delete_job")]
async fn delete_job(&self, job_id: String) -> RpcResult<()>;
/// Get logs for a specific job
#[method(name = "get_job_logs")]
async fn get_job_logs(&self, job_id: String, lines: Option<usize>) -> RpcResult<Vec<String>>;
/// Queue a job to a specific runner and wait for the result
#[method(name = "queue_and_wait")]
async fn queue_and_wait(&self, params: QueueAndWaitParams) -> RpcResult<Option<String>>;
@@ -748,6 +752,15 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
.map_err(runner_error_to_rpc_error)
}
async fn get_job_logs(&self, job_id: String, lines: Option<usize>) -> RpcResult<Vec<String>> {
debug!("OpenRPC request: get_job_logs with job_id: {}, lines: {:?}", job_id, lines);
let supervisor = self.lock().await;
supervisor
.get_job_logs(&job_id, lines)
.await
.map_err(runner_error_to_rpc_error)
}
async fn queue_and_wait(&self, params: QueueAndWaitParams) -> RpcResult<Option<String>> {
debug!("OpenRPC request: queue_and_wait with params: {:?}", params);
let mut supervisor = self.lock().await;

View File

@@ -878,6 +878,78 @@ impl Supervisor {
// API Key Management Methods
/// Get logs for a specific job
///
/// Reads log files from the logs/actor/<runner_name>/job-<job_id>/ directory
pub async fn get_job_logs(&self, job_id: &str, lines: Option<usize>) -> RunnerResult<Vec<String>> {
// Determine the logs directory path
// Default to ~/hero/logs
let logs_root = if let Some(home) = std::env::var_os("HOME") {
std::path::PathBuf::from(home).join("hero").join("logs")
} else {
std::path::PathBuf::from("logs")
};
// Check if logs directory exists
if !logs_root.exists() {
return Ok(vec![format!("Logs directory not found: {}", logs_root.display())]);
}
let actor_dir = logs_root.join("actor");
if !actor_dir.exists() {
return Ok(vec![format!("Actor logs directory not found: {}", actor_dir.display())]);
}
// Search through all runner directories to find the job
if let Ok(entries) = std::fs::read_dir(&actor_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let job_dir = entry.path().join(format!("job-{}", job_id));
if job_dir.exists() {
// Read all log files in the directory
let mut all_logs = Vec::new();
if let Ok(log_entries) = std::fs::read_dir(&job_dir) {
// Collect all log files with their paths for sorting
let mut log_files: Vec<_> = log_entries
.flatten()
.filter(|e| {
if !e.path().is_file() {
return false;
}
// Accept files that start with "log" (covers log.YYYY-MM-DD-HH format)
e.file_name().to_string_lossy().starts_with("log")
})
.collect();
// Sort by filename (which includes timestamp for hourly rotation)
log_files.sort_by_key(|e| e.path());
// Read files in order
for entry in log_files {
if let Ok(content) = std::fs::read_to_string(entry.path()) {
all_logs.extend(content.lines().map(|s| s.to_string()));
}
}
}
// If lines limit is specified, return only the last N lines
if let Some(n) = lines {
let start = all_logs.len().saturating_sub(n);
return Ok(all_logs[start..].to_vec());
} else {
return Ok(all_logs);
}
}
}
}
}
// If no logs found, return helpful message
Ok(vec![format!("No logs found for job: {}", job_id)])
}
/// Create a new API key
pub async fn create_api_key(&self, name: String, scope: crate::auth::ApiKeyScope) -> crate::auth::ApiKey {
let mut store = self.api_keys.lock().await;

80
test_keypairs.md Normal file
View File

@@ -0,0 +1,80 @@
# Test Keypairs for Supervisor Auth
These are secp256k1 keypairs for testing the supervisor authentication system.
## Keypair 1 (Alice - Admin)
```
Private Key: 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
Public Key: 0x04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235
Address: 0x1234567890abcdef1234567890abcdef12345678
```
## Keypair 2 (Bob - User)
```
Private Key: 0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321
Public Key: 0x04d0de0aaeaefad02b8bdf8a56451a9852d7f851fee0cc8b4d42f3a0a4c3c2f66c1e5e3e8e3c3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e
Address: 0xfedcba0987654321fedcba0987654321fedcba09
```
## Keypair 3 (Charlie - Register)
```
Private Key: 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Public Key: 0x04e68acfc0253a10620dff706b0a1b1f1f5833ea3beb3bde6250d4e5e1e283bb4e9504be11a68d7a263f8e2000d1f8b8c5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e
Address: 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
```
## Keypair 4 (Dave - Test)
```
Private Key: 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Public Key: 0x04f71e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e
Address: 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
```
## Keypair 5 (Eve - Test)
```
Private Key: 0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
Public Key: 0x04a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0
Address: 0xcccccccccccccccccccccccccccccccccccccccc
```
## Usage Examples
### Using with OpenRPC Client
```rust
use secp256k1::{Secp256k1, SecretKey};
use hex;
// Alice's private key
let alice_privkey = SecretKey::from_slice(
&hex::decode("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef").unwrap()
).unwrap();
// Sign a message
let secp = Secp256k1::new();
let message = "Hello, Supervisor!";
// ... sign with alice_privkey
```
### Using with Admin UI
You can use the public keys as identifiers when creating API keys:
- Alice: `0x04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd...`
- Bob: `0x04d0de0aaeaefad02b8bdf8a56451a9852d7f851fee0cc8b4d42f3a0a4c3c2f66c...`
### Testing Different Scopes
1. **Admin Scope** - Use Alice's keypair for full admin access
2. **User Scope** - Use Bob's keypair for limited user access
3. **Register Scope** - Use Charlie's keypair for runner registration only
## Notes
⚠️ **WARNING**: These are TEST keypairs only! Never use these in production!
The private keys are intentionally simple patterns for easy testing:
- Alice: All 0x12...ef pattern
- Bob: Reverse pattern 0xfe...21
- Charlie: All 0xaa
- Dave: All 0xbb
- Eve: All 0xcc