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