@@ -6,25 +6,49 @@ import 'package:shared_preferences/shared_preferences.dart';
66
77import 'package:sentracore_dashboard/models/history_sample.dart' ;
88import 'package:sentracore_dashboard/models/system_state.dart' ;
9-
9+ import 'package:sentracore_dashboard/services/engine_service.dart' ;
10+
11+ /// History samples ultimately live with the engine (persisted under
12+ /// ``history/` ` next to the rest of its datastore). This provider:
13+ ///
14+ /// * Pulls authoritative samples from the engine via `/api/v1/history` .
15+ /// * Keeps a small `SharedPreferences` mirror so charts still render when the
16+ /// engine is offline (e.g. during the first second of dashboard startup or
17+ /// while the engine restarts).
18+ /// * Records live samples opportunistically — the data is rolled into the
19+ /// offline mirror but the engine remains the source of truth across
20+ /// restarts.
1021class HistoryProvider extends ChangeNotifier {
11- static const _kHistorySamples = 'history_samples_v1 ' ;
22+ static const _kHistorySamples = 'history_samples_v2 ' ;
1223
13- /// Default sampling cadence (engine pushes every ~2s; we downsample to keep storage sane ).
24+ /// Local downsample cadence (engine pushes every ~2s).
1425 static const Duration sampleInterval = Duration (seconds: 30 );
1526
16- /// Hard cap to prevent unbounded growth in SharedPreferences.
17- static const int maxSamples =
18- 12000 ; // ~4 days at 30s; UI ranges use downsampling
27+ /// Hard cap for the offline mirror; the server-side archive holds the rest.
28+ static const int maxSamples = 12000 ;
29+
30+ /// How often we re-fetch from the engine while it is connected.
31+ static const Duration _refreshInterval = Duration (minutes: 1 );
32+
33+ /// How far back we ask the engine for on each refresh.
34+ static const Duration _refreshWindow = Duration (days: 7 );
1935
2036 final List <HistorySample > _samples = [];
2137 bool _loaded = false ;
2238 DateTime ? _lastSampleAt;
2339 Timer ? _saveDebounce;
2440
41+ /// When `true` , _samples reflects data pulled from the engine and is the
42+ /// source of truth. Local appends from [recordIfDue] still augment it
43+ /// between server refreshes.
44+ bool _syncedFromEngine = false ;
45+
2546 List <HistorySample > get samples => List <HistorySample >.unmodifiable (_samples);
2647 bool get loaded => _loaded;
2748
49+ /// True once we've populated samples from the engine since launch.
50+ bool get syncedFromEngine => _syncedFromEngine;
51+
2852 Future <void > load () async {
2953 if (_loaded) return ;
3054 final p = await SharedPreferences .getInstance ();
@@ -58,10 +82,98 @@ class HistoryProvider extends ChangeNotifier {
5882 void clear () {
5983 _samples.clear ();
6084 _lastSampleAt = null ;
85+ _syncedFromEngine = false ;
6186 notifyListeners ();
6287 _scheduleSave ();
6388 }
6489
90+ /// Pull the persistent server-side archive and replace local samples with
91+ /// it. Falls back silently if the engine is unreachable so we keep showing
92+ /// the offline mirror.
93+ Future <void > refreshFromEngine (EngineService service,
94+ {Duration ? window}) async {
95+ final now = DateTime .now ();
96+ final from = now.subtract (window ?? _refreshWindow);
97+ final raw = await service.getHistory (
98+ from: from,
99+ to: now,
100+ // Engine collects at 30s spacing; granularity matches so we don't waste
101+ // bytes on near-duplicate samples for long ranges.
102+ granularitySec: 30.0 ,
103+ limit: maxSamples,
104+ );
105+ if (raw.isEmpty) return ;
106+
107+ final parsed = < HistorySample > [];
108+ for (final m in raw) {
109+ final at = m['at' ];
110+ DateTime when ;
111+ if (at is num ) {
112+ when = DateTime .fromMillisecondsSinceEpoch (
113+ (at.toDouble () * 1000 ).round (),
114+ );
115+ } else {
116+ continue ;
117+ }
118+ parsed.add (
119+ HistorySample (
120+ at: when ,
121+ cpuPercent: (m['cpu_percent' ] as num ? )? .toDouble () ?? 0 ,
122+ memPercent: (m['mem_percent' ] as num ? )? .toDouble () ?? 0 ,
123+ diskPressurePercent:
124+ (m['disk_pressure_percent' ] as num ? )? .toDouble () ?? 0 ,
125+ topProcesses: _parseEngineProcesses (m['top_processes' ]),
126+ ),
127+ );
128+ }
129+ if (parsed.isEmpty) return ;
130+
131+ parsed.sort ((a, b) => a.at.compareTo (b.at));
132+ _samples
133+ ..clear ()
134+ ..addAll (parsed);
135+ _lastSampleAt = parsed.last.at;
136+ _syncedFromEngine = true ;
137+ notifyListeners ();
138+ _scheduleSave ();
139+ }
140+
141+ Timer ? _refreshTimer;
142+
143+ /// Begin periodically pulling from the engine. Safe to call repeatedly.
144+ void startPeriodicRefresh (EngineService service) {
145+ _refreshTimer? .cancel ();
146+ // Fire an immediate refresh then continue on the interval.
147+ unawaited (refreshFromEngine (service));
148+ _refreshTimer = Timer .periodic (
149+ _refreshInterval,
150+ (_) => unawaited (refreshFromEngine (service)),
151+ );
152+ }
153+
154+ void stopPeriodicRefresh () {
155+ _refreshTimer? .cancel ();
156+ _refreshTimer = null ;
157+ }
158+
159+ static List <HistoryProcessSample > _parseEngineProcesses (dynamic raw) {
160+ if (raw is ! List ) return const [];
161+ final out = < HistoryProcessSample > [];
162+ for (final m in raw) {
163+ if (m is ! Map ) continue ;
164+ out.add (
165+ HistoryProcessSample (
166+ name: '${m ['name' ] ?? '' }' ,
167+ pid: (m['pid' ] as num ? )? .toInt () ?? 0 ,
168+ cpuPercent: (m['cpu_percent' ] as num ? )? .toDouble () ?? 0 ,
169+ memPercent: (m['mem_percent' ] as num ? )? .toDouble () ?? 0 ,
170+ impact: (m['impact' ] as num ? )? .toDouble () ?? 0 ,
171+ ),
172+ );
173+ }
174+ return out;
175+ }
176+
65177 /// Called by EngineProvider on every live state; this function decides whether
66178 /// we should record a new sample based on [sampleInterval] .
67179 void recordIfDue ({
@@ -127,6 +239,8 @@ class HistoryProvider extends ChangeNotifier {
127239 void dispose () {
128240 _saveDebounce? .cancel ();
129241 _saveDebounce = null ;
242+ _refreshTimer? .cancel ();
243+ _refreshTimer = null ;
130244 super .dispose ();
131245 }
132246}
0 commit comments