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/search.rs

//! Search — every axis the server's proto actually offers: name, namespace,
//! version, path, checksum, a repository restriction, and custom-property
//! filters (`PropertyMatch`). The URL is the search state: filters prefill
//! from it, a shared address auto-runs, and running a search IS navigating.

use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::hooks::{use_navigate, use_query_map};

use crate::components::feedback::{EmptyState, ErrorText};
use crate::grpc;
use crate::util::{
    artifact_href, display_name, fmt_bytes, parse_prop_param, search_href, search_href_from_chips,
    SEARCH_AXES,
};

/// The active filters in the address, as (kind, raw value) — the axes in
/// `SEARCH_AXES` order, then one entry per `prop`. It is the chip row AND the
/// list each ✕ re-navigates without, so both are built from the same walk.
fn active_filters(q: &leptos_router::params::ParamsMap) -> Vec<(String, String)> {
    let mut chips: Vec<(String, String)> = Vec::new();
    for k in SEARCH_AXES {
        if let Some(v) = q.get(k) {
            if !v.is_empty() {
                chips.push((k.to_string(), v));
            }
        }
    }
    for raw in q.get_all("prop").into_iter().flatten() {
        chips.push(("prop".to_string(), raw));
    }
    chips
}

#[component]
pub fn Search() -> impl IntoView {
    crate::webio::set_title("Search");
    let query = use_query_map();
    let navigate = use_navigate();

    let name = RwSignal::new(String::new());
    let namespace = RwSignal::new(String::new());
    let version = RwSignal::new(String::new());
    let path_axis = RwSignal::new(String::new());
    let checksum = RwSignal::new(String::new());
    let repo_filter = RwSignal::new(String::new());
    // Property filters as (key, value) rows; empty value = "key present".
    let prop_filters: RwSignal<Vec<(String, String)>> = RwSignal::new(Vec::new());
    let results: RwSignal<Option<grpc::pb::SearchResponse>> = RwSignal::new(None);
    let error: RwSignal<Option<String>> = RwSignal::new(None);
    let loading = RwSignal::new(false);
    // Monotone generation: rapid back/forward can leave several searches in
    // flight, and gRPC-web has no cancellation — so stale responses are
    // IGNORED instead (only the newest generation may write the signals).
    let generation: StoredValue<u64> = StoredValue::new(0);

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

    // The URL is the search state: prefill the axes from it and run whenever
    // it changes — /search?name=firmware shared cold runs by itself.
    Effect::new(move |_| {
        let q = query.read();
        let vals = [
            q.get("name").unwrap_or_default(),
            q.get("namespace").unwrap_or_default(),
            q.get("version").unwrap_or_default(),
            q.get("path").unwrap_or_default(),
            q.get("checksum").unwrap_or_default(),
            q.get("repo").unwrap_or_default(),
        ];
        let props: Vec<(String, String)> = q
            .get_all("prop")
            .into_iter()
            .flatten()
            .map(|raw| parse_prop_param(&raw))
            .collect();
        name.set(vals[0].clone());
        namespace.set(vals[1].clone());
        version.set(vals[2].clone());
        path_axis.set(vals[3].clone());
        checksum.set(vals[4].clone());
        repo_filter.set(vals[5].clone());
        prop_filters.set(props.clone());
        // Bump FIRST: clearing the filters must also invalidate whatever is
        // in flight, or a response from the previous query lands afterwards
        // and shows results under an empty search.
        let my_gen = generation.with_value(|g| g + 1);
        generation.set_value(my_gen);
        if vals.iter().all(|v| v.is_empty()) && props.is_empty() {
            results.set(None);
            error.set(None);
            loading.set(false);
            return;
        }
        let [n, ns, v, p, c, repo] = vals;
        loading.set(true);
        error.set(None);
        spawn_local(async move {
            let res = grpc::search()
                .search(grpc::pb::SearchRequest {
                    name: n,
                    namespace: ns,
                    version: v,
                    checksum: c,
                    path: p,
                    repositories: if repo.is_empty() { vec![] } else { vec![repo] },
                    limit: 200,
                    properties: props
                        .into_iter()
                        .map(|(key, value)| grpc::pb::PropertyMatch { key, value })
                        .collect(),
                })
                .await;
            if generation.get_value() != my_gen {
                return; // a newer search superseded this one
            }
            loading.set(false);
            match res {
                Ok(r) => results.set(Some(r.into_inner())),
                Err(s) => {
                    // A refused search has no hits. Keeping the previous
                    // query's tables would present them as THIS query's answer
                    // — undimmed once loading ends, under the new query's
                    // chips and its own row count. Same rule as the browse
                    // listing in repository_detail.
                    results.set(None);
                    error.set(Some(grpc::status_text(&s)));
                }
            }
        });
    });

    // Running a search = navigating to its address.
    let run = move || {
        let axes: Vec<(&str, String)> = SEARCH_AXES
            .iter()
            .zip([name, namespace, version, path_axis, checksum, repo_filter])
            .map(|(k, sig)| (*k, sig.get_untracked()))
            .collect();
        navigate(
            &search_href(&axes, &prop_filters.get_untracked()),
            Default::default(),
        );
    };
    let run_click = run.clone();
    let reset = {
        let navigate = use_navigate();
        move |_| navigate("/search", Default::default())
    };

    let axis = move |label: &'static str, placeholder: &'static str, sig: RwSignal<String>| {
        let run = run.clone();
        view! {
            <label class="axis">
                <span>{label}</span>
                <input
                    type="text"
                    placeholder=placeholder
                    prop:value=move || sig.get()
                    on:input=move |ev| sig.set(event_target_value(&ev))
                    on:keydown=move |ev| { if ev.key() == "Enter" { run(); } }
                />
            </label>
        }
    };

    // Active filters as removable chips — one glance says what constrained the
    // result, and each ✕ re-navigates without that constraint.
    let active_chips = move || active_filters(&query.read());
    let remove_chip = {
        let navigate = use_navigate();
        move |kind: String, value: String| {
            let kept: Vec<(String, String)> = active_filters(&query.read_untracked())
                .into_iter()
                .filter(|(k, v)| !(*k == kind && *v == value))
                .collect();
            navigate(&search_href_from_chips(&kept), Default::default());
        }
    };

    view! {
        <section class="page">
            <crate::pages::repositories::RepoTabs/>
            <h2>"Search"</h2>
            <div class="axes">
                {axis("Name", "substring, e.g. firmware", name)}
                {axis("Namespace", "substring (groupId / scope)", namespace)}
                {axis("Version", "exact, e.g. 2.0.0", version)}
                {axis("Path", "archive path substring", path_axis)}
                {axis("Checksum", "sha256 hex — expensive, hashes candidates", checksum)}
                <label class="axis">
                    <span>"Repository"</span>
                    {move || match repos.get() {
                        Some(Ok(names)) => view! {
                            <select
                                prop:value=move || repo_filter.get()
                                on:change=move |ev| repo_filter.set(event_target_value(&ev))>
                                <option value="">"— all repositories —"</option>
                                {names.into_iter().map(|n| { let val = n.clone(); view! { <option value=val>{n}</option> } }).collect_view()}
                            </select>
                        }.into_any(),
                        _ => view! { <span class="pending">"…"</span> }.into_any(),
                    }}
                </label>
            </div>

            <h3>"Property filters"</h3>
            <p class="count measurenote">
                "AND-composed: an artifact matches only if it carries every listed property. \
                 Empty value = the key is present with any value."
            </p>
            {move || prop_filters.get().into_iter().enumerate().map(|(i, (k, v))| {
                view! {
                    <div class="attach">
                        <input type="text" placeholder="key" prop:value=k.clone()
                            on:input=move |ev| prop_filters.update(|p| { if let Some(r) = p.get_mut(i) { r.0 = event_target_value(&ev); } })/>
                        <input type="text" placeholder="value (empty = any)" prop:value=v.clone()
                            on:input=move |ev| prop_filters.update(|p| { if let Some(r) = p.get_mut(i) { r.1 = event_target_value(&ev); } })/>
                        <button class="danger" on:click=move |_| prop_filters.update(|p| { p.remove(i); })>
                            "Remove"
                        </button>
                    </div>
                }
            }).collect_view()}
            <div class="toolbar">
                <button class="quiet" on:click=move |_| prop_filters.update(|p| p.push((String::new(), String::new())))>
                    "+ property filter"
                </button>
            </div>

            <div class="toolbar">
                <button on:click=move |_| run_click()>"Search"</button>
                <button class="quiet" on:click=reset>"Reset filters"</button>
                {move || loading.get().then(|| view! { <span class="pending" role="status">"searching…"</span> })}
            </div>

            {move || {
                let chips = active_chips();
                (!chips.is_empty()).then(|| {
                    let remove_chip = remove_chip.clone();
                    view! {
                        <p class="chips">
                            <span class="count">"active: "</span>
                            {chips.into_iter().map(|(k, v)| {
                                let remove = {
                                    let remove_chip = remove_chip.clone();
                                    let k = k.clone(); let v = v.clone();
                                    move |_| remove_chip(k.clone(), v.clone())
                                };
                                view! {
                                    <span class="chip">
                                        {format!("{k}: {v} ")}
                                        <button class="chip-x" aria-label=format!("remove filter {k}") on:click=remove>"✕"</button>
                                    </span>
                                }
                            }).collect_view()}
                        </p>
                    }
                })
            }}

            // A failed CALL and an empty RESULT are different facts — the
            // error renders red and alone; empty results render per-kind.
            {move || error.get().map(|e| view! { <ErrorText context="search failed" message=e/> })}
            {move || results.get().map(|r| {
                let truncated = r.truncated;
                let artifacts = r.artifacts;
                let paths = r.paths;
                view! {
                    {truncated.then(|| view! {
                        <p class="banner banner-warn" role="alert">"result capped at the limit — more hits exist, narrow the query"</p>
                    })}
                    <h3>{format!("Artifacts ({})", artifacts.len())}</h3>
                    {if artifacts.is_empty() {
                        view! { <EmptyState message="no artifact hits"/> }.into_any()
                    } else {
                        view! {
                            <div class="tablewrap" class:stale=move || loading.get()
                                 aria-busy=move || loading.get().to_string()>
                                <table>
                                    <thead>
                                        <tr>
                                            <th scope="col">"Repository"</th>
                                            <th scope="col">"Artifact"</th>
                                            <th scope="col">"Version"</th>
                                            <th scope="col">"Size"</th>
                                            <th scope="col">"Checksum"</th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        {artifacts.into_iter().map(|h| {
                                            let id = h.id.unwrap_or_default();
                                            let full_name = display_name(&id.namespace, &id.name);
                                            let href = artifact_href(&h.repository, &id.namespace, &id.name, &id.version);
                                            view! {
                                                <tr>
                                                    <td>{h.repository.clone()}</td>
                                                    <td><a href=href>{full_name}</a></td>
                                                    <td>{id.version.clone()}</td>
                                                    <td class="num">{fmt_bytes(h.size_bytes)}</td>
                                                    <td class="mono">{if h.checksum.is_empty() { "—".into() } else { h.checksum }}</td>
                                                </tr>
                                            }
                                        }).collect_view()}
                                    </tbody>
                                </table>
                            </div>
                        }.into_any()
                    }}
                    <h3>{format!("Paths ({})", paths.len())}</h3>
                    {if paths.is_empty() {
                        view! { <EmptyState message="no path hits"/> }.into_any()
                    } else {
                        view! {
                            <div class="tablewrap" class:stale=move || loading.get()
                                 aria-busy=move || loading.get().to_string()>
                                <table>
                                    <thead>
                                        <tr><th scope="col">"Repository"</th><th scope="col">"Path"</th></tr>
                                    </thead>
                                    <tbody>
                                        {paths.into_iter().map(|p| view! {
                                            <tr><td>{p.repository}</td><td class="mono">{p.path}</td></tr>
                                        }).collect_view()}
                                    </tbody>
                                </table>
                            </div>
                        }.into_any()
                    }}
                }
            })}
        </section>
    }
}