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

//! The security workflow's two pages: the dashboard (`/security`) and one
//! artifact's view (`/security/artifact?ns=&name=&version=`). Every fact on
//! them is holger's answer, turned into lines by `crate::security`; the four
//! buttons each send one `AdvanceArtifact` and show what the server answered.
//!
//! The dashboard shows security, never the machinery (owner 2026-10-27): the gateway as
//! holger words it (`AdminService.SecuritySummary`), then the intake queues. The estate
//! board is the Overview's.

use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::hooks::use_query_map;

use crate::components::feedback::{Badge, ConfirmDialog, EmptyState, Loading, Tone};
use crate::security::{self as sec, Action, GrpcDoor, Pane, Pressed, RefusalView};

/// **The gateway, as holger words it** (lane HOL-SEC): one card per section the server
/// sent (the chain, the sources, the scanners, what waits, what is held, who reads, the
/// sync signatures, the last security events), each row as it came.
#[component]
fn SecuritySummary() -> impl IntoView {
    let view_of = LocalResource::new(|| async { sec::load_security(&GrpcDoor).await });
    view! {
        <div class="security-summary" data-security="summary">
            {move || match view_of.get() {
                None => view! { <Loading what="the security summary"/> }.into_any(),
                Some(Err(r)) => view! { <Refusal context="holger refused the security summary" refusal=r/> }.into_any(),
                Some(Ok(v)) => view! {
                    <p class="banner" data-security="headline">{v.headline}</p>
                    <div class="panes">
                        {v.sections.into_iter().map(|s| view! {
                            <div class="card pane" data-section=s.key.clone()>
                                <h3>{s.title}</h3>
                                {(!s.summary.is_empty()).then(|| view! { <p class="count">{s.summary.clone()}</p> })}
                                {if s.rows.is_empty() {
                                    view! { <EmptyState message=s.empty.clone()/> }.into_any()
                                } else {
                                    view! {
                                        <ul class="pathlist">
                                            {s.rows.into_iter().map(|r| view! {
                                                <li>
                                                    {match r.href {
                                                        Some(href) => view! { <a href=href>{r.name}</a> }.into_any(),
                                                        None => view! { <strong>{r.name}</strong> }.into_any(),
                                                    }}
                                                    " " <Badge tone=r.tone label=r.state/>
                                                    {(!r.detail.is_empty()).then(|| view! { <span class="count">{format!(" {}", r.detail)}</span> })}
                                                </li>
                                            }).collect_view()}
                                        </ul>
                                    }.into_any()
                                }}
                            </div>
                        }).collect_view()}
                    </div>
                }.into_any(),
            }}
        </div>
    }
}

/// The server's refusal: its code and its words.
#[component]
fn Refusal(#[prop(into)] context: String, refusal: RefusalView) -> impl IntoView {
    view! {
        <div class="failbox" role="alert">
            <p class="fail-summary"><strong>{context}</strong></p>
            <p class="fail-detail mono">
                <span class="badge badge-bad">{refusal.code}</span>
                {refusal.text}
            </p>
        </div>
    }
}

