src/pages/artifact_detail.rs
//! Query-addressed artifact detail. A shared URL carries only the coordinate,
//! so every fact is looked up from the address alone. Sections: overview
//! facts, actions, SBOM, properties, promotion — with promotion behind an
//! explicit consequence-stating confirmation.
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::hooks::use_query_map;
use crate::components::copy_button::CopyButton;
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorText, Loading};
use crate::grpc;
use crate::util::{display_name, download_file_name, enc, fmt_bytes, parse_property_values};
use crate::webio;
const PAGE_SIZE: i32 = 100;
/// What the exact-match page established about a coordinate's facts. `Absent`
/// is the ONLY state that may render as "—": it is the only one where the
/// server actually showed a complete page without the coordinate in it.
#[derive(Clone)]
enum Facts {
Found(Box<grpc::pb::ArtifactEntry>),
Absent,
Undetermined,
}
#[component]
pub fn ArtifactDetail() -> impl IntoView {
let query = use_query_map();
let repo = Memo::new(move |_| query.read().get("repo").unwrap_or_default());
let id = Memo::new(move |_| grpc::pb::ArtifactId {
namespace: query.read().get("ns").unwrap_or_default(),
name: query.read().get("name").unwrap_or_default(),
version: query.read().get("version").unwrap_or_default(),
});
Effect::new(move |_| {
let i = id.get();
crate::webio::set_title(&display_name(&i.namespace, &i.name));
});
// Facts (size, content type) via an exact-match page. Four distinct
// states, because "—" must only ever mean the server HAS no such fact:
// found, absent (the page was complete and the coordinate was not in it),
// undetermined (the filter returned a further page, so absence is not
// proven), and refused (an error, shown verbatim).
let facts = LocalResource::new(move || {
let repo = repo.get();
let id = id.get();
async move {
let resp = grpc::repositories()
.list_artifacts(grpc::pb::ListArtifactsRequest {
repository: repo,
name_filter: id.name.clone(),
limit: PAGE_SIZE,
page_token: String::new(),
})
.await
.map(|r| r.into_inner())
.map_err(|s| grpc::status_text(&s))?;
let complete = resp.next_page_token.is_empty();
Ok::<_, String>(
match resp
.artifacts
.into_iter()
.find(|e| e.id.as_ref() == Some(&id))
{
Some(e) => Facts::Found(Box::new(e)),
// Not on this page AND more pages exist: the console has
// not established absence, so it must not assert it.
None if !complete => Facts::Undetermined,
None => Facts::Absent,
},
)
}
});
let sbom = LocalResource::new(move || {
let repo = repo.get();
let id = id.get();
async move {
match grpc::sboms()
.fetch_sbom(grpc::pb::FetchSbomRequest {
repository: repo,
artifact: Some(id),
})
.await
{
Ok(r) => Ok(Some(r.into_inner())),
// Absent is a normal state, not an error.
Err(s) if s.code() == tonic::Code::NotFound => Ok(None),
Err(s) => Err(grpc::status_text(&s)),
}
}
});
type PropsState = Option<Result<Vec<grpc::pb::PropertyEntry>, String>>;
let props: RwSignal<PropsState> = RwSignal::new(None);
// The property WRITE's own outcome — never mixed into the read state.
let prop_write: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);
let prop_busy = RwSignal::new(false);
Effect::new(move |_| {
let repo = repo.get();
let id = id.get();
props.set(None);
spawn_local(async move {
let res = grpc::properties()
.get_properties(grpc::pb::GetPropertiesRequest {
repository: repo,
artifact: Some(id),
})
.await
.map(|r| r.into_inner().properties)
.map_err(|s| grpc::status_text(&s));
props.set(Some(res));
});
});
let download_state: RwSignal<Option<String>> = RwSignal::new(None);
let downloading = RwSignal::new(false);
let download = move |_| {
if downloading.get_untracked() {
return;
}
let repo = repo.get_untracked();
let id = id.get_untracked();
let file_name = download_file_name(&id.name);
downloading.set(true);
download_state.set(Some("fetching…".into()));
spawn_local(async move {
let res = grpc::repositories()
.fetch_artifact(grpc::pb::FetchArtifactRequest {
repository: repo,
id: Some(id),
})
.await;
downloading.set(false);
match res {
Ok(resp) => {
let resp = resp.into_inner();
match webio::save_bytes(&file_name, &resp.data, &resp.content_type) {
Ok(()) => download_state.set(Some(format!(
"saved {} ({})",
file_name,
fmt_bytes(resp.data.len() as i64)
))),
Err(e) => download_state.set(Some(format!("save failed: {e}"))),
}
}
Err(s) => {
download_state.set(Some(format!("fetch failed: {}", grpc::status_text(&s))))
}
}
});
};
view! {
<section>
<p class="crumbs">
<a href="/repositories">"repositories"</a>
" / "
<a href=move || format!("/repo/{}", enc(&repo.get()))>{move || repo.get()}</a>
" / " <strong>{move || { let i = id.get(); display_name(&i.namespace, &i.name) }}</strong>
</p>
<h2>"Artifact"</h2>
<dl class="facts">
<dt>"Repository"</dt><dd>{move || repo.get()}</dd>
<dt>"Coordinate"</dt><dd>{move || { let i = id.get(); display_name(&i.namespace, &i.name) }}</dd>
<dt>"Version"</dt><dd>{move || {
let v = id.get().version;
if v.is_empty() { "—".into() } else { v }
}}</dd>
<dt>"Size"</dt><dd>{move || match facts.get() {
Some(Ok(Facts::Found(e))) => fmt_bytes(e.size_bytes),
Some(Ok(Facts::Absent)) => "—".into(),
Some(Ok(Facts::Undetermined)) => "not on the first page".into(),
Some(Err(_)) => "unknown".into(),
None => "…".into(),
}}</dd>
<dt>"Content type"</dt><dd>{move || match facts.get() {
Some(Ok(Facts::Found(e))) if !e.content_type.is_empty() => e.content_type.clone(),
Some(Ok(Facts::Found(_))) | Some(Ok(Facts::Absent)) => "—".into(),
Some(Ok(Facts::Undetermined)) => "not on the first page".into(),
Some(Err(_)) => "unknown".into(),
None => "…".into(),
}}</dd>
</dl>
{move || match facts.get() {
Some(Err(e)) => Some(view! {
<ErrorText context="could not read the artifact's facts" message=e/>
}),
_ => None,
}}
<div class="toolbar">
<button on:click=download disabled=move || downloading.get()>
{move || if downloading.get() { "Fetching…" } else { "Download" }}
</button>
<CopyButton label="coordinate" value=Signal::derive(move || {
let i = id.get();
display_name(&i.namespace, &i.name)
})/>
<CopyButton label="version" value=Signal::derive(move || id.get().version)/>
<CopyButton label="link" value=Signal::derive(move || {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
let i = id.get();
format!("{origin}{}", crate::util::artifact_href(&repo.get(), &i.namespace, &i.name, &i.version))
})/>
{move || download_state.get().map(|s| view! { <span class="count" role="status">{s}</span> })}
</div>
<h3>"SBOM"</h3>
{move || match sbom.get() {
None => view! { <Loading what="SBOM"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="failed to fetch SBOM" message=e/> }.into_any(),
Some(Ok(None)) => view! { <EmptyState message="no SBOM attached"/> }.into_any(),
Some(Ok(Some(s))) => {
let body = String::from_utf8_lossy(&s.document).into_owned();
let addr = s.content_address.clone();
view! {
<p class="count">
{format!("{} · blake3 {}", fmt_bytes(s.size_bytes), s.content_address)}
" "
<CopyButton label="blake3" value=Signal::derive(move || addr.clone())/>
</p>
<pre class="doc">{body}</pre>
}.into_any()
}
}}
<SbomAttach repo=repo id=id on_attached=Callback::new(move |()| sbom.refetch())/>
<h3>"Properties"</h3>
{move || match props.get() {
None => view! { <Loading what="properties"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="failed to fetch properties" message=e/> }.into_any(),
Some(Ok(list)) if list.is_empty() =>
view! { <EmptyState message="no properties"/> }.into_any(),
Some(Ok(list)) => view! {
<div class="tablewrap">
<table>
<thead>
<tr>
<th scope="col">"Key"</th>
<th scope="col">"Values"</th>
<th scope="col"><span class="vh">"Actions"</span></th>
</tr>
</thead>
<tbody>
{list.into_iter().map(|p| {
let remove = {
let key = p.key.clone();
// Empty `values` ⇒ remove — the proto's own
// idiom, same as the CLI's no-`--value` form.
move |_| set_property(
props,
prop_write,
prop_busy,
repo.get_untracked(),
id.get_untracked(),
key.clone(),
vec![],
)
};
view! {
<tr>
<td>{p.key.clone()}</td>
<td>{p.values.join(", ")}</td>
<td class="rowact"><button class="danger" on:click=remove
disabled=move || prop_busy.get()>"Remove"</button></td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
}}
<PropertyEditor repo=repo id=id props=props write=prop_write busy=prop_busy/>
{move || prop_write.get().map(|r| match r {
Ok(msg) => view! { <p class="ok" role="status">{msg}</p> }.into_any(),
Err(msg) => view! {
<ErrorText context="property write refused" message=msg/>
}.into_any(),
})}
<h3>"Promote"</h3>
<Promote repo=repo id=id/>
</section>
}
}
/// Fire one SetProperty and land the server's returned full map in the shared
/// signal — the response IS the new truth, no refetch.
///
/// The WRITE's outcome lives in its own signal: a refused write says nothing
/// about what the last successful READ returned, so it must not overwrite the
/// property list with an error (which would also mislabel the failure as
/// "failed to fetch properties"). `busy` guards against double submits.
fn set_property(
props: RwSignal<Option<Result<Vec<grpc::pb::PropertyEntry>, String>>>,
write: RwSignal<Option<Result<String, String>>>,
busy: RwSignal<bool>,
repo: String,
id: grpc::pb::ArtifactId,
key: String,
values: Vec<String>,
) {
if busy.get_untracked() {
return;
}
busy.set(true);
write.set(None);
spawn_local(async move {
match grpc::properties()
.set_property(grpc::pb::SetPropertyRequest {
repository: repo,
artifact: Some(id),
key,
values,
})
.await
{
Ok(r) => {
let r = r.into_inner();
if r.success {
// The response carries the full map: it IS the new read.
props.set(Some(Ok(r.properties)));
write.set(Some(Ok(format!("saved · {}", crate::webio::now_hms()))));
} else {
// A refusal the server chose to report in-band. The
// previously read list stands untouched.
write.set(Some(Err(r.message)));
}
}
Err(s) => write.set(Some(Err(grpc::status_text(&s)))),
}
busy.set(false);
});
}
/// Attach a CycloneDX document to the coordinate. The server owns validation
/// and the write gate — its refusal is the UI text, verbatim.
#[component]
fn SbomAttach(
repo: Memo<String>,
id: Memo<grpc::pb::ArtifactId>,
on_attached: Callback<()>,
) -> impl IntoView {
let picked: RwSignal<Option<web_sys::File>> = RwSignal::new(None);
let status: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);
let busy = RwSignal::new(false);
let on_pick = move |ev: leptos::ev::Event| {
let input: web_sys::HtmlInputElement = event_target(&ev);
picked.set(input.files().and_then(|l| l.get(0)));
status.set(None);
};
let attach = move |_| {
if busy.get_untracked() {
return;
}
let Some(file) = picked.get_untracked() else {
status.set(Some(Err("pick a CycloneDX JSON file first".into())));
return;
};
let repo = repo.get_untracked();
let id = id.get_untracked();
busy.set(true);
status.set(None);
spawn_local(async move {
let document = match webio::read_file(&file).await {
Ok(b) => b,
Err(e) => {
busy.set(false);
status.set(Some(Err(format!("could not read file: {e}"))));
return;
}
};
let res = grpc::sboms()
.attach_sbom(grpc::pb::AttachSbomRequest {
repository: repo,
artifact: Some(id),
document,
})
.await;
busy.set(false);
match res {
Ok(r) => {
let r = r.into_inner();
if r.success {
status.set(Some(Ok(format!(
"attached · blake3 {} · {}",
r.content_address,
crate::webio::now_hms()
))));
picked.set(None);
on_attached.run(());
} else {
status.set(Some(Err(r.message)));
}
}
Err(s) => status.set(Some(Err(grpc::status_text(&s)))),
}
});
};
view! {
<div class="attach">
<label class="vh" for="sbom-file">"CycloneDX JSON file"</label>
<input id="sbom-file" type="file" accept=".json,application/json" on:change=on_pick/>
<button on:click=attach disabled=move || busy.get()>
{move || if busy.get() { "Attaching…" } else { "Attach SBOM" }}
</button>
{move || status.get().map(|s| match s {
Ok(m) => view! { <span class="ok" role="status">{m}</span> }.into_any(),
Err(m) => view! { <span class="err" role="alert">{format!("attach failed: {m}")}</span> }.into_any(),
})}
</div>
}
}
/// Set a key = comma-separated values on the coordinate. A re-set of an
/// existing key overwrites its values; removal lives on each table row.
#[component]
fn PropertyEditor(
repo: Memo<String>,
id: Memo<grpc::pb::ArtifactId>,
props: RwSignal<Option<Result<Vec<grpc::pb::PropertyEntry>, String>>>,
write: RwSignal<Option<Result<String, String>>>,
busy: RwSignal<bool>,
) -> impl IntoView {
let key = RwSignal::new(String::new());
let values = RwSignal::new(String::new());
let hint: RwSignal<Option<String>> = RwSignal::new(None);
let set = move |_| {
let k = key.get_untracked().trim().to_string();
if k.is_empty() {
hint.set(Some("key is required".into()));
return;
}
let vals = parse_property_values(&values.get_untracked());
if vals.is_empty() {
hint.set(Some(
"at least one value — removal is the Remove button".into(),
));
return;
}
hint.set(None);
set_property(
props,
write,
busy,
repo.get_untracked(),
id.get_untracked(),
k,
vals,
);
key.set(String::new());
values.set(String::new());
};
view! {
<div class="attach">
<label class="vh" for="prop-key">"Property key"</label>
<input id="prop-key" type="text" placeholder="key"
prop:value=move || key.get()
on:input=move |ev| key.set(event_target_value(&ev))/>
<label class="vh" for="prop-values">"Property values, comma-separated"</label>
<input id="prop-values" type="text" placeholder="value, value, …"
prop:value=move || values.get()
on:input=move |ev| values.set(event_target_value(&ev))/>
<button on:click=set disabled=move || busy.get()>
{move || if busy.get() { "Saving…" } else { "Set property" }}
</button>
{move || hint.get().map(|m| view! { <span class="err" role="alert">{m}</span> })}
</div>
}
}
/// Promote this coordinate into an immutable destination — behind an explicit
/// confirmation that states the seal-once consequence. The server's ADMIN gate
/// and the create-once seal are the safety; the dialog makes the
/// irreversibility readable, and never softens it.
#[component]
fn Promote(repo: Memo<String>, id: Memo<grpc::pb::ArtifactId>) -> impl IntoView {
let dest = RwSignal::new(String::new());
let status: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);
let busy = RwSignal::new(false);
let confirm_open = RwSignal::new(false);
// Destination candidates: every configured repo except the source.
let repos = LocalResource::new(move || {
let source = repo.get();
async move {
grpc::admin()
.list_repositories(grpc::pb::ListRepositoriesRequest {})
.await
.map(|r| {
r.into_inner()
.repositories
.into_iter()
.map(|r| r.name)
.filter(|n| *n != source)
.collect::<Vec<_>>()
})
.map_err(|s| grpc::status_text(&s))
}
});
let open_confirm = move |_| {
if dest.get_untracked().is_empty() {
status.set(Some(Err("choose a destination repository".into())));
return;
}
status.set(None);
confirm_open.set(true);
};
let run = move |_| {
if busy.get_untracked() {
return;
}
let to_repo = dest.get_untracked();
let from_repo = repo.get_untracked();
let artifact = id.get_untracked();
busy.set(true);
spawn_local(async move {
let res = grpc::promotion()
.promote_artifact(grpc::pb::PromoteArtifactRequest {
from_repo,
to_repo,
artifact: Some(artifact),
async_job: false,
})
.await;
busy.set(false);
confirm_open.set(false);
match res {
Ok(r) => {
let r = r.into_inner();
if r.success {
status.set(Some(Ok(format!(
"promoted · blake3 {} · by {} · {}",
r.content_address,
r.promoted_by,
crate::webio::now_hms()
))));
} else {
// Re-promotion refused, policy gate, admin gate — the
// server's sentence is the explanation.
status.set(Some(Err(r.message)));
}
}
Err(s) => status.set(Some(Err(grpc::status_text(&s)))),
}
});
};
view! {
<div class="attach">
{move || match repos.get() {
None => view! { <Loading what="repositories"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="cannot list repositories" message=e/> }.into_any(),
Some(Ok(names)) if names.is_empty() =>
view! { <EmptyState message="no other repository to promote into"/> }.into_any(),
Some(Ok(names)) => view! {
<label class="vh" for="promote-dest">"Destination repository"</label>
<select id="promote-dest" on:change=move |ev| dest.set(event_target_value(&ev))>
<option value="" selected>"— destination repository —"</option>
{names.into_iter().map(|n| { let val = n.clone(); view! { <option value=val>{n}</option> } }).collect_view()}
</select>
}.into_any(),
}}
<button on:click=open_confirm disabled=move || busy.get()>"Promote…"</button>
<span class="count">"admin-gated · immutable once sealed"</span>
{move || status.get().map(|s| match s {
Ok(m) => view! { <span class="ok" role="status">{m}</span> }.into_any(),
Err(m) => view! { <span class="err" role="alert">{format!("promote refused: {m}")}</span> }.into_any(),
})}
</div>
<ConfirmDialog
open=confirm_open
title="Promote artifact"
consequence="Seal-once: the destination coordinate becomes immutable and a re-promote \
of the same coordinate will be refused. There is no undo from the console."
confirm_label="Promote"
busy=busy
on_confirm=Callback::new(run)
// Wrong-target defence: the operator must type the DESTINATION
// repository. Seal-once means a mis-picked destination cannot be
// taken back, and the picker is one keystroke away from the
// neighbouring repo.
confirm_phrase=Signal::derive(move || dest.get())
>
{move || {
let i = id.get();
view! {
<dl class="facts facts-plain">
<dt>"Artifact"</dt><dd>{display_name(&i.namespace, &i.name)}</dd>
<dt>"From"</dt><dd>{repo.get()}</dd>
<dt>"To"</dt><dd>{dest.get()}</dd>
</dl>
}
}}
</ConfirmDialog>
}
}