Inline profile storage into settings.json (ticket 049)
Fold ProfileStore's separate profiles.json into Settings itself, so settings.json is the single source of truth instead of two files kept in sync by hand. Settings now carries `profiles: Vec<Profile>` and `active_profile: Option<String>` directly; every former ProfileStore mutation (create, delete, set_active, record_param_declaration, record_metric_declaration, dashboard CRUD, ensure_default) is now a method on Settings that persists via Settings::save. A custom Deserialize (via SettingsOnDisk) keeps an old settings.json (with the removed Profiles pointer struct) loading cleanly, and Settings::load runs a one-time migration: if profiles is empty and a legacy <settings_dir>/profiles/profiles.json exists, its contents are merged in, settings.json is resaved, and the old file is renamed so it isn't re-imported. Updated all call sites in api.rs and main.rs to the single-path API, and ported profile.rs's ProfileStore tests to exercise Settings directly. 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
85fb145be7
commit
e8baf2b004
+38
-81
@@ -13,7 +13,7 @@ use crate::engine::{
|
||||
EngineResponse,
|
||||
};
|
||||
use crate::alerts::{deliver_ntfy, AlertError};
|
||||
use crate::profile::{self, Profile, ProfileError, ProfileStore};
|
||||
use crate::profile::{self, Dashboard, 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
|
||||
@@ -57,11 +57,11 @@ impl ApiState {
|
||||
|
||||
/// Same as `persist_param_declaration`, for metric declarations.
|
||||
fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
|
||||
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_metric_declaration(&store_path, &profile_name, request) {
|
||||
if let Err(err) = settings.record_metric_declaration(&self.settings_path, &profile_name, request) {
|
||||
eprintln!(
|
||||
"warning: failed to persist metric declaration '{}' into profile '{profile_name}': {err}",
|
||||
request.name
|
||||
@@ -71,26 +71,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 {
|
||||
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_metric_declaration_update(&store_path, &profile_name, old_name, request)
|
||||
{
|
||||
if let Err(err) = settings.record_metric_declaration_update(
|
||||
&self.settings_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()?;
|
||||
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 {
|
||||
@@ -560,9 +556,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()
|
||||
}
|
||||
|
||||
@@ -571,12 +565,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),
|
||||
@@ -589,10 +580,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),
|
||||
}
|
||||
@@ -637,11 +625,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),
|
||||
}
|
||||
};
|
||||
@@ -663,9 +648,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()
|
||||
@@ -679,11 +662,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),
|
||||
}
|
||||
@@ -694,9 +674,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),
|
||||
@@ -710,16 +688,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),
|
||||
}
|
||||
@@ -729,11 +704,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),
|
||||
}
|
||||
@@ -1158,20 +1130,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(
|
||||
@@ -1188,8 +1150,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");
|
||||
}
|
||||
@@ -1271,7 +1233,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);
|
||||
@@ -1298,15 +1260,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