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:
co-authored by
Claude Sonnet 5
parent
e6e5bc8f50
commit
56da086c7a
+146
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user