Skip to content

Commit 09556da

Browse files
feat: integrate HistoryProvider into dashboard for enhanced system history tracking and visualization
1 parent b3df03a commit 09556da

10 files changed

Lines changed: 774 additions & 457 deletions

dashboard/lib/main.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
33
import 'package:provider/provider.dart';
44
import 'package:sentracore_dashboard/providers/engine_provider.dart';
5+
import 'package:sentracore_dashboard/providers/history_provider.dart';
56
import 'package:sentracore_dashboard/providers/logbook_provider.dart';
67
import 'package:sentracore_dashboard/providers/settings_provider.dart';
78
import 'package:sentracore_dashboard/navigation/dashboard_navigation.dart';
@@ -44,13 +45,17 @@ class SentraCoreApp extends StatelessWidget {
4445
providers: [
4546
Provider<DesktopNotificationService>.value(value: notifications),
4647
ChangeNotifierProvider<SettingsProvider>.value(value: settings),
48+
ChangeNotifierProvider<HistoryProvider>(
49+
create: (_) => HistoryProvider()..load(),
50+
),
4751
ChangeNotifierProvider<LogbookProvider>(
4852
create: (_) => LogbookProvider()..load(),
4953
),
5054
ChangeNotifierProvider(
51-
create: (_) => EngineProvider(
55+
create: (ctx) => EngineProvider(
5256
settings: settings,
5357
notifications: notifications,
58+
history: Provider.of<HistoryProvider>(ctx, listen: false),
5459
)..connect(),
5560
),
5661
],
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
class HistoryProcessSample {
2+
final String name;
3+
final int pid;
4+
final double cpuPercent;
5+
final double memPercent;
6+
final double impact;
7+
8+
const HistoryProcessSample({
9+
required this.name,
10+
required this.pid,
11+
required this.cpuPercent,
12+
required this.memPercent,
13+
required this.impact,
14+
});
15+
16+
Map<String, dynamic> toJson() => {
17+
'name': name,
18+
'pid': pid,
19+
'cpu_percent': cpuPercent,
20+
'mem_percent': memPercent,
21+
'impact': impact,
22+
};
23+
24+
factory HistoryProcessSample.fromJson(Map<String, dynamic> json) {
25+
return HistoryProcessSample(
26+
name: '${json['name'] ?? ''}',
27+
pid: (json['pid'] as num?)?.toInt() ?? 0,
28+
cpuPercent: (json['cpu_percent'] as num?)?.toDouble() ?? 0,
29+
memPercent: (json['mem_percent'] as num?)?.toDouble() ?? 0,
30+
impact: (json['impact'] as num?)?.toDouble() ?? 0,
31+
);
32+
}
33+
}
34+
35+
class HistorySample {
36+
final DateTime at;
37+
final double cpuPercent;
38+
final double memPercent;
39+
final double diskPressurePercent;
40+
final List<HistoryProcessSample> topProcesses;
41+
42+
const HistorySample({
43+
required this.at,
44+
required this.cpuPercent,
45+
required this.memPercent,
46+
required this.diskPressurePercent,
47+
required this.topProcesses,
48+
});
49+
50+
Map<String, dynamic> toJson() => {
51+
'at': at.toIso8601String(),
52+
'cpu_percent': cpuPercent,
53+
'mem_percent': memPercent,
54+
'disk_pressure_percent': diskPressurePercent,
55+
'top_processes': topProcesses.map((p) => p.toJson()).toList(),
56+
};
57+
58+
factory HistorySample.fromJson(Map<String, dynamic> json) {
59+
final atRaw = json['at'];
60+
final at = atRaw is String
61+
? (DateTime.tryParse(atRaw) ?? DateTime.fromMillisecondsSinceEpoch(0))
62+
: DateTime.fromMillisecondsSinceEpoch(0);
63+
final procsRaw = json['top_processes'];
64+
final procs = <HistoryProcessSample>[];
65+
if (procsRaw is List) {
66+
for (final x in procsRaw) {
67+
if (x is Map) {
68+
procs
69+
.add(HistoryProcessSample.fromJson(Map<String, dynamic>.from(x)));
70+
}
71+
}
72+
}
73+
return HistorySample(
74+
at: at,
75+
cpuPercent: (json['cpu_percent'] as num?)?.toDouble() ?? 0,
76+
memPercent: (json['mem_percent'] as num?)?.toDouble() ?? 0,
77+
diskPressurePercent:
78+
(json['disk_pressure_percent'] as num?)?.toDouble() ?? 0,
79+
topProcesses: procs,
80+
);
81+
}
82+
}

dashboard/lib/providers/engine_provider.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io' show Platform, Process;
44

55
import 'package:flutter/foundation.dart';
66
import 'package:sentracore_dashboard/models/system_state.dart';
7+
import 'package:sentracore_dashboard/providers/history_provider.dart';
78
import 'package:sentracore_dashboard/providers/settings_provider.dart';
89
import 'package:sentracore_dashboard/services/desktop_notification_service.dart';
910
import 'package:sentracore_dashboard/services/engine_bundled_launcher.dart';
@@ -17,13 +18,16 @@ class EngineProvider extends ChangeNotifier {
1718
EngineProvider({
1819
required SettingsProvider settings,
1920
required DesktopNotificationService notifications,
21+
HistoryProvider? history,
2022
}) : _settings = settings,
21-
_notifications = notifications {
23+
_notifications = notifications,
24+
_history = history {
2225
_service = EngineService(port: settings.lastEngineHttpPort);
2326
}
2427

2528
final SettingsProvider _settings;
2629
final DesktopNotificationService _notifications;
30+
final HistoryProvider? _history;
2731
late EngineService _service;
2832

2933
// ── Connection State ──
@@ -380,6 +384,11 @@ class EngineProvider extends ChangeNotifier {
380384

381385
void _onStateReceived(SystemState state) {
382386
_lastLiveStateAt = DateTime.now();
387+
_history?.recordIfDue(
388+
now: _lastLiveStateAt!,
389+
state: state,
390+
processes: _processes,
391+
);
383392
if (!_didPullEnginePrefs) {
384393
unawaited(_maybePullEnginePreferences());
385394
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
4+
import 'package:flutter/foundation.dart';
5+
import 'package:shared_preferences/shared_preferences.dart';
6+
7+
import 'package:sentracore_dashboard/models/history_sample.dart';
8+
import 'package:sentracore_dashboard/models/system_state.dart';
9+
10+
class HistoryProvider extends ChangeNotifier {
11+
static const _kHistorySamples = 'history_samples_v1';
12+
13+
/// Default sampling cadence (engine pushes every ~2s; we downsample to keep storage sane).
14+
static const Duration sampleInterval = Duration(seconds: 30);
15+
16+
/// Hard cap to prevent unbounded growth in SharedPreferences.
17+
static const int maxSamples =
18+
12000; // ~4 days at 30s; UI ranges use downsampling
19+
20+
final List<HistorySample> _samples = [];
21+
bool _loaded = false;
22+
DateTime? _lastSampleAt;
23+
Timer? _saveDebounce;
24+
25+
List<HistorySample> get samples => List<HistorySample>.unmodifiable(_samples);
26+
bool get loaded => _loaded;
27+
28+
Future<void> load() async {
29+
if (_loaded) return;
30+
final p = await SharedPreferences.getInstance();
31+
final raw = p.getString(_kHistorySamples);
32+
if (raw == null || raw.trim().isEmpty) {
33+
_loaded = true;
34+
notifyListeners();
35+
return;
36+
}
37+
try {
38+
final decoded = jsonDecode(raw);
39+
if (decoded is List) {
40+
_samples
41+
..clear()
42+
..addAll(
43+
decoded.whereType<Map>().map(
44+
(m) => HistorySample.fromJson(Map<String, dynamic>.from(m)),
45+
),
46+
);
47+
_samples.sort((a, b) => a.at.compareTo(b.at));
48+
_lastSampleAt = _samples.isNotEmpty ? _samples.last.at : null;
49+
}
50+
} catch (_) {
51+
// Corrupt payload -> start clean.
52+
_samples.clear();
53+
}
54+
_loaded = true;
55+
notifyListeners();
56+
}
57+
58+
void clear() {
59+
_samples.clear();
60+
_lastSampleAt = null;
61+
notifyListeners();
62+
_scheduleSave();
63+
}
64+
65+
/// Called by EngineProvider on every live state; this function decides whether
66+
/// we should record a new sample based on [sampleInterval].
67+
void recordIfDue({
68+
required DateTime now,
69+
required SystemState state,
70+
required List<ProcessImpact> processes,
71+
}) {
72+
final n = state.normalized;
73+
if (n == null) return;
74+
75+
final last = _lastSampleAt;
76+
if (last != null && now.difference(last) < sampleInterval) return;
77+
78+
final diskPct =
79+
((n.diskIo.totalOpsPerSec) / 500.0 * 100.0).clamp(0.0, 100.0);
80+
final top = List<ProcessImpact>.from(processes)
81+
..sort((a, b) => b.impactScore.compareTo(a.impactScore));
82+
final top10 = top.take(10).map((p) {
83+
return HistoryProcessSample(
84+
name: p.name,
85+
pid: p.pid,
86+
cpuPercent: p.cpuImpact,
87+
memPercent: p.memoryPercent,
88+
impact: p.impactScore,
89+
);
90+
}).toList();
91+
92+
_samples.add(
93+
HistorySample(
94+
at: now,
95+
cpuPercent: n.cpu.smoothed.clamp(0.0, 100.0).toDouble(),
96+
memPercent: n.memory.smoothed.clamp(0.0, 100.0).toDouble(),
97+
diskPressurePercent: diskPct.toDouble(),
98+
topProcesses: top10,
99+
),
100+
);
101+
_samples.sort((a, b) => a.at.compareTo(b.at));
102+
_lastSampleAt = now;
103+
104+
// Retain only the newest maxSamples.
105+
if (_samples.length > maxSamples) {
106+
_samples.removeRange(0, _samples.length - maxSamples);
107+
}
108+
109+
notifyListeners();
110+
_scheduleSave();
111+
}
112+
113+
void _scheduleSave() {
114+
_saveDebounce?.cancel();
115+
_saveDebounce = Timer(const Duration(milliseconds: 600), () {
116+
unawaited(_saveNow());
117+
});
118+
}
119+
120+
Future<void> _saveNow() async {
121+
final p = await SharedPreferences.getInstance();
122+
final payload = jsonEncode(_samples.map((s) => s.toJson()).toList());
123+
await p.setString(_kHistorySamples, payload);
124+
}
125+
126+
@override
127+
void dispose() {
128+
_saveDebounce?.cancel();
129+
_saveDebounce = null;
130+
super.dispose();
131+
}
132+
}

dashboard/lib/screens/dashboard_screen.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,17 @@ class _SentraNavRail extends StatelessWidget {
230230
const Spacer(),
231231
// Theme Toggle
232232
Consumer<SettingsProvider>(
233-
builder: (context, settings, _) => IconButton(
233+
builder: (context, settings, _) => IconButton.filledTonal(
234234
tooltip: 'Toggle light / dark',
235+
style: IconButton.styleFrom(
236+
backgroundColor: AppTheme.surfaceLightFor(context),
237+
foregroundColor: AppTheme.primary,
238+
),
235239
onPressed: () => settings.toggleTheme(),
236240
icon: Icon(
237241
settings.isDarkMode
238242
? Icons.light_mode_outlined
239243
: Icons.dark_mode_outlined,
240-
color: Theme.of(context).iconTheme.color,
241244
size: 20,
242245
),
243246
),

0 commit comments

Comments
 (0)