/// **The dependency sets** (holger lane HOL-DEPSET-APPROVE): each pull's whole closure as ONE
/// approval item, its members inside. holger serves the sets and decides every verb (who
/// may, the two-person rule, what refuses or holds a set); the card shows its words.
#[component]
fn DependencySets() -> impl IntoView {
    let generation = RwSignal::new(0u32);
    let sets = LocalResource::new(move || {
        let _ = generation.get();
        async move { crate::depsets::load().await }
    });
    let reason = RwSignal::new(String::new());
    let words: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);
    let busy = RwSignal::new(false);
    let press = move |verb: &'static str, id: String, revision: String| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        let why = reason.get_untracked();
        spawn_local(async move {
            let got = crate::depsets::press(verb, &id, &revision, &why).await;
            busy.set(false);
            words.set(Some(got));
            generation.update(|g| *g += 1);
        });
    };
    view! {
        <div class="depsets" data-security="depsets">
            <div class="metaline">
                <h3>"Dependency sets"</h3>
                <button class="quiet" on:click=move |_| generation.update(|g| *g += 1)>"Refresh"</button>
            </div>
            <p class="count">"A pull brings in its whole dependency closure; every file is scanned, and the set is approved or refused as one."</p>
            <label class="vh" for="set-reason">"Reason"</label>
            <input id="set-reason" type="text" placeholder="reason (optional)"
                prop:value=move || reason.get()
                on:input=move |ev| reason.set(event_target_value(&ev))/>
            {move || words.get().map(|w| match w {
                Ok(m) => view! { <p class="good" role="status">{m}</p> }.into_any(),
                Err(m) => view! { <p class="bad" role="alert">{m}</p> }.into_any(),
            })}
            {move || match sets.get() {
                None => view! { <Loading what="the dependency sets"/> }.into_any(),
                Some(Err(m)) => view! { <p class="bad" role="alert">{m}</p> }.into_any(),
                Some(Ok(list)) if list.is_empty() => view! { <EmptyState message="no dependency sets"/> }.into_any(),
                Some(Ok(list)) => view! {
                    <div class="panes">
                        {list.into_iter().map(|c| {
                            let (a, r) = (c.id.clone(), c.id.clone());
                            let (ra, rr) = (c.revision.clone(), c.revision.clone());
                            view! {
                                <div class="card pane" data-set=c.id.clone() data-revision=c.revision.clone()>
                                    <h3>{c.root.clone()} " " <Badge tone=c.tone label=c.status.clone()/></h3>
                                    <p class="count">{format!("{} · {} · {}", c.ecosystem, c.source, c.count)}</p>
                                    <p>{c.summary.clone()}</p>
                                    <p class="count">{c.votes.clone()}
                                        {(!c.voted.is_empty()).then(|| format!(" ({})", c.voted))}
                                    </p>
                                    {(!c.blockers.is_empty()).then(|| view! {
                                        <p><strong>"Refused by"</strong></p>
                                        <ul class="pathlist">{c.blockers.iter().map(|b| view! { <li class="mono bad">{b.clone()}</li> }).collect_view()}</ul>
                                    })}
                                    {(!c.holds.is_empty()).then(|| view! {
                                        <p><strong>"Held by"</strong></p>
                                        <ul class="pathlist">{c.holds.iter().map(|h| view! { <li class="mono">{h.clone()}</li> }).collect_view()}</ul>
                                    })}
                                    {(!c.notes.is_empty()).then(|| view! {
                                        <ul class="pathlist">{c.notes.iter().map(|n| view! { <li class="count">{n.clone()}</li> }).collect_view()}</ul>
                                    })}
                                    {c.open.then(|| view! {
                                        <div class="toolbar actions">
                                            <button on:click=move |_| press("approve", a.clone(), ra.clone())>"Approve the set"</button>
                                            <button class="danger" on:click=move |_| press("refuse", r.clone(), rr.clone())>"Refuse the set"</button>
                                        </div>
                                    })}
                                    <details>
                                        <summary>{format!("Members ({})", c.members.len())}</summary>
                                        <div class="tablewrap">
                                            <table>
                                                <thead><tr>
                                                    <th scope="col">"Member"</th><th scope="col">"Part"</th>
                                                    <th scope="col">"State"</th><th scope="col">"Votes"</th><th scope="col">"Notes"</th>
                                                </tr></thead>
                                                <tbody>
                                                    {c.members.into_iter().map(|m| view! {
                                                        <tr>
                                                            <td class="wrap mono">{m.name}</td>
                                                            <td>{m.part}</td>
                                                            <td><Badge tone=m.tone label=m.state/></td>
                                                            <td>{if m.votes.is_empty() { "—".to_string() } else { m.votes }}</td>
                                                            <td class="wrap">{m.reason}{(!m.warnings.is_empty()).then(|| format!(" warning: {}", m.warnings))}</td>
                                                        </tr>
                                                    }).collect_view()}
                                                </tbody>
                                            </table>
                                        </div>
                                    </details>
                                </div>
                            }
                        }).collect_view()}
                    </div>
                }.into_any(),
            }}
        </div>
    }
}

