Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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>
    }
}