Merge ticket 049 (inline profiles into settings.json) with auto-derived-param promotion
worktree-agent-a19b9ef959dcda6b2 (ticket 049) was branched from before "Auto
derived format" (1a481d3) landed on main, so its synchronous
persist_metric_declaration(_update) and ProfileStore::record_metric_declaration(_update)
didn't know about promoting auto-derived engine params into the persisted
profile. This merge reconciles the two:
- src/api.rs: kept the async persist_metric_declaration/_update methods and
promoted_param_records helper from "Auto derived format", but switched their
persistence from ProfileStore::load/record_metric_declaration to the new
Settings-based settings.record_metric_declaration(_update), matching ticket
049's single-settings.json model. Dropped the now-unused
active_profile_location helper in favor of locking self.settings directly.
- src/profile.rs: removed the standalone ProfileStore struct/impl entirely
(049 folds profiles into Settings), keeping load_legacy_profile_store for
migrating a pre-049 profiles.json. Extended record_metric_declaration and
record_metric_declaration_update (now methods on Settings) to keep taking
promoted_params: &[ParamDeclarationRecord], persisting promoted auto-derived
params alongside the metric via Settings::save. Moved the ticket 037/038
legacy-shape migration (migrate_legacy_metric_params /
migrate_duration_value_params) out of the removed ProfileStore::load into a
new pub(crate) migrate_legacy_profile_shapes(profiles), invoked from
Settings::load (via a new Settings::migrate_legacy_profile_shapes) so
profiles inlined in settings.json still get migrated forward on every load.
Rewrote the ProfileStore-based tests (legacy-shape migration tests, the
no-op-migration test, and the auto-derived-param promotion round-trip test)
to exercise Settings directly, including a write_legacy_settings_json helper
that replaces the old write_legacy_profiles_json.
- src/api.rs tests: rewrote declaring_metric_against_auto_derived_param_promotes_it_into_the_profile
to create/activate the profile via settings_handle.lock() and reload via
Settings::load instead of ProfileStore.
Also fixed one clippy::collapsible_if warning introduced by the new
Settings::migrate_legacy_profile_shapes.
cargo build, cargo test (222 passed, 0 failed, 0 ignored), and
cargo clippy --all-targets all pass; no new clippy warnings beyond
pre-existing ones in files untouched by this merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
+61
-100
@@ -13,7 +13,7 @@ use crate::engine::{
|
||||
EngineResponse,
|
||||
};
|
||||
use crate::alerts::{deliver_ntfy, AlertError};
|
||||
use crate::profile::{self, ParamDeclarationRecord, Profile, ProfileError, ProfileStore};
|
||||
use crate::profile::{self, Dashboard, ParamDeclarationRecord, Profile, ProfileError};
|
||||
use crate::settings::{CssError, SettingsHandle};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
@@ -43,11 +43,11 @@ impl ApiState {
|
||||
|
||||
/// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
|
||||
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
|
||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
||||
let mut settings = self.settings.lock().unwrap();
|
||||
let Some(profile_name) = settings.active_profile.clone() else {
|
||||
return;
|
||||
};
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
if let Err(err) = store.record_param_declaration(&store_path, &profile_name, request) {
|
||||
if let Err(err) = settings.record_param_declaration(&self.settings_path, &profile_name, request) {
|
||||
eprintln!(
|
||||
"warning: failed to persist param declaration '{}' into profile '{profile_name}': {err}",
|
||||
request.name
|
||||
@@ -61,15 +61,25 @@ impl ApiState {
|
||||
/// auto-detected logfmt key survives a restart instead of being rejected as "undeclared" once
|
||||
/// declarations replay before any log line re-derives it.
|
||||
async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
|
||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
||||
return;
|
||||
let profile_name = {
|
||||
let settings = self.settings.lock().unwrap();
|
||||
let Some(profile_name) = settings.active_profile.clone() else {
|
||||
return;
|
||||
};
|
||||
profile_name
|
||||
};
|
||||
let existing_params = {
|
||||
let settings = self.settings.lock().unwrap();
|
||||
settings.profile(&profile_name).map(|p| p.params.clone()).unwrap_or_default()
|
||||
};
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
let existing_params = store.get(&profile_name).map(|p| p.params.clone()).unwrap_or_default();
|
||||
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
|
||||
if let Err(err) =
|
||||
store.record_metric_declaration(&store_path, &profile_name, request, &promoted)
|
||||
{
|
||||
let mut settings = self.settings.lock().unwrap();
|
||||
if let Err(err) = settings.record_metric_declaration(
|
||||
&self.settings_path,
|
||||
&profile_name,
|
||||
request,
|
||||
&promoted,
|
||||
) {
|
||||
eprintln!(
|
||||
"warning: failed to persist metric declaration '{}' into profile '{profile_name}': {err}",
|
||||
request.name
|
||||
@@ -79,14 +89,21 @@ 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.
|
||||
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
|
||||
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
||||
return;
|
||||
let profile_name = {
|
||||
let settings = self.settings.lock().unwrap();
|
||||
let Some(profile_name) = settings.active_profile.clone() else {
|
||||
return;
|
||||
};
|
||||
profile_name
|
||||
};
|
||||
let existing_params = {
|
||||
let settings = self.settings.lock().unwrap();
|
||||
settings.profile(&profile_name).map(|p| p.params.clone()).unwrap_or_default()
|
||||
};
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
let existing_params = store.get(&profile_name).map(|p| p.params.clone()).unwrap_or_default();
|
||||
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
|
||||
if let Err(err) = store.record_metric_declaration_update(
|
||||
&store_path,
|
||||
let mut settings = self.settings.lock().unwrap();
|
||||
if let Err(err) = settings.record_metric_declaration_update(
|
||||
&self.settings_path,
|
||||
&profile_name,
|
||||
old_name,
|
||||
request,
|
||||
@@ -98,13 +115,6 @@ impl ApiState {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
|
||||
let settings = self.settings.lock().unwrap();
|
||||
let profile_name = settings.profiles.active_profile.clone()?;
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &self.settings_path);
|
||||
Some((profile::profiles_store_path(&profiles_dir), profile_name))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
|
||||
@@ -621,9 +631,7 @@ fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
||||
|
||||
async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
let summaries: Vec<ProfileSummaryDto> = store.list().iter().map(ProfileSummaryDto::from).collect();
|
||||
let summaries: Vec<ProfileSummaryDto> = settings.profiles.iter().map(ProfileSummaryDto::from).collect();
|
||||
Json(summaries).into_response()
|
||||
}
|
||||
|
||||
@@ -632,12 +640,9 @@ async fn create_profile(
|
||||
Json(body): Json<CreateProfileRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let mut settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.create(&store_path, &mut settings, &state.settings_path, body.name.clone()) {
|
||||
match settings.create_profile(&state.settings_path, body.name.clone()) {
|
||||
Ok(()) => {
|
||||
let profile = store.get(&body.name).expect("profile was just created by store.create");
|
||||
let profile = settings.profile(&body.name).expect("profile was just created by create_profile");
|
||||
(StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response()
|
||||
}
|
||||
Err(err) => profile_error_response(err),
|
||||
@@ -650,10 +655,7 @@ async fn delete_profile(
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
let mut settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.delete(&store_path, &mut settings, &state.settings_path, &name) {
|
||||
match settings.delete_profile(&state.settings_path, &name) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
@@ -701,11 +703,8 @@ async fn activate_profile(
|
||||
) -> impl IntoResponse {
|
||||
let activated_profile = {
|
||||
let mut settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let store = ProfileStore::load(&store_path);
|
||||
match store.set_active(&mut settings, &state.settings_path, Some(name.clone())) {
|
||||
Ok(()) => Ok(store.get(&name).cloned()),
|
||||
match settings.set_active_profile(&state.settings_path, Some(name.clone())) {
|
||||
Ok(()) => Ok(settings.profile(&name).cloned()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
};
|
||||
@@ -727,9 +726,7 @@ async fn list_dashboards(
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.list_dashboards(&name) {
|
||||
match settings.list_dashboards(&name) {
|
||||
Ok(dashboards) => {
|
||||
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
|
||||
Json(dtos).into_response()
|
||||
@@ -743,11 +740,8 @@ async fn create_dashboard(
|
||||
AxumPath(name): AxumPath<String>,
|
||||
Json(body): Json<CreateDashboardRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.create_dashboard(&store_path, &name, body.name) {
|
||||
let mut settings = state.settings.lock().unwrap();
|
||||
match settings.create_dashboard(&state.settings_path, &name, body.name) {
|
||||
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
@@ -758,9 +752,7 @@ async fn get_dashboard(
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
||||
match store.list_dashboards(&name) {
|
||||
match settings.list_dashboards(&name) {
|
||||
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
|
||||
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
|
||||
None => profile_error_response(ProfileError::NotFound),
|
||||
@@ -774,16 +766,13 @@ async fn save_dashboard(
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
Json(body): Json<SaveDashboardRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
let dashboard = crate::profile::Dashboard {
|
||||
let mut settings = state.settings.lock().unwrap();
|
||||
let dashboard = Dashboard {
|
||||
id: dashboard_id.clone(),
|
||||
name: body.name,
|
||||
panels: body.panels.iter().map(|p| p.into()).collect(),
|
||||
};
|
||||
match store.save_dashboard(&store_path, &name, &dashboard_id, dashboard) {
|
||||
match settings.save_dashboard(&state.settings_path, &name, &dashboard_id, dashboard) {
|
||||
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
@@ -793,11 +782,8 @@ async fn delete_dashboard(
|
||||
State(state): State<ApiState>,
|
||||
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = state.settings.lock().unwrap();
|
||||
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
match store.delete_dashboard(&store_path, &name, &dashboard_id) {
|
||||
let mut settings = state.settings.lock().unwrap();
|
||||
match settings.delete_dashboard(&state.settings_path, &name, &dashboard_id) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => profile_error_response(err),
|
||||
}
|
||||
@@ -1231,20 +1217,10 @@ mod tests {
|
||||
|
||||
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let profiles_dir = {
|
||||
let settings = settings_handle.lock().unwrap();
|
||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
||||
};
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
{
|
||||
let mut settings = settings_handle.lock().unwrap();
|
||||
store
|
||||
.create(&store_path, &mut settings, &settings_path, "web".to_string())
|
||||
.unwrap();
|
||||
store
|
||||
.set_active(&mut settings, &settings_path, Some("web".to_string()))
|
||||
.unwrap();
|
||||
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||
}
|
||||
|
||||
let (status, _body) = send_json(
|
||||
@@ -1261,8 +1237,8 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
|
||||
let reloaded_store = ProfileStore::load(&store_path);
|
||||
let profile = reloaded_store.get("web").unwrap();
|
||||
let reloaded_settings = Settings::load(&settings_path);
|
||||
let profile = reloaded_settings.profile("web").unwrap();
|
||||
assert_eq!(profile.params.len(), 1);
|
||||
assert_eq!(profile.params[0].name, "level");
|
||||
}
|
||||
@@ -1277,20 +1253,10 @@ mod tests {
|
||||
let (app, engine, settings_handle, dir) = test_app();
|
||||
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let profiles_dir = {
|
||||
let settings = settings_handle.lock().unwrap();
|
||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
||||
};
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let mut store = ProfileStore::load(&store_path);
|
||||
{
|
||||
let mut settings = settings_handle.lock().unwrap();
|
||||
store
|
||||
.create(&store_path, &mut settings, &settings_path, "web".to_string())
|
||||
.unwrap();
|
||||
store
|
||||
.set_active(&mut settings, &settings_path, Some("web".to_string()))
|
||||
.unwrap();
|
||||
settings.create_profile(&settings_path, "web".to_string()).unwrap();
|
||||
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
|
||||
}
|
||||
|
||||
// Ingest a line carrying a logfmt key before any param is manually declared, so "dx" only
|
||||
@@ -1310,8 +1276,8 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
|
||||
let reloaded_store = ProfileStore::load(&store_path);
|
||||
let profile = reloaded_store.get("web").unwrap();
|
||||
let reloaded_settings = Settings::load(&settings_path);
|
||||
let profile = reloaded_settings.profile("web").unwrap();
|
||||
assert_eq!(profile.metrics.len(), 1);
|
||||
assert_eq!(profile.metrics[0].name, "direction_x");
|
||||
// The auto-derived "dx" param got promoted into the profile alongside the metric.
|
||||
@@ -1413,7 +1379,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_dashboard_persists_across_simulated_restart() {
|
||||
let (app, _engine, settings_handle, dir) = test_app();
|
||||
let (app, _engine, _settings_handle, dir) = test_app();
|
||||
|
||||
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
@@ -1440,15 +1406,10 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
|
||||
// Simulate a restart by reloading settings fresh from disk, independent of the running app/engine.
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let profiles_dir = {
|
||||
let settings = settings_handle.lock().unwrap();
|
||||
profile::resolve_profiles_dir(&settings, &settings_path)
|
||||
};
|
||||
let store_path = profile::profiles_store_path(&profiles_dir);
|
||||
let reloaded_store = ProfileStore::load(&store_path);
|
||||
let dashboards = reloaded_store.list_dashboards("web").unwrap();
|
||||
let reloaded_settings = Settings::load(&settings_path);
|
||||
let dashboards = reloaded_settings.list_dashboards("web").unwrap();
|
||||
assert_eq!(dashboards.len(), 1);
|
||||
assert_eq!(dashboards[0].name, "Overview");
|
||||
assert_eq!(dashboards[0].panels.len(), 1);
|
||||
|
||||
Reference in New Issue
Block a user