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