Update admin UI with real API integration and secret management
This commit is contained in:
40
clients/admin-ui/Cargo.lock
generated
40
clients/admin-ui/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
78
clients/openrpc/examples/generate_test_keys.rs
Normal file
78
clients/openrpc/examples/generate_test_keys.rs
Normal 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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user