#[component]
pub fn SecurityDashboard() -> impl IntoView {
    crate::webio::set_title("Security");
    let query = use_query_map();
    let queue = Memo::new(move |_| query.read().get("queue").unwrap_or_default());
    let view_of = LocalResource::new(move || {
        let q = queue.get();
        async move { sec::load_dashboard(&GrpcDoor, &q).await }
    });

    view! {
        <section>
            <h2>"Security"</h2>
            <p class="count">"How this Holger guards what comes in, and every artifact on its way in."</p>
            <SecuritySummary/>
            <DependencySets/>
            <h3>"Intake queues"</h3>
            {move || match view_of.get() {
                None => view! { <Loading what="the intake queues"/> }.into_any(),
                Some(Err(r)) => view! { <Refusal context="holger refused the intake queues" refusal=r/> }.into_any(),
                Some(Ok(v)) => view! {
                    <nav class="cards tiles" aria-label="Intake queues">
                        {v.tiles.into_iter().map(|t| {
                            let class = if t.active { "card tile tile-active" } else { "card tile" };
                            view! {
                                <a class=class href=t.href aria-current=t.active.then_some("page")>
                                    <span class="tile-title">{t.title}</span>
                                    <span class="tile-count">{t.count.to_string()}</span>
                                </a>
                            }
                        }).collect_view()}
                    </nav>
                    <div class="metaline">
                        <h3>{v.open}</h3>
                        <button class="quiet" on:click=move |_| view_of.refetch()>"Refresh"</button>
                        {v.truncated.then(|| view! { <span class="count">"more rows than shown"</span> })}
                    </div>
                    {if v.rows.is_empty() {
                        view! { <EmptyState message="nothing in this queue"/> }.into_any()
                    } else {
                        view! {
                            <div class="tablewrap">
                                <table>
                                    <thead>
                                        <tr>
                                            <th scope="col">"Artifact"</th>
                                            <th scope="col">"State"</th>
                                            <th scope="col">"Votes"</th>
                                            <th scope="col">"Imported by"</th>
                                            <th scope="col">"Last step"</th>
                                            <th scope="col">"sha256"</th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        {v.rows.into_iter().map(|r| view! {
                                            <tr>
                                                <td class="wrap"><a href=r.href>{r.label}</a>
                                                    {(!r.note.is_empty()).then(|| view! { <span class="count">{format!(" · {}", r.note)}</span> })}
                                                </td>
                                                <td><Badge tone=r.tone label=r.state/></td>
                                                <td>{if r.votes.is_empty() { "—".to_string() } else { r.votes }}</td>
                                                <td>{r.importer}</td>
                                                <td>{r.last}</td>
                                                <td class="mono">{r.sha256}</td>
                                            </tr>
                                        }).collect_view()}
                                    </tbody>
                                </table>
                            </div>
                        }.into_any()
                    }}
                }.into_any(),
            }}
        </section>
    }
}

#[component]
fn PaneBox(pane: Pane) -> impl IntoView {
    let empty = pane.lines.is_empty() && pane.scanners.is_empty();
    view! {
        <div class="card pane">
            <h3>
                {pane.title}
                {(!pane.verdict.is_empty()).then(|| view! { " " <Badge tone=pane.tone label=pane.verdict.clone()/> })}
            </h3>
            {empty.then(|| view! { <p class="pending">{pane.empty.clone()}</p> })}
            {(!pane.lines.is_empty()).then(|| view! {
                <dl class="facts facts-plain">
                    {pane.lines.into_iter().map(|l| view! {
                        <dt>{l.label}</dt><dd class="mono">{l.value}</dd>
                    }).collect_view()}
                </dl>
            })}
            {pane.scanners.into_iter().map(|s| view! {
                <div class="scanner">
                    <p class="cardline">
                        <Badge tone=s.tone label=s.verdict/>
                        <span class="mono">{s.scanner}</span>
                        {s.flags.into_iter().map(|f| view! { " " <span class="chip">{f}</span> }).collect_view()}
                    </p>
                    {(!s.detail.is_empty()).then(|| view! { <p class="count">{s.detail.clone()}</p> })}
                    {(!s.findings.is_empty()).then(|| view! {
                        <ul class="pathlist">
                            {s.findings.into_iter().map(|f| view! {
                                <li><span class="mono">{f.label}</span>" — "{f.value}</li>
                            }).collect_view()}
                        </ul>
                    })}
                </div>
            }).collect_view()}
        </div>
    }
}

