src/pages/migrate.rs
//! **Migrate** — pull a whole Nexus or Artifactory into this holger.
//!
//! The screen an operator does a one-time registry migration from, and the one
//! place in the console that carries someone else's credential.
//!
//! # The flow this page is shaped around
//!
//! Discover first, always: it reads no artifact content, writes nothing, and
//! answers the question the operator actually has — *what is over there, and
//! what would taking it mean here?*
//!
//! Then the part no button can do:
//!
//! ```text
//! Discover → paste the declarations into the config → restart → Start
//! ```
//!
//! **holger cannot create a repository while it is running.** `FastRoutes` is
//! built from the wired config at boot and is immutable for the life of the
//! process; there is no create-repository verb on any surface. So a repository
//! the source has and this holger does not is not a thing Start can do — it is a
//! thing a person must do first, by declaring it and restarting. This page makes
//! that the visible middle of the flow: a `create` row cannot be selected, it
//! shows the RON to paste instead, and the Start button says in words how many
//! repositories are waiting on a declaration. A Migrate button that started and
//! then refused per repository would be worse than no page at all.
//!
//! The preview text under the table is the server's `report` field — the SAME
//! renderer the CLI's `--dry-run` prints (`traits::migrate`) — so the terminal
//! and the console can never describe one plan in two ways. The declarations
//! come down the wire for the same reason.
//!
//! # The credential
//!
//! The console presents the OPERATOR's own bearer token (`grpc.rs`, sessionStorage,
//! dies with the tab); `MigrationService` is ADMIN-gated server-wide and fails
//! closed, so this page asks `WhoAmI` first and says plainly when the session
//! cannot call it, rather than offering buttons that will be refused.
//!
//! The SOURCE registry's password is a different secret and it is treated as a
//! parameter of one call: it is never written to storage, never put in a URL,
//! never logged, never rendered, and it is cleared out of the form — signal and
//! DOM input both — the moment the request has been built. Discover and Start are
//! therefore two separate typings of it, deliberately. Everything the page says
//! about the source is rendered by `util`, which never receives the password in
//! any string-producing path; `util::tests::a_source_password_never_reaches_\
//! anything_the_page_renders` is the assertion.
use std::collections::BTreeMap;
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::components::copy_button::CopyButton;
use crate::components::feedback::{Badge, FailBox};
use crate::grpc;
use crate::util::{
declare_first_note, disposition_note, disposition_tone, fmt_bytes, forgotten,
migration_headline, migration_outcome, migration_percent, migration_progress_line, migration_repo_outcome,
migration_role_refusal, migration_row_selectable, source_form_error, source_summary,
start_plan, start_plan_note, SourceForm, StartPlan, MIGRATION_KIND_ARTIFACTORY,
MIGRATION_KIND_NEXUS,
};
type Progress = BTreeMap<String, grpc::pb::MigrationProgressEvent>;
/// The form, on the wire. `only` overrides the form's own repository field when
/// the page is running one job per selected repository.
fn wire_source(form: &SourceForm, only: &str) -> grpc::pb::MigrationSource {
grpc::pb::MigrationSource {
kind: form.kind,
base_url: form.base_url.trim().to_string(),
username: form.username.trim().to_string(),
password: form.password.clone(),
repository: if only.is_empty() {
form.repository.trim().to_string()
} else {
only.to_string()
},
}
}
/// Follow one job to its end. A watcher that joins late gets the latest row per
/// repository first and then follows live, so this is also the reconnect path.
async fn follow(
job_id: String,
rows: RwSignal<Progress>,
log: RwSignal<Vec<String>>,
) -> Result<(), tonic::Status> {
let mut stream = grpc::migration()
.watch(grpc::pb::WatchMigrationRequest { job_id })
.await?
.into_inner();
while let Some(event) = stream.message().await? {
let finished = event.job_finished;
let key = if event.repo.is_empty() {
"(job)".to_string()
} else {
event.repo.clone()
};
if !event.message.is_empty() {
let line = format!("{} · {}", crate::webio::now_hms(), event.message);
log.update(|l| l.push(line));
}
rows.update(|m| {
m.insert(key, event);
});
if finished {
break;
}
}
Ok(())
}
#[component]
pub fn Migrate() -> impl IntoView {
crate::webio::set_title("Migrate");
let form = RwSignal::new(SourceForm::default());
let password_input: NodeRef<leptos::html::Input> = NodeRef::new();
let plan: RwSignal<Option<grpc::pb::DiscoverMigrationResponse>> = RwSignal::new(None);
let selected: RwSignal<Vec<String>> = RwSignal::new(Vec::new());
let discovered_at: RwSignal<Option<String>> = RwSignal::new(None);
let error: RwSignal<Option<String>> = RwSignal::new(None);
let fail: RwSignal<Option<grpc::Fail>> = RwSignal::new(None);
let busy = RwSignal::new(false);
let running = RwSignal::new(false);
let stopping = RwSignal::new(false);
let rows: RwSignal<Progress> = RwSignal::new(BTreeMap::new());
let current_job: RwSignal<Option<String>> = RwSignal::new(None);
let log: RwSignal<Vec<String>> = RwSignal::new(Vec::new());
// The server's view of this session. The migration door is ADMIN-gated and
// fails closed, so the page says up front whether this credential can call
// it at all instead of letting the operator find out by typing a password
// into a form that was never going to work.
let identity = LocalResource::new(|| async { grpc::who_am_i().await.ok() });
// Clear the secret from BOTH places a browser holds it: the signal, and the
// input element's own value. Called the instant a request has been built —
// before it is even awaited.
let forget_password = move || {
form.update(|f| *f = forgotten(f));
if let Some(el) = password_input.get_untracked() {
el.set_value("");
}
};
let discover = move |_| {
if busy.get_untracked() || running.get_untracked() {
return;
}
let f = form.get_untracked();
if let Some(refusal) = source_form_error(&f) {
error.set(Some(refusal));
return;
}
let source = wire_source(&f, "");
forget_password();
busy.set(true);
error.set(None);
fail.set(None);
spawn_local(async move {
let res = grpc::migration()
.discover(grpc::pb::DiscoverMigrationRequest {
source: Some(source),
})
.await;
busy.set(false);
match res {
Ok(r) => {
let response = r.into_inner();
// Pre-select exactly what Start can actually fill today.
selected.set(
response
.repos
.iter()
.filter(|p| migration_row_selectable(&p.disposition))
.map(|p| p.source_repo.clone())
.collect(),
);
rows.set(BTreeMap::new());
log.set(Vec::new());
discovered_at.set(Some(crate::webio::now_hms()));
plan.set(Some(response));
}
Err(s) => {
plan.set(None);
fail.set(Some(grpc::fail(&s)));
}
}
});
};
let start = move |_| {
if busy.get_untracked() || running.get_untracked() {
return;
}
let f = form.get_untracked();
if let Some(refusal) = source_form_error(&f) {
error.set(Some(refusal));
return;
}
let Some(p) = plan.get_untracked() else {
return;
};
let all: Vec<String> = p.repos.iter().map(|r| r.source_repo.clone()).collect();
let queue: Vec<String> = match start_plan(&all, &selected.get_untracked()) {
StartPlan::Nothing => {
error.set(Some(
"select at least one repository that is already declared here".into(),
));
return;
}
// Empty = the whole registry, which is what the wire means by it.
StartPlan::WholeRegistry => vec![String::new()],
StartPlan::PerRepository(v) => v,
};
// The password is taken ONCE, into the task that needs it, and the form
// forgets it here — before a single byte has left the browser.
let password = f.password.clone();
let base = f.clone();
forget_password();
error.set(None);
fail.set(None);
rows.set(BTreeMap::new());
log.set(Vec::new());
running.set(true);
stopping.set(false);
spawn_local(async move {
for only in queue {
if stopping.get_untracked() {
break;
}
let mut source = wire_source(&base, &only);
source.password = password.clone();
let started = grpc::migration()
.start(grpc::pb::StartMigrationRequest {
source: Some(source),
concurrency: 0,
// Left false ON PURPOSE: the safe state is the zero
// value, so every published digest is verified.
skip_digest_verification: false,
})
.await;
let job_id = match started {
Ok(r) => {
let r = r.into_inner();
let what = if only.is_empty() {
"whole registry".to_string()
} else {
only.clone()
};
let line = format!(
"{} · job {} started · {what} · {}",
crate::webio::now_hms(),
r.job_id,
r.message
);
log.update(|l| l.push(line));
r.job_id
}
Err(s) => {
fail.set(Some(grpc::fail(&s)));
break;
}
};
current_job.set(Some(job_id.clone()));
if let Err(s) = follow(job_id, rows, log).await {
fail.set(Some(grpc::fail(&s)));
break;
}
current_job.set(None);
}
running.set(false);
current_job.set(None);
// `password` dies with this task, and it was never anywhere else.
});
};
let cancel = move |_| {
stopping.set(true);
let Some(job_id) = current_job.get_untracked() else {
return;
};
spawn_local(async move {
match grpc::migration()
.cancel(grpc::pb::CancelMigrationRequest { job_id })
.await
{
Ok(r) => {
let line = format!("{} · {}", crate::webio::now_hms(), r.into_inner().message);
log.update(|l| l.push(line));
}
Err(s) => fail.set(Some(grpc::fail(&s))),
}
});
};
let toggle = move |name: String, on: bool| {
selected.update(|s| {
if on {
if !s.contains(&name) {
s.push(name);
}
} else {
s.retain(|n| n != &name);
}
});
};
let select_all = move |on: bool| {
let Some(p) = plan.get_untracked() else {
return;
};
selected.set(if on {
p.repos
.iter()
.filter(|r| migration_row_selectable(&r.disposition))
.map(|r| r.source_repo.clone())
.collect()
} else {
Vec::new()
});
};
view! {
<section class="page">
<crate::pages::repositories::RepoTabs/>
<h2>"Migrate a registry into holger"</h2>
<p class="count measurenote">
"Reads a Nexus or Artifactory and moves what it finds into this server. \
Discover writes nothing and reads no artifact content — it is the preview, \
and it is where every migration starts."
</p>
// ── Can this session even call the door? ─────────────────────────
{move || match identity.get().flatten() {
None => Some(view! {
<p class="banner banner-warn" role="status">
"The server did not answer WhoAmI, so this console cannot say which \
identity it holds. Every migration RPC is ADMIN-gated and fails closed."
</p>
}.into_any()),
Some(id) => migration_role_refusal(id.authenticated, &id.global_role).map(|why| view! {
<p class="banner banner-bad" role="alert">
{why}
" "
<a href="/account">"Account"</a>
</p>
}.into_any()),
}}
// ── The source ───────────────────────────────────────────────────
<div class="axes">
<label class="axis" for="mig-kind">
<span>"Source"</span>
<select id="mig-kind" on:change=move |ev| {
let v = event_target_value(&ev);
let kind = if v == "artifactory" { MIGRATION_KIND_ARTIFACTORY } else { MIGRATION_KIND_NEXUS };
form.update(|f| f.kind = kind);
}>
<option value="nexus" selected>"Nexus"</option>
<option value="artifactory">"Artifactory"</option>
</select>
</label>
<label class="axis" for="mig-url">
<span>"Base URL"</span>
<input id="mig-url" type="url" inputmode="url" autocomplete="off"
placeholder="https://nexus.example.internal"
prop:value=move || form.with(|f| f.base_url.clone())
on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.base_url = v); }/>
</label>
<label class="axis" for="mig-user">
<span>"User"</span>
<input id="mig-user" type="text" autocomplete="off"
prop:value=move || form.with(|f| f.username.clone())
on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.username = v); }/>
</label>
<label class="axis" for="mig-pass">
<span>"Password"</span>
// Never stored, never rendered, never in a URL. Cleared from
// this element and from the page's state as soon as a request
// has been built — so Discover and Start each need it typed.
<input id="mig-pass" type="password" autocomplete="off" node_ref=password_input
on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.password = v); }/>
</label>
<label class="axis" for="mig-repo">
<span>"One repository (optional)"</span>
<input id="mig-repo" type="text" autocomplete="off" placeholder="blank = every repository"
prop:value=move || form.with(|f| f.repository.clone())
on:input=move |ev| { let v = event_target_value(&ev); form.update(|f| f.repository = v); }/>
</label>
</div>
<p class="count">{move || form.with(source_summary)}</p>
<p class="count measurenote">
"The source password is used for the one call you press and then forgotten — \
it is never stored, never logged and never put in a URL. Type it again before Start."
</p>
<div class="toolbar">
<button on:click=discover disabled=move || busy.get() || running.get()>
{move || if busy.get() { "Discovering…" } else { "Discover" }}
</button>
</div>
{move || error.get().map(|e| view! { <p class="err" role="alert">{e}</p> })}
{move || fail.get().map(|f| view! {
<FailBox context="migration" fail=Signal::derive(move || f.clone())/>
})}
// ── The plan ─────────────────────────────────────────────────────
{move || plan.get().map(|p| {
let to_declare = p.repos.iter().filter(|r| r.disposition == "create").count();
let fillable = p.repos.iter().filter(|r| migration_row_selectable(&r.disposition)).count();
let refused = p.repos.iter().filter(|r| !r.refusal_reason.is_empty()).count();
let declarations: Vec<(String, String)> = p.repos.iter()
.filter(|r| !r.declaration.is_empty())
.map(|r| (r.target_repo.clone(), r.declaration.clone()))
.collect();
let all_declarations = declarations.iter()
.map(|(_, d)| d.clone())
.collect::<Vec<_>>()
.join("\n");
let refusals = p.refusals.clone();
let report = p.report.clone();
let total_assets = p.total_assets;
let total_bytes = p.total_bytes;
let repo_rows = p.repos.clone();
let stamp = discovered_at.get().unwrap_or_default();
view! {
<h3>{format!("Discovered · {stamp}")}</h3>
<p class="count">
{migration_headline(p.repos.len(), fillable, to_declare, refused)}
{format!(" · {} assets · {} advertised", total_assets, fmt_bytes(total_bytes))}
</p>
{declare_first_note(to_declare).map(|note| view! {
<p class="banner banner-warn" role="alert">{note}</p>
})}
<div class="toolbar">
<button class="quiet" on:click=move |_| select_all(true)>"Select all fillable"</button>
<button class="quiet" on:click=move |_| select_all(false)>"Select none"</button>
</div>
<div class="tablewrap">
<table class="report">
<thead>
<tr>
<th scope="col"><span class="vh">"Selected"</span></th>
<th scope="col">"Source repository"</th>
<th scope="col">"Source format"</th>
<th scope="col">"Target here"</th>
<th scope="col">"holger format"</th>
<th scope="col">"Disposition"</th>
<th scope="col" class="num">"Assets"</th>
<th scope="col" class="num">"Bytes"</th>
</tr>
</thead>
<tbody>
{repo_rows.into_iter().map(|r| {
let name = r.source_repo.clone();
let for_toggle = name.clone();
let selectable = migration_row_selectable(&r.disposition);
let tone = disposition_tone(&r.disposition);
let note = disposition_note(&r.disposition);
let reason = r.refusal_reason.clone();
let checked = {
let n = name.clone();
move || selected.with(|s| s.contains(&n))
};
view! {
<tr>
<td>
<input type="checkbox" disabled=!selectable
aria-label=format!("select {name}")
prop:checked=checked
on:change=move |ev| {
let on = event_target_checked(&ev);
toggle(for_toggle.clone(), on);
}/>
</td>
<td class="mono">{r.source_repo.clone()}</td>
<td class="mono">{r.source_format.clone()}</td>
<td class="mono">{r.target_repo.clone()}</td>
<td class="mono">
{if r.target_format.is_empty() { "—".to_string() } else { r.target_format.clone() }}
</td>
<td>
<Badge tone=tone label=r.disposition.clone()/>
<span class="count">{format!(" {note}")}</span>
{(!reason.is_empty()).then(|| view! {
<p class="err">{reason.clone()}</p>
})}
</td>
<td class="num">{r.assets}</td>
<td class="num">{fmt_bytes(r.bytes)}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
// ── What a person must do before Start can fill these ────
{(!declarations.is_empty()).then(|| view! {
<h3>{format!("Declare these {} first, then restart holger", declarations.len())}</h3>
<p class="count measurenote">
"holger builds its route table from the config at boot and it is immutable \
for the life of the process — there is no create-repository verb on any \
surface. Paste each line into the wired config, fill in the storage paths, \
restart, then Discover again: these rows come back as fillable."
</p>
<div class="toolbar">
<CopyButton label="all declarations"
value=Signal::derive({ let a = all_declarations.clone(); move || a.clone() })/>
</div>
{declarations.into_iter().map(|(repo, decl)| {
let value = decl.clone();
view! {
<div class="cardline">
<span class="mono">{repo}</span>
<CopyButton label="declaration"
value=Signal::derive(move || value.clone())/>
</div>
<pre class="doc">{decl}</pre>
}
}).collect_view()}
})}
{(!refusals.is_empty()).then(|| view! {
<h3>{format!("Refused ({})", refusals.len())}</h3>
<p class="banner banner-bad" role="alert">
"This migration plan is REFUSED as a whole: a whole-registry run will not start until \
these are resolved. Repositories that are declared here can still be \
migrated one at a time — select them and Start."
</p>
<ul class="pathlist">
{refusals.into_iter().map(|r| view! { <li class="mono">{r}</li> }).collect_view()}
</ul>
})}
<h3>"Preview"</h3>
<p class="count measurenote">
"The server's own dry-run text, rendered by the same function the CLI prints — \
the terminal and this page cannot describe one plan in two ways."
</p>
<pre class="doc">{report}</pre>
// ── Start ────────────────────────────────────────────────
<h3>"Start"</h3>
<p class="count">
{move || {
let all: Vec<String> = plan.get()
.map(|p| p.repos.iter().map(|r| r.source_repo.clone()).collect())
.unwrap_or_default();
start_plan_note(&start_plan(&all, &selected.get()))
}}
</p>
<div class="toolbar">
<button class="danger-big" on:click=start
disabled=move || running.get() || busy.get() || selected.with(Vec::is_empty)>
{move || if running.get() { "Migrating…" } else { "Start migration" }}
</button>
<button class="danger" on:click=cancel
disabled=move || !running.get() || stopping.get()>
{move || if stopping.get() { "Stopping…" } else { "Cancel" }}
</button>
</div>
}
})}
// ── Live progress ────────────────────────────────────────────────
{move || {
let progress = rows.get();
(!progress.is_empty()).then(|| view! {
<h3>"Progress"</h3>
<div class="tablewrap">
<table class="report">
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col" class="num">"%"</th>
<th scope="col">"Detail"</th>
<th scope="col">"State"</th>
</tr>
</thead>
<tbody>
{progress.into_iter().map(|(repo, e)| {
let pct = migration_percent(e.done, e.skipped, e.total);
let line = migration_progress_line(e.done, e.skipped, e.failed, e.total, e.bytes);
// `complete` is only meaningful on the job's
// terminal row; the per-repository event
// always carries `complete: false`, so a
// repository is judged on its own counters.
let state = if e.job_finished {
let (text, tone) = migration_outcome(e.complete, e.cancelled, e.failed);
view! { <Badge tone=tone label=text/> }.into_any()
} else if e.repo_finished {
let (text, tone) =
migration_repo_outcome(e.done, e.skipped, e.failed, e.total, e.cancelled);
view! { <Badge tone=tone label=text/> }.into_any()
} else {
view! { <span class="pending">"moving…"</span> }.into_any()
};
view! {
<tr>
<td class="mono">{repo}</td>
<td class="num">{format!("{pct}%")}</td>
<td class="count">{line}</td>
<td>{state}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
})
}}
{move || {
let lines = log.get();
(!lines.is_empty()).then(|| view! {
<h3>"Job log"</h3>
<pre class="doc">{lines.join("\n")}</pre>
})
}}
</section>
}
}