Skip to content

Commit 83e9cbb

Browse files
committed
feat(storage): persist app state across sessions
- What: Added new storage module (src/storage.rs) for saving/loading app state - What: State is saved to ~/.config/httpulse/state.json (cross-platform via dirs crate) - What: Added serde_json and dirs dependencies to Cargo.toml - What: Added Serialize/Deserialize derives to MetricKind, ProfileViewMode, TargetPaneMode, MetricsCategory - What: Added to_persisted_state() and restore_from_persisted() methods to AppState - What: Integrated state loading on startup in main.rs (CLI targets override persisted state) - What: Integrated state saving on quit and after state-changing actions (add/remove target, settings changes) - Why: Users reported that added URLs were lost after restarting the application Tests: cargo fmt, cargo clippy, cargo test all pass.
1 parent 94b3f25 commit 83e9cbb

File tree

11 files changed

+315
-11
lines changed

11 files changed

+315
-11
lines changed

Cargo.lock

Lines changed: 82 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ hdrhistogram = "7"
1616
libc = "0.2"
1717
ratatui = "0.30"
1818
serde = { version = "1", features = ["derive"] }
19+
serde_json = "1"
20+
dirs = "6"
1921
thiserror = "2"
2022
url = { version = "2", features = ["serde"] }
2123
uuid = { version = "1.19.0", features = ["v4", "serde"] }

src/features/app/state.rs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ use crate::metrics_aggregate::{MetricsStore, ProfileKey};
44
use crate::probe::{ProbeErrorKind, ProbeSample};
55
use crate::probe_engine::detect_tls13_support;
66
use crate::runtime::{ControlMessage, WorkerHandle, spawn_profile_worker};
7+
use serde::{Deserialize, Serialize};
78
use std::collections::{BTreeMap, HashSet};
89
use std::net::IpAddr;
910
use url::Url;
1011

11-
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
13+
#[serde(rename_all = "snake_case")]
1214
pub enum ProfileViewMode {
1315
Single,
1416
Compare,
1517
}
1618

17-
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
20+
#[serde(rename_all = "snake_case")]
1821
pub enum TargetPaneMode {
1922
Split,
2023
Chart,
@@ -23,7 +26,8 @@ pub enum TargetPaneMode {
2326
}
2427

2528
/// Metrics category for tab-based navigation
26-
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
29+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Serialize, Deserialize)]
30+
#[serde(rename_all = "snake_case")]
2731
pub enum MetricsCategory {
2832
#[default]
2933
Latency,
@@ -339,4 +343,71 @@ impl AppState {
339343
.sum();
340344
summary
341345
}
346+
347+
pub fn to_persisted_state(&self) -> crate::storage::PersistedState {
348+
crate::storage::PersistedState {
349+
version: "1".to_string(),
350+
global_config: self.global.clone(),
351+
targets: self
352+
.targets
353+
.iter()
354+
.map(|t| crate::storage::PersistedTarget {
355+
config: t.config.clone(),
356+
view_mode: t.view_mode,
357+
selected_profile: t.selected_profile,
358+
pane_mode: t.pane_mode,
359+
metrics_category: t.metrics_category,
360+
})
361+
.collect(),
362+
ui_state: crate::storage::PersistedUiState {
363+
selected_target: self.selected_target,
364+
selected_metric: self.selected_metric,
365+
selected_metrics: self.selected_metrics.clone(),
366+
window: self.window,
367+
},
368+
}
369+
}
370+
371+
pub fn restore_from_persisted(
372+
&mut self,
373+
state: &crate::storage::PersistedState,
374+
sample_tx: crossbeam_channel::Sender<crate::probe::ProbeSample>,
375+
) {
376+
for persisted_target in &state.targets {
377+
let profiles = persisted_target.config.profiles.clone();
378+
let mut profile_runtimes = Vec::new();
379+
for profile in &profiles {
380+
let worker = spawn_profile_worker(
381+
persisted_target.config.clone(),
382+
profile.clone(),
383+
sample_tx.clone(),
384+
);
385+
profile_runtimes.push(ProfileRuntime {
386+
config: profile.clone(),
387+
worker,
388+
last_sample: None,
389+
last_error: None,
390+
});
391+
}
392+
393+
self.targets.push(TargetRuntime {
394+
config: persisted_target.config.clone(),
395+
paused: false,
396+
last_ip: None,
397+
profiles: profile_runtimes,
398+
view_mode: persisted_target.view_mode,
399+
selected_profile: persisted_target.selected_profile,
400+
pane_mode: persisted_target.pane_mode,
401+
metrics_category: persisted_target.metrics_category,
402+
});
403+
}
404+
405+
self.selected_target = state
406+
.ui_state
407+
.selected_target
408+
.min(self.targets.len().saturating_sub(1));
409+
self.selected_metric = state.ui_state.selected_metric;
410+
self.selected_metrics = state.ui_state.selected_metrics.clone();
411+
self.window = state.ui_state.window;
412+
}
342413
}

src/features/metrics/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ pub mod aggregate;
22

33
use crate::config::{ProfileId, TargetId, WindowSpec};
44
use crate::probe::ProbeErrorKind;
5+
use serde::{Deserialize, Serialize};
56
use std::collections::HashMap;
67
use std::net::IpAddr;
78

8-
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
9+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
10+
#[serde(rename_all = "snake_case")]
911
pub enum MetricKind {
1012
Dns,
1113
Connect,

src/features/ui/input/add.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::app::{AppState, parse_profile_specs, parse_target_url};
22
use crate::probe::ProbeSample;
3+
use crate::storage;
34
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
45
use url::Url;
56

@@ -22,6 +23,7 @@ pub(in crate::features::ui) fn handle_input_key(
2223
InputMode::AddTarget => {
2324
if let Some((url, profiles)) = parse_add_command(input_buffer) {
2425
app.add_target(url, profiles, sample_tx.clone());
26+
let _ = storage::save(&app.to_persisted_state());
2527
}
2628
}
2729
InputMode::Normal

src/features/ui/input/confirm.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::app::AppState;
2+
use crate::storage;
23
use crossterm::event::{KeyCode, KeyEvent};
34

45
use super::super::state::InputMode;
@@ -11,6 +12,7 @@ pub(in crate::features::ui) fn handle_confirm_delete_key(
1112
match key.code {
1213
KeyCode::Char('y') | KeyCode::Char('Y') => {
1314
app.remove_target(app.selected_target);
15+
let _ = storage::save(&app.to_persisted_state());
1416
*input_mode = InputMode::Normal;
1517
}
1618
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {

src/features/ui/input/settings.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::app::{AppState, apply_edit_command};
2+
use crate::storage;
23
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
34

45
use super::super::render::{seed_settings_input, settings_rows};
@@ -36,6 +37,7 @@ pub(in crate::features::ui) fn handle_settings_key(
3637
let mut updated = target.config.clone();
3738
updated.dns_enabled = !updated.dns_enabled;
3839
app.update_target_config(app.selected_target, updated);
40+
let _ = storage::save(&app.to_persisted_state());
3941
}
4042
}
4143
SettingsField::TargetPane => {
@@ -127,6 +129,7 @@ pub(in crate::features::ui) fn handle_settings_edit_key(
127129
}
128130

129131
if applied {
132+
let _ = storage::save(&app.to_persisted_state());
130133
*input_mode = InputMode::Settings;
131134
input_buffer.clear();
132135
}

src/features/ui/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod state;
44

55
use crate::app::AppState;
66
use crate::probe::ProbeSample;
7+
use crate::storage;
78
use crossterm::event::{self, Event};
89
use crossterm::terminal::{
910
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -184,6 +185,7 @@ pub fn run_ui(
184185
}
185186
}
186187

188+
let _ = storage::save(&app.to_persisted_state());
187189
cleanup_terminal(&mut terminal)?;
188190
Ok(())
189191
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ pub mod probe;
1010
pub mod probe_engine;
1111
pub mod runtime;
1212
pub mod settings;
13+
pub mod storage;
1314
pub mod ui;

src/main.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
use httpulse::app::{AppState, parse_target_url};
2-
use httpulse::config::GlobalConfig;
32
use httpulse::settings::{apply_global, load_from_cli};
3+
use httpulse::storage;
44
use httpulse::ui::run_ui;
55

66
fn main() -> std::io::Result<()> {
77
let settings = load_from_cli()
88
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err.to_string()))?;
9-
let mut global = GlobalConfig::default();
9+
10+
let persisted = storage::load();
11+
12+
let mut global = persisted.global_config.clone();
1013
apply_global(&settings, &mut global);
1114

1215
let (sample_tx, sample_rx) = crossbeam_channel::unbounded();
1316
let mut app = AppState::new(global);
1417

15-
for target in settings.targets {
16-
if let Some(url) = parse_target_url(&target) {
17-
app.add_target(url, None, sample_tx.clone());
18+
let is_default_target =
19+
settings.targets.len() == 1 && settings.targets[0] == "https://google.com";
20+
let has_cli_targets = !settings.targets.is_empty() && !is_default_target;
21+
22+
if has_cli_targets {
23+
for target in settings.targets {
24+
if let Some(url) = parse_target_url(&target) {
25+
app.add_target(url, None, sample_tx.clone());
26+
}
27+
}
28+
} else if !persisted.targets.is_empty() {
29+
app.restore_from_persisted(&persisted, sample_tx.clone());
30+
} else {
31+
for target in settings.targets {
32+
if let Some(url) = parse_target_url(&target) {
33+
app.add_target(url, None, sample_tx.clone());
34+
}
1835
}
1936
}
2037

0 commit comments

Comments
 (0)