#[component]
pub fn SecurityArtifact() -> impl IntoView {
    let query = use_query_map();
    let id = Memo::new(move |_| crate::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 |_| crate::webio::set_title(&sec::label(&id.get())));
    let view_of = LocalResource::new(move || {
        let id = id.get();
        async move { sec::load_artifact(&GrpcDoor, &id).await }
    });
    let reason = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    let pressed: RwSignal<Option<Pressed>> = RwSignal::new(None);
    let confirm_block = RwSignal::new(false);
    let saved: RwSignal<Option<String>> = RwSignal::new(None);

    let send = move |action: Action| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        pressed.set(None);
        let id = id.get_untracked();
        let why = reason.get_untracked();
        spawn_local(async move {
            let got = sec::press(&GrpcDoor, &id, action, &why).await;
            busy.set(false);
            confirm_block.set(false);
            pressed.set(Some(got));
            view_of.refetch();
        });
    };

    view! {
        <section>
            <p class="crumbs">
                <a href=sec::DASHBOARD>"security"</a>
                " / " <strong>{move || sec::label(&id.get())}</strong>
            </p>
            <h2>"Artifact security"</h2>
            {move || match view_of.get() {
                None => view! { <Loading what="the artifact's state and evidence"/> }.into_any(),
                Some(Err(r)) => view! { <Refusal context="holger refused the artifact's state" refusal=r/> }.into_any(),
                Some(Ok(v)) => {
                    let sbom = v.sbom.clone();
                    view! {
                        <dl class="facts">
                            <dt>"State"</dt><dd><Badge tone=v.tone label=v.state.clone()/></dd>
                            <dt>"Imported by"</dt><dd>{v.importer.clone()}</dd>
                            <dt>"Approvals"</dt><dd>
                                {v.votes.clone()}
                                {(!v.voters.is_empty()).then(|| view! {
                                    <span class="count">{format!(" · recorded: {}", v.voters.join(", "))}</span>
                                })}
                            </dd>
                        </dl>

                        <div class="toolbar actions">
                            <label class="vh" for="sec-reason">"Reason"</label>
                            <input id="sec-reason" type="text" placeholder="reason (optional)"
                                prop:value=move || reason.get()
                                on:input=move |ev| reason.set(event_target_value(&ev))/>
                            {v.actions.iter().copied().map(|a| {
                                let click = move |_| {
                                    if a.verb == crate::grpc::pb::IntakeVerb::Block {
                                        confirm_block.set(true);
                                    } else {
                                        send(a);
                                    }
                                };
                                view! { <button class=a.class on:click=click disabled=move || busy.get()>{a.label}</button> }
                            }).collect_view()}
                        </div>

                        {v.evidence_refused.clone().map(|r| view! {
                            <Refusal context="holger refused the evidence" refusal=r/>
                        })}
                        <div class="panes">
                            {v.panes.into_iter().map(|p| {
                                let is_sbom = p.title == "SBOM";
                                let file = sbom.clone();
                                view! {
                                    <div class="pane-wrap">
                                        <PaneBox pane=p/>
                                        {(is_sbom && file.is_some()).then(|| {
                                            let f = file.clone().unwrap();
                                            let save = move |_| {
                                                let r = crate::webio::save_bytes(&f.file_name, f.json.as_bytes(), "application/vnd.cyclonedx+json");
                                                saved.set(Some(match r { Ok(()) => format!("saved {}", f.file_name), Err(e) => format!("save failed: {e}") }));
                                            };
                                            view! {
                                                <p class="toolbar">
                                                    <button class="quiet" on:click=save>"Download SBOM"</button>
                                                    {move || saved.get().map(|s| view! { <span class="count" role="status">{s}</span> })}
                                                </p>
                                            }
                                        })}
                                    </div>
                                }
                            }).collect_view()}
                        </div>

                        <h3>"History"</h3>
                        {if v.history.is_empty() {
                            view! { <EmptyState message="no transitions recorded"/> }.into_any()
                        } else {
                            view! {
                                <div class="tablewrap">
                                    <table>
                                        <thead>
                                            <tr>
                                                <th scope="col">"When"</th>
                                                <th scope="col">"Step"</th>
                                                <th scope="col">"By"</th>
                                                <th scope="col">"Policy"</th>
                                                <th scope="col">"Evidence"</th>
                                            </tr>
                                        </thead>
                                        <tbody>
                                            {v.history.into_iter().map(|s| view! {
                                                <tr>
                                                    <td>{s.when}</td>
                                                    <td>{s.hop}{(!s.reason.is_empty()).then(|| view! { <span class="count">{format!(" · {}", s.reason)}</span> })}</td>
                                                    <td>{s.actor}</td>
                                                    <td class="wrap">{s.policy}</td>
                                                    <td class="wrap">{s.evidence.into_iter().map(|e| view! {
                                                        <span class="chip">{format!("{} {}", e.label, e.value)}</span>
                                                    }).collect_view()}</td>
                                                </tr>
                                            }).collect_view()}
                                        </tbody>
                                    </table>
                                </div>
                            }.into_any()
                        }}
                        <h3>"Trail"</h3>
                        {match v.trail.clone() {
                            None => view! { <EmptyState message="no bytes recorded, so no trail"/> }.into_any(),
                            Some(Err(r)) => view! { <Refusal context="holger refused the audit trail" refusal=r/> }.into_any(),
                            Some(Ok(t)) => view! {
                                <p class="cardline">
                                    <Badge tone=if t.intact { Tone::Good } else { Tone::Bad } label=if t.intact { "chain intact" } else { "chain broken" }/>
                                    <span class="count">{t.chain.clone()}</span>
                                </p>
                                {if t.entries.is_empty() {
                                    view! { <EmptyState message="the trail holds no entries"/> }.into_any()
                                } else {
                                    view! {
                                        <div class="tablewrap">
                                            <table>
                                                <thead>
                                                    <tr>
                                                        <th scope="col">"#"</th>
                                                        <th scope="col">"When"</th>
                                                        <th scope="col">"What"</th>
                                                        <th scope="col">"By"</th>
                                                        <th scope="col">"Repository"</th>
                                                        <th scope="col">"Detail"</th>
                                                        <th scope="col">"Hash"</th>
                                                    </tr>
                                                </thead>
                                                <tbody>
                                                    {t.entries.into_iter().map(|e| view! {
                                                        <tr>
                                                            <td>{e.seq.to_string()}</td>
                                                            <td>{e.when}</td>
                                                            <td>{e.kind}</td>
                                                            <td>{e.who}</td>
                                                            <td>{e.repo}</td>
                                                            <td class="wrap">{e.detail}</td>
                                                            <td class="mono">{e.hash}</td>
                                                        </tr>
                                                    }).collect_view()}
                                                </tbody>
                                            </table>
                                        </div>
                                    }.into_any()
                                }}
                            }.into_any(),
                        }}
                    }.into_any()
                }
            }}
            {move || pressed.get().map(|p| match p {
                Pressed::Done { message, state, votes } => view! {
                    <p class="banner" role="status">
                        <Badge tone=sec::state_tone(&state) label=state.clone()/>
                        {format!("{message} · {votes}")}
                    </p>
                }.into_any(),
                Pressed::Refused(r) => view! { <Refusal context="holger refused the step" refusal=r/> }.into_any(),
            })}
            <ConfirmDialog
                open=confirm_block
                title="Block this artifact"
                consequence="Blocked is terminal: holger never serves a blocked artifact again."
                confirm_label="Block"
                busy=busy
                on_confirm=Callback::new(move |()| send(sec::ACTIONS[3]))>
                <p class="mono">{move || sec::label(&id.get())}</p>
            </ConfirmDialog>
        </section>
    }
}