diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index d949045..02746bf 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -102,6 +102,7 @@ use crate::routes::{ flagforge_storage::models::Flag, flagforge_storage::models::FlagConfig, flagforge_storage::flags::ConfiguredFlag, + crate::routes::flags::FlagWithUsage, flagforge_storage::models::Segment, crate::routes::segments::SegmentWithUsage, flagforge_storage::models::Experiment, diff --git a/crates/api/src/routes/flags.rs b/crates/api/src/routes/flags.rs index 0d1f991..7ac548f 100644 --- a/crates/api/src/routes/flags.rs +++ b/crates/api/src/routes/flags.rs @@ -6,8 +6,8 @@ use axum::http::StatusCode; use flagforge_core::{Distribution, Rule, Variant}; use flagforge_storage::flags::ConfiguredFlag; use flagforge_storage::models::{Flag, FlagConfig, NewAuditEntry}; -use flagforge_storage::{audit, flags, projects, segments}; -use serde::Deserialize; +use flagforge_storage::{audit, experiments, flags, projects, segments}; +use serde::{Deserialize, Serialize}; use crate::auth::AuthUser; use crate::error::{ApiError, ApiResult}; @@ -111,6 +111,20 @@ pub async fn list( Ok(Json(flags::list_flags(&state.pool, project.id, query.include_archived).await?)) } +/// A flag together with the experiments measuring it — the same shape +/// [`crate::routes::segments::SegmentWithUsage`] gives a segment, and for the +/// same reason: the blast radius of an edit should be visible on the screen +/// where the edit happens, not discovered in a refusal afterwards. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct FlagWithUsage { + #[serde(flatten)] + pub flag: Flag, + /// Experiments whose arms are this flag's variants, as `environment/key`, + /// in any lifecycle state — a draft or stopped experiment still pins the + /// flag against deletion, and its counters still name these variants. + pub measured_by: Vec, +} + #[utoipa::path( get, path = "/api/v1/projects/{project_key}/flags/{flag_key}", tag = "flags", security(("bearer" = [])), @@ -119,7 +133,7 @@ pub async fn list( ("flag_key" = String, Path, description = "Flag key"), ), responses( - (status = 200, description = "The flag definition", body = Flag), + (status = 200, description = "The flag definition and what measures it", body = FlagWithUsage), (status = 404, description = "No such flag"), ) )] @@ -127,9 +141,11 @@ pub async fn get( State(state): State, caller: AuthUser, Path((project_key, flag_key)): Path<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let project = projects::find_project(&state.pool, caller.organization_id, &project_key).await?; - Ok(Json(flags::find_flag(&state.pool, project.id, &flag_key).await?)) + let flag = flags::find_flag(&state.pool, project.id, &flag_key).await?; + let measured_by = experiments::experiments_referencing_flag(&state.pool, flag.id).await?; + Ok(Json(FlagWithUsage { flag, measured_by })) } #[derive(Debug, Deserialize, utoipa::ToSchema)] diff --git a/crates/api/tests/experiments.rs b/crates/api/tests/experiments.rs index 26a9bdb..1cb670e 100644 --- a/crates/api/tests/experiments.rs +++ b/crates/api/tests/experiments.rs @@ -155,6 +155,14 @@ async fn an_experiment_pins_its_flag_against_deletion(pool: PgPool) { .await .expect(StatusCode::CREATED); + // The pin is visible on the flag itself before any refusal: the dashboard + // shows the blast radius on the screen where the edit happens. + let flag = app + .get("/api/v1/projects/checkout/flags/checkout.v2", Some(&tenant.token)) + .await + .expect(StatusCode::OK); + assert_eq!(flag["measured_by"], json!(["production/checkout-cta"]), "{flag}"); + let refused = app.delete("/api/v1/projects/checkout/flags/checkout.v2", Some(&tenant.token)).await; let problem = refused.expect(StatusCode::CONFLICT); @@ -167,6 +175,11 @@ async fn an_experiment_pins_its_flag_against_deletion(pool: PgPool) { app.delete(&format!("{EXPERIMENTS}/checkout-cta"), Some(&tenant.token)) .await .expect(StatusCode::NO_CONTENT); + let flag = app + .get("/api/v1/projects/checkout/flags/checkout.v2", Some(&tenant.token)) + .await + .expect(StatusCode::OK); + assert_eq!(flag["measured_by"], json!([]), "{flag}"); app.delete("/api/v1/projects/checkout/flags/checkout.v2", Some(&tenant.token)) .await .expect(StatusCode::NO_CONTENT); diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 39394c2..6b39428 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -149,6 +149,11 @@ pub mod models { pub variants: Vec, pub archived: bool, pub updated_at: String, + /// Experiments measuring this flag, as `environment/key`. Only the + /// single-flag endpoint sends it; defaulted so list responses — which + /// omit it — still deserialize. + #[serde(default)] + pub measured_by: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize)] diff --git a/crates/web/src/pages/flag.rs b/crates/web/src/pages/flag.rs index cff9f9e..151e26a 100644 --- a/crates/web/src/pages/flag.rs +++ b/crates/web/src/pages/flag.rs @@ -52,6 +52,9 @@ pub fn FlagDetail() -> impl IntoView { let environments = RwSignal::new(Load::>::Loading); let selected = RwSignal::new(Option::::None); let variants = RwSignal::new(Vec::::new()); + // Experiments measuring this flag, as `environment/key`. Shown before an + // edit rather than discovered in a refusal after one. + let measured_by = RwSignal::new(Vec::::new()); // The environment's audiences. Needed to resolve a rule's segment // reference — without them the preview would report every gated rule as // matching nobody while production said otherwise. @@ -113,6 +116,7 @@ pub fn FlagDetail() -> impl IntoView { match (definition, config) { (Ok(definition), Ok(config)) => { variants.set(definition.variants); + measured_by.set(definition.measured_by); let value = Draft { enabled: config.enabled, off_variant: config.off_variant, @@ -273,6 +277,19 @@ pub fn FlagDetail() -> impl IntoView { (Load::Ready(_), Some(_)) => { view! { <> + +
+
+ +
+ + "Measured by " + {move || measured_by.get().join(", ")} + ". Its variants are the experiment's arms — renaming one mid-run orphans its counters, and the flag cannot be deleted while an experiment references it." + +
+
+ @@ -303,6 +320,7 @@ pub fn FlagDetail() -> impl IntoView { @@ -1407,7 +1425,11 @@ fn Preview( /// stops a flag being served but keeps its history, while deleting removes it /// from every environment at once and cannot be undone. #[component] -fn DangerZone(project: Signal, flag_key: Signal) -> impl IntoView { +fn DangerZone( + project: Signal, + flag_key: Signal, + measured_by: Signal>, +) -> impl IntoView { let session = use_session(); let toaster = use_toaster(); let navigate = leptos_router::hooks::use_navigate(); @@ -1453,6 +1475,9 @@ fn DangerZone(project: Signal, flag_key: Signal) -> impl IntoVie }); }; + // Built once, outside the Show: callbacks are Copy, closures are not. + let delete_confirmed = Callback::new(delete); + view! {
@@ -1482,11 +1507,22 @@ fn DangerZone(project: Signal, flag_key: Signal) -> impl IntoVie "Removes it from every environment immediately. SDKs fall back to their own defaults."
- + + "Measured by an experiment, so it cannot be deleted yet." + + } + } + > + +
diff --git a/crates/web/styles/app.css b/crates/web/styles/app.css index 672473e..0f8ecf5 100644 --- a/crates/web/styles/app.css +++ b/crates/web/styles/app.css @@ -685,9 +685,12 @@ code { background-size: 15px; } +/* Muted rather than subtle: hints appear on sunken surfaces too (the + * preview panel, card footers), where subtle's 4.37:1 misses AA at this + * size. The hierarchy step below body text survives in the font size. */ .hint { font-size: 12px; - color: var(--text-subtle); + color: var(--text-muted); } .field-error { diff --git a/docs/screenshots/audit-log.png b/docs/screenshots/audit-log.png index dc8cdbb..c807253 100644 Binary files a/docs/screenshots/audit-log.png and b/docs/screenshots/audit-log.png differ diff --git a/docs/screenshots/experiments.png b/docs/screenshots/experiments.png index f5df003..e8d36c7 100644 Binary files a/docs/screenshots/experiments.png and b/docs/screenshots/experiments.png differ diff --git a/docs/screenshots/flag-editor.png b/docs/screenshots/flag-editor.png index 67d8d0e..5796538 100644 Binary files a/docs/screenshots/flag-editor.png and b/docs/screenshots/flag-editor.png differ diff --git a/docs/screenshots/flag-rules.png b/docs/screenshots/flag-rules.png index f1e8745..6ed222b 100644 Binary files a/docs/screenshots/flag-rules.png and b/docs/screenshots/flag-rules.png differ diff --git a/docs/screenshots/flags-dark.png b/docs/screenshots/flags-dark.png index 120b0a7..3f0d632 100644 Binary files a/docs/screenshots/flags-dark.png and b/docs/screenshots/flags-dark.png differ diff --git a/docs/screenshots/flags-light.png b/docs/screenshots/flags-light.png index 6386fa1..ad27d20 100644 Binary files a/docs/screenshots/flags-light.png and b/docs/screenshots/flags-light.png differ diff --git a/docs/screenshots/sdk-keys.png b/docs/screenshots/sdk-keys.png index f1efe99..c951e76 100644 Binary files a/docs/screenshots/sdk-keys.png and b/docs/screenshots/sdk-keys.png differ diff --git a/docs/screenshots/segments.png b/docs/screenshots/segments.png index fe5722d..7fad2b6 100644 Binary files a/docs/screenshots/segments.png and b/docs/screenshots/segments.png differ