Allow editing an existing metric declaration in place (ticket 022)

Clicking a declared metric badge in Metrics.tsx now loads its regex/params
into the declaration form for editing (in addition to selecting it for the
bucket-values view), and shows those properties inline once selected rather
than only on hover. The backend gains PUT /api/metrics/{name}, backed by a
new engine::update_metric that reuses declare_metric's validation, requires
the metric to already exist, supports renaming, and re-backfills bucket data
from the new regex/params instead of leaving stale buckets from the old one.
Submitting the form while editing calls the update endpoint in place rather
than creating a second declaration; declaring a brand-new metric is unchanged.

Note: tickets/022-edit-existing-metric-declarations.md does not exist on this
worktree's branch (it was added to tickets/ on main after this branch was
created), so there is nothing to delete here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 19:38:00 +01:00
co-authored by Claude Sonnet 5
parent e6e5bc8f50
commit 56da086c7a
5 changed files with 467 additions and 21 deletions
+146 -1
View File
@@ -69,6 +69,22 @@ impl ApiState {
}
}
/// Same as `persist_metric_declaration`, for an in-place edit (`PUT /api/metrics/{name}`) — also drops the persisted record under `old_name` when the edit renames the metric, so a rename doesn't leave a stale duplicate declaration in the profile file.
fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
let Some((store_path, profile_name)) = self.active_profile_location() else {
return;
};
let mut store = ProfileStore::load(&store_path);
if let Err(err) =
store.record_metric_declaration_update(&store_path, &profile_name, old_name, request)
{
eprintln!(
"warning: failed to persist metric declaration update '{}' into profile '{profile_name}': {err}",
request.name
);
}
}
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
let settings = self.settings.lock().unwrap();
let profile_name = settings.profiles.active_profile.clone()?;
@@ -90,7 +106,10 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat
.route("/api/stats", get(get_stats))
.route("/api/params", get(list_params).post(declare_param))
.route("/api/metrics", get(list_metrics).post(declare_metric))
.route("/api/metrics/{name}", get(get_metric_bucket_values))
.route(
"/api/metrics/{name}",
get(get_metric_bucket_values).put(update_metric),
)
.route("/api/query", get(run_promql_query))
.route("/api/settings", get(get_settings).patch(update_settings))
.route("/api/settings/css", get(list_css).post(import_css))
@@ -271,6 +290,39 @@ async fn declare_metric(
}
}
/// Updates the metric declaration named by the URL path in place, reusing `declare_metric`'s validation (via `engine::update_metric`) rather than creating a second declaration or erroring on a duplicate-name conflict. `body.name` may differ from the path segment to rename the metric, so long as no other declaration already uses the new name.
async fn update_metric(
State(state): State<ApiState>,
AxumPath(name): AxumPath<String>,
Json(body): Json<DeclareMetricRequest>,
) -> impl IntoResponse {
let (respond_to, response) = oneshot::channel();
let request = body.clone();
if state
.engine
.send(EngineMessage::UpdateMetric { name: name.clone(), request: body, respond_to })
.await
.is_err()
{
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
match response.await {
Ok(Ok(())) => {
state.persist_metric_declaration_update(&name, &request);
let dto = MetricDeclarationDto {
name: request.name,
message_match_regex: request.message_match_regex,
params: request.params,
matched_labels: request.matched_labels,
unmatched_labels: request.unmatched_labels,
};
(StatusCode::OK, Json(dto)).into_response()
}
Ok(Err(err)) => (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response(),
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
}
}
/// Malformed pairs (no `=`) are skipped rather than rejected.
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
raw.split(',')
@@ -905,6 +957,99 @@ mod tests {
assert!(body["error"].as_str().unwrap().contains("duration"));
}
#[tokio::test]
async fn updates_metric_declaration_in_place_rather_than_duplicating() {
let (app, _engine, _settings, _dir) = test_app();
send_json(
&app,
"POST",
"/api/metrics",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"params": [
{ "param_name": "path", "capture_group": "path", "kind": "string" }
]
}),
)
.await;
let (status, body) = send_json(
&app,
"PUT",
"/api/metrics/requests",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
"params": [
{ "param_name": "path", "capture_group": "path", "kind": "string" },
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
]
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["name"], "requests");
assert_eq!(body["params"].as_array().unwrap().len(), 2);
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
let (status, body) = get_json(&app, "/api/metrics").await;
assert_eq!(status, StatusCode::OK);
let metrics = body.as_array().unwrap();
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0]["params"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn update_metric_rejects_unknown_metric_name() {
let (app, _engine, _settings, _dir) = test_app();
let (status, body) = send_json(
&app,
"PUT",
"/api/metrics/does-not-exist",
json!({
"name": "does-not-exist",
"message_match_regex": r"request path=(?P<path>\S+)",
"params": []
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().unwrap().contains("does-not-exist"));
}
#[tokio::test]
async fn update_metric_reuses_declare_metric_validation() {
let (app, _engine, _settings, _dir) = test_app();
send_json(
&app,
"POST",
"/api/metrics",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"params": []
}),
)
.await;
let (status, body) = send_json(
&app,
"PUT",
"/api/metrics/requests",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"params": [
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
]
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().unwrap().contains("duration"));
}
#[tokio::test]
async fn queries_bucket_values_with_label_filter_and_range() {
let (app, engine, _settings, _dir) = test_app();