{content} @@ -244,6 +356,206 @@ document.addEventListener('DOMContentLoaded',renderMermaid); )) } +// ── Variant banner + overview ───────────────────────────────────────────── + +/// Render the "Filtered to variant: X (N of M artifacts shown)" banner +/// injected above the main content when a variant filter is active. +/// +/// Returns an empty string when no variant is selected or when the +/// selected variant is unknown/unresolvable (so the user gets a clean +/// page; the `/variants` page surfaces the error explicitly). +fn render_variant_banner(state: &AppState, active_variant: Option<&str>) -> String { + let Some(name) = active_variant else { + return String::new(); + }; + if !state.variants.has_model() { + return "
\ + Variant filter ignored: this project has no feature model. \ + Clear filter\ +
" + .to_string(); + } + let total = state.store.len(); + match state.build_variant_scope(name) { + Ok(Some(scope)) => format!( + "
\ + \ + \ + Filtered to variant: {name} \ + ({count} of {total} artifacts shown, {feats} features effective)\ + Clear filter\ +
", + name = html_escape(&scope.name), + count = scope.artifact_count, + total = total, + feats = scope.feature_count, + clear_href = "?", + ), + Ok(None) => String::new(), + Err(msg) => format!( + "
\ + Variant error: {msg} \ + Clear filter\ +
", + msg = html_escape(&msg), + ), + } +} + +/// Render the `/variants` overview page: a table of every declared +/// variant with its validation status, feature count, and artifact +/// count. Each row links to the scoped view for that variant. +pub(crate) fn render_variants_overview(state: &AppState) -> String { + use super::variant::VariantStatus; + + if !state.variants.has_model() { + return String::from( + "
\ +

Variants

\ +

This project has no feature model. Run \ + rivet variant init to scaffold one, or place a \ + feature-model.yaml under artifacts/.

\ +

See the \ + variant documentation \ + for details.

\ +
", + ); + } + + let total = state.store.len(); + let mut html = String::from( + "
\ +

Variants

\ +

\ + Declared variants for this project's feature model. \ + Select one to scope the dashboard to just the artifacts bound to \ + its effective features.

", + ); + if let Some(ref p) = state.variants.model_path { + html.push_str(&format!( + "
\ + Feature model: {}
", + html_escape(&p.display().to_string()), + )); + } + + if state.variants.variants.is_empty() { + html.push_str( + "

No variant configurations discovered. \ + Add YAML files to artifacts/variants/.

", + ); + html.push_str("
"); + return html; + } + + html.push_str( + "\ + \ + \ + \ + \ + ", + ); + + for v in &state.variants.variants { + let status = state.variants.validation_status(&v.name); + let (status_label, status_style, feature_count, artifact_count) = match &status { + VariantStatus::Pass { + feature_count, + artifact_count, + } => ( + "PASS".to_string(), + "color:#065f46;background:#d1fae5;padding:.15rem .5rem;border-radius:4px;font-weight:700", + *feature_count as i64, + *artifact_count as i64, + ), + VariantStatus::Fail(_) => ( + "FAIL".to_string(), + "color:#7f1d1d;background:#fee2e2;padding:.15rem .5rem;border-radius:4px;font-weight:700", + -1, + -1, + ), + VariantStatus::Missing => ( + "missing".to_string(), + "color:#78350f;background:#fef3c7;padding:.15rem .5rem;border-radius:4px", + -1, + -1, + ), + VariantStatus::NoModel => ( + "no model".to_string(), + "color:var(--text-muted)", + -1, + -1, + ), + }; + let pct = if total > 0 && artifact_count > 0 { + format!("{:.1}%", (artifact_count as f64) * 100.0 / (total as f64)) + } else if artifact_count == 0 { + "0.0%".to_string() + } else { + "—".to_string() + }; + let feat_disp = if feature_count >= 0 { + feature_count.to_string() + } else { + "—".to_string() + }; + let art_disp = if artifact_count >= 0 { + artifact_count.to_string() + } else { + "—".to_string() + }; + + html.push_str(&format!( + "\ + \ + \ + \ + \ + \ + \ + ", + name = html_escape(&v.name), + v_enc = urlencoding::encode(&v.name), + )); + + if let VariantStatus::Fail(errs) = status { + let msg = errs.join("; "); + html.push_str(&format!( + "", + html_escape(&msg), + )); + } + } + html.push_str("
NameStatusFeaturesArtifacts% of totalActions
{name}{status_label}{feat_disp}{art_disp}{pct}\ + scope dashboard\ + coverage\ + artifacts\ +
{}
"); + html.push_str("