Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 21 additions & 5 deletions crates/api/src/routes/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<String>,
}

#[utoipa::path(
get, path = "/api/v1/projects/{project_key}/flags/{flag_key}", tag = "flags",
security(("bearer" = [])),
Expand All @@ -119,17 +133,19 @@ 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"),
)
)]
pub async fn get(
State(state): State<AppState>,
caller: AuthUser,
Path((project_key, flag_key)): Path<(String, String)>,
) -> ApiResult<Json<Flag>> {
) -> ApiResult<Json<FlagWithUsage>> {
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)]
Expand Down
13 changes: 13 additions & 0 deletions crates/api/tests/experiments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions crates/web/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ pub mod models {
pub variants: Vec<Variant>,
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<String>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
Expand Down
48 changes: 42 additions & 6 deletions crates/web/src/pages/flag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub fn FlagDetail() -> impl IntoView {
let environments = RwSignal::new(Load::<Vec<Environment>>::Loading);
let selected = RwSignal::new(Option::<String>::None);
let variants = RwSignal::new(Vec::<Variant>::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::<String>::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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -273,6 +277,19 @@ pub fn FlagDetail() -> impl IntoView {
(Load::Ready(_), Some(_)) => {
view! {
<>
<Show when=move || !measured_by.get().is_empty()>
<div class="callout callout--accent">
<div style="width:16px;height:16px;flex:none">
<Icon name="flask" />
</div>
<span>
"Measured by "
<strong>{move || measured_by.get().join(", ")}</strong>
". 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."
</span>
</div>
</Show>

<Show when=move || !issues.get().is_empty()>
<IssueList issues=Signal::derive(move || issues.get()) />
</Show>
Expand Down Expand Up @@ -303,6 +320,7 @@ pub fn FlagDetail() -> impl IntoView {
<DangerZone
project=Signal::derive(project_key)
flag_key=Signal::derive(flag_key)
measured_by=Signal::derive(move || measured_by.get())
/>
</Show>
</>
Expand Down Expand Up @@ -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<String>, flag_key: Signal<String>) -> impl IntoView {
fn DangerZone(
project: Signal<String>,
flag_key: Signal<String>,
measured_by: Signal<Vec<String>>,
) -> impl IntoView {
let session = use_session();
let toaster = use_toaster();
let navigate = leptos_router::hooks::use_navigate();
Expand Down Expand Up @@ -1453,6 +1475,9 @@ fn DangerZone(project: Signal<String>, flag_key: Signal<String>) -> impl IntoVie
});
};

// Built once, outside the Show: callbacks are Copy, closures are not.
let delete_confirmed = Callback::new(delete);

view! {
<div class="card">
<div class="card__header">
Expand Down Expand Up @@ -1482,11 +1507,22 @@ fn DangerZone(project: Signal<String>, flag_key: Signal<String>) -> impl IntoVie
"Removes it from every environment immediately. SDKs fall back to their own defaults."
</span>
</div>
<ConfirmButton
label="Delete"
confirm_label="Delete permanently"
on_confirm=Callback::new(delete)
/>
<Show
when=move || measured_by.get().is_empty()
fallback=|| {
view! {
<span class="cell-secondary">
"Measured by an experiment, so it cannot be deleted yet."
</span>
}
}
>
<ConfirmButton
label="Delete"
confirm_label="Delete permanently"
on_confirm=delete_confirmed
/>
</Show>
</div>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion crates/web/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Binary file modified docs/screenshots/audit-log.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/experiments.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/flag-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/flag-rules.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/flags-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/flags-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/sdk-keys.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/segments.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading