src/pages/replication.rs
//! One-shot repository replication with the server's own two-step shape:
//! `execute: false` is a PREVIEW and the default; the Execute button unlocks
//! only after a preview of the same pair has been seen. An Ok RESPONSE is not
//! success — `failed`/`truncated`/`complete` render as their own facts.
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::components::feedback::ErrorText;
use crate::grpc;
use crate::util::{execute_armed, fmt_bytes, replication_pair_error};
#[component]
pub fn Replicate() -> impl IntoView {
crate::webio::set_title("Replicate");
let from = RwSignal::new(String::new());
let to = RwSignal::new(String::new());
// The (from, to) pair the last preview covered — Execute is armed only
// while the current selection still matches it.
let previewed: RwSignal<Option<(String, String)>> = RwSignal::new(None);
let report: RwSignal<Option<grpc::pb::ReplicateResponse>> = RwSignal::new(None);
let executed = RwSignal::new(false);
let error: RwSignal<Option<String>> = RwSignal::new(None);
let busy = RwSignal::new(false);
let repos = LocalResource::new(|| async {
grpc::admin()
.list_repositories(grpc::pb::ListRepositoriesRequest {})
.await
.map(|r| {
r.into_inner()
.repositories
.into_iter()
.map(|r| r.name)
.collect::<Vec<_>>()
})
.map_err(|s| grpc::status_text(&s))
});
// An executed copy is holger's queued job (owner rule): its id and its words, followed.
let job_line: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);
let run = move |execute: bool| {
if busy.get_untracked() {
return;
}
let from_repo = from.get_untracked();
let to_repo = to.get_untracked();
if let Some(refusal) = replication_pair_error(&from_repo, &to_repo) {
error.set(Some(refusal.into()));
return;
}
busy.set(true);
error.set(None);
spawn_local(async move {
let res = grpc::replication()
.replicate(grpc::pb::ReplicateRequest {
from_repo: from_repo.clone(),
to_repo: to_repo.clone(),
execute,
limit: 0,
async_job: execute,
})
.await;
busy.set(false);
job_line.set(None);
match res {
// Queued: the counts are the job's, said when holger says it is over, never a
// "Copied 0" the reply carries before the copy ran.
Ok(r) if !r.get_ref().job_id.is_empty() => {
let job = r.into_inner().job_id;
report.set(None);
previewed.set(None);
busy.set(true);
let end = crate::job_follow::follow(&job, |w| job_line.set(Some(Ok(w)))).await;
busy.set(false);
job_line.set(Some(end));
}
Ok(r) => {
report.set(Some(r.into_inner()));
executed.set(execute);
if execute {
// An executed run consumes its preview.
previewed.set(None);
} else {
previewed.set(Some((from_repo, to_repo)));
}
}
Err(s) => error.set(Some(grpc::status_text(&s))),
}
});
};
let preview = run;
let execute = run;
let picker = move |label: &'static str, id: &'static str, sig: RwSignal<String>| {
let repos_view = move || {
match repos.get() {
None => view! { <span class="pending">"…"</span> }.into_any(),
Some(Err(e)) => view! { <span class="err">{e}</span> }.into_any(),
Some(Ok(names)) => view! {
<select id=id on:change=move |ev| { sig.set(event_target_value(&ev)); }>
<option value="" selected>{format!("— {label} —")}</option>
{names.into_iter().map(|n| { let val = n.clone(); view! { <option value=val>{n}</option> } }).collect_view()}
</select>
}.into_any(),
}
};
view! { <label class="axis" for=id><span>{label}</span>{repos_view}</label> }
};
view! {
<section class="page">
<crate::pages::repositories::RepoTabs/>
<h2>"Replicate"</h2>
<p class="count measurenote">
"One-shot copy of missing artifacts, content-id idempotent. Preview writes nothing; \
Execute unlocks only after a preview of the selected pair."
</p>
<div class="axes">
{picker("Source", "repl-source", from)}
{picker("Destination", "repl-dest", to)}
</div>
<div class="toolbar">
<button on:click=move |_| preview(false) disabled=move || busy.get()>
{move || if busy.get() { "Working…" } else { "Preview (dry-run)" }}
</button>
<button class="danger-big"
on:click=move |_| execute(true)
disabled=move || {
if busy.get() {
return true;
}
let seen = previewed.get();
!execute_armed(
seen.as_ref().map(|(f, t)| (f.as_str(), t.as_str())),
&from.get(),
&to.get(),
)
}>
"Execute copy"
</button>
</div>
{move || error.get().map(|e| view! { <ErrorText context="replication failed" message=e/> })}
{move || job_line.get().map(|w| match w {
Ok(m) => view! { <p class="count" role="status" data-job="">{m}</p> }.into_any(),
Err(m) => view! { <ErrorText context="replication failed" message=m/> }.into_any(),
})}
{move || report.get().map(|r| {
let ran = executed.get();
view! {
<h3>{format!(
"{} · {}",
if ran { "Executed" } else { "Preview — nothing written" },
crate::webio::now_hms()
)}</h3>
<div class="tablewrap">
<table class="report">
<tbody>
<tr><td>"Examined"</td><td class="num">{r.examined}</td></tr>
{if ran {
view! {
<tr><td>"Copied"</td><td class="num">{r.copied}</td></tr>
<tr><td>"Copied bytes"</td><td class="num">{fmt_bytes(r.copied_bytes)}</td></tr>
}.into_any()
} else {
view! {
<tr><td>"Would copy"</td><td class="num">{r.would_copy}</td></tr>
}.into_any()
}}
<tr><td>"Skipped (already present)"</td><td class="num">{r.skipped}</td></tr>
<tr><td>"Failed"</td><td class="num">{r.failed}</td></tr>
</tbody>
</table>
</div>
{(!r.complete).then(|| view! {
<p class="banner banner-bad" role="alert">
"incomplete — some artifacts failed; this run is NOT a success, nothing is assumed"
</p>
})}
{r.truncated.then(|| view! {
<p class="banner banner-warn" role="alert">"limit hit — more source artifacts remain"</p>
})}
}
})}
</section>
}
}