Skip to content

Commit b397f53

Browse files
feat: enhance dashboard and engine functionality with improved notification handling, alert history management, and navigation integration
1 parent 9ab3585 commit b397f53

10 files changed

Lines changed: 153 additions & 19 deletions

File tree

dashboard/lib/main.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
23
import 'package:provider/provider.dart';
34
import 'package:sentracore_dashboard/providers/engine_provider.dart';
45
import 'package:sentracore_dashboard/providers/settings_provider.dart';
6+
import 'package:sentracore_dashboard/navigation/dashboard_navigation.dart';
57
import 'package:sentracore_dashboard/screens/dashboard_screen.dart';
68
import 'package:sentracore_dashboard/services/desktop_notification_service.dart';
79
import 'package:sentracore_dashboard/theme/app_theme.dart';
810

911
Future<void> main() async {
1012
WidgetsFlutterBinding.ensureInitialized();
1113
final notifications = DesktopNotificationService();
12-
await notifications.init();
14+
await notifications.init(
15+
onDidReceiveNotificationResponse: (response) {
16+
if (response.notificationResponseType ==
17+
NotificationResponseType.selectedNotification) {
18+
DashboardNavigation.openAlertsFromNotification();
19+
}
20+
},
21+
);
1322
final settings = SettingsProvider();
1423
await settings.load();
1524
runApp(SentraCoreApp(

dashboard/lib/models/system_state.dart

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,18 @@ class AlertInfo {
251251
json['last_alert'] != null && json['last_alert']['root_cause'] != null
252252
? RootCauseAnalysis.fromJson(json['last_alert']['root_cause'])
253253
: null,
254-
recentAlerts: recent is List
255-
? recent
256-
.whereType<Map>()
257-
.map((e) => AlertRecord.fromJson(Map<String, dynamic>.from(e)))
258-
.toList()
259-
: const [],
254+
recentAlerts: () {
255+
if (recent is! List) return const <AlertRecord>[];
256+
final out = <AlertRecord>[];
257+
for (final e in recent) {
258+
if (e is Map<String, dynamic>) {
259+
out.add(AlertRecord.fromJson(e));
260+
} else if (e is Map) {
261+
out.add(AlertRecord.fromJson(Map<String, dynamic>.from(e)));
262+
}
263+
}
264+
return out;
265+
}(),
260266
);
261267
}
262268
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import 'package:flutter/scheduler.dart';
2+
3+
/// Registered by [DashboardScreen] / [DiagnosticsScreen] for cross-widget jumps.
4+
class DashboardNavigation {
5+
DashboardNavigation._();
6+
7+
/// Switch main rail: 0 Overview … 3 Diagnostics … 4 Settings.
8+
static void Function(int index)? selectMainTab;
9+
10+
/// Switches Diagnostics inner tab to "Alerts & RCA" (index 1).
11+
static VoidCallback? focusDiagnosticsAlertsTab;
12+
13+
static void openAlertsFromNotification() {
14+
selectMainTab?.call(3);
15+
SchedulerBinding.instance.addPostFrameCallback((_) {
16+
focusDiagnosticsAlertsTab?.call();
17+
});
18+
}
19+
}

dashboard/lib/providers/engine_provider.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,27 @@ class EngineProvider extends ChangeNotifier {
7676
List<SystemEvent> _events = [];
7777
List<SystemEvent> get events => _events;
7878

79+
/// Each [/ws/alerts] push (one per fired alert). Merged with engine [recent_alerts] for UI.
80+
static const int _maxAlertFeed = 120;
81+
final List<AlertRecord> _alertFeedFromWs = [];
82+
83+
/// Newest-first list for Diagnostics; deduped against live state history.
84+
List<AlertRecord> get mergedAlertHistory {
85+
final fromEngine =
86+
_currentState?.alert.recentAlerts ?? const <AlertRecord>[];
87+
final seen = <String>{};
88+
String dedupeKey(AlertRecord r) =>
89+
'${r.timestamp.toStringAsFixed(3)}::${r.message}';
90+
final merged = <AlertRecord>[];
91+
for (final r in [..._alertFeedFromWs, ...fromEngine]) {
92+
if (seen.add(dedupeKey(r))) {
93+
merged.add(r);
94+
}
95+
}
96+
merged.sort((a, b) => b.timestamp.compareTo(a.timestamp));
97+
return merged;
98+
}
99+
79100
// ── Subscriptions ──
80101
StreamSubscription? _liveSub;
81102
StreamSubscription? _alertSub;
@@ -96,6 +117,7 @@ class EngineProvider extends ChangeNotifier {
96117
}
97118

98119
Future<void> reconnect() async {
120+
_alertFeedFromWs.clear();
99121
_liveSub?.cancel();
100122
_alertSub?.cancel();
101123
_processFetchTimer?.cancel();
@@ -208,6 +230,12 @@ class EngineProvider extends ChangeNotifier {
208230
}
209231

210232
void _onAlertPayload(Map<String, dynamic> payload) {
233+
try {
234+
_alertFeedFromWs.insert(0, AlertRecord.fromJson(payload));
235+
if (_alertFeedFromWs.length > _maxAlertFeed) {
236+
_alertFeedFromWs.removeRange(_maxAlertFeed, _alertFeedFromWs.length);
237+
}
238+
} catch (_) {}
211239
final message = payload['message'] as String? ?? 'System stress alert';
212240
if (_settings.desktopNotificationsEnabled) {
213241
unawaited(_notifications.show(
@@ -216,6 +244,7 @@ class EngineProvider extends ChangeNotifier {
216244
message.length > 256 ? '${message.substring(0, 253)}...' : message,
217245
));
218246
}
247+
notifyListeners();
219248
}
220249

221250
void _syncCooldownTicker(SystemState state) {

dashboard/lib/screens/dashboard_screen.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:provider/provider.dart';
3+
import 'package:sentracore_dashboard/navigation/dashboard_navigation.dart';
34
import 'package:sentracore_dashboard/providers/engine_provider.dart';
45
import 'package:sentracore_dashboard/screens/overview_screen.dart';
56
import 'package:sentracore_dashboard/screens/performance_screen.dart';
@@ -22,6 +23,21 @@ class _DashboardScreenState extends State<DashboardScreen> {
2223
int _selectedIndex = 0;
2324
bool _navExpanded = false;
2425

26+
@override
27+
void initState() {
28+
super.initState();
29+
DashboardNavigation.selectMainTab = (i) {
30+
if (!mounted) return;
31+
setState(() => _selectedIndex = i.clamp(0, 4));
32+
};
33+
}
34+
35+
@override
36+
void dispose() {
37+
DashboardNavigation.selectMainTab = null;
38+
super.dispose();
39+
}
40+
2541
static const _pages = [
2642
OverviewScreen(),
2743
PerformanceScreen(),

dashboard/lib/screens/diagnostics_screen.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart';
22
import 'package:provider/provider.dart';
33
import 'package:sentracore_dashboard/models/system_state.dart';
4+
import 'package:sentracore_dashboard/navigation/dashboard_navigation.dart';
45
import 'package:sentracore_dashboard/providers/engine_provider.dart';
56
import 'package:sentracore_dashboard/theme/app_theme.dart';
67
import 'package:sentracore_dashboard/widgets/responsive_builder.dart';
@@ -18,14 +19,21 @@ class _DiagnosticsScreenState extends State<DiagnosticsScreen>
1819
late final TabController _tabController;
1920
String _eventSeverityFilter = 'all';
2021

22+
void _focusAlertsTab() {
23+
if (!mounted || _tabController.length < 2) return;
24+
_tabController.animateTo(1);
25+
}
26+
2127
@override
2228
void initState() {
2329
super.initState();
2430
_tabController = TabController(length: 2, vsync: this);
31+
DashboardNavigation.focusDiagnosticsAlertsTab = _focusAlertsTab;
2532
}
2633

2734
@override
2835
void dispose() {
36+
DashboardNavigation.focusDiagnosticsAlertsTab = null;
2937
_tabController.dispose();
3038
super.dispose();
3139
}
@@ -369,7 +377,7 @@ class _AlertsRcaTab extends StatelessWidget {
369377
}
370378

371379
final rca = alert.lastRootCause;
372-
final history = alert.recentAlerts;
380+
final history = provider.mergedAlertHistory;
373381

374382
return SingleChildScrollView(
375383
padding: const EdgeInsets.all(16),

dashboard/lib/services/desktop_notification_service.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import 'dart:io' show Platform;
22

33
import 'package:flutter/foundation.dart' show kIsWeb;
4-
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
4+
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
5+
show
6+
FlutterLocalNotificationsPlugin,
7+
InitializationSettings,
8+
NotificationResponse,
9+
WindowsInitializationSettings,
10+
WindowsNotificationDetails,
11+
NotificationDetails;
512

613
/// Windows toast-style alerts via flutter_local_notifications.
714
class DesktopNotificationService {
@@ -11,7 +18,10 @@ class DesktopNotificationService {
1118
FlutterLocalNotificationsPlugin();
1219
bool _ready = false;
1320

14-
Future<void> init() async {
21+
Future<void> init({
22+
void Function(NotificationResponse response)?
23+
onDidReceiveNotificationResponse,
24+
}) async {
1525
if (kIsWeb || !Platform.isWindows) {
1626
_ready = false;
1727
return;
@@ -25,6 +35,7 @@ class DesktopNotificationService {
2535
guid: 'c3d4e5f6-a7b8-4c9d-0e1f-223344556677',
2636
),
2737
),
38+
onDidReceiveNotificationResponse: onDidReceiveNotificationResponse,
2839
);
2940
_ready = true;
3041
} catch (_) {

engine/alerts/alert_manager.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ def to_dict(self) -> dict:
5050
"root_cause": self.root_cause,
5151
}
5252

53+
def to_summary_dict(self) -> dict:
54+
"""Compact form for live-state history lists (smaller WebSocket payloads)."""
55+
return {
56+
"timestamp": self.timestamp,
57+
"stress_score": round(self.stress_score, 2),
58+
"level": self.level,
59+
"message": self.message,
60+
}
61+
5362

5463
AlertCallback = Callable[["Alert"], None]
5564

engine/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def _writable_datastore_dir() -> Path:
132132

133133
# Drop a PID from the tracker after this many snapshots without appearing in
134134
# the collector's top-N process list (exited, or fell out of the top list).
135-
PROCESS_MISS_SNAPSHOTS_BEFORE_PRUNE: int = 5
135+
PROCESS_MISS_SNAPSHOTS_BEFORE_PRUNE: int = 1
136136

137137
# ---------------------------------------------------------------------------
138138
# Event Logger

engine/main.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ def get_current_state(self) -> dict:
122122
"consecutive_high": self.alert_manager.consecutive_high_count,
123123
"last_alert": self._last_alert.to_dict() if self._last_alert else None,
124124
"recent_alerts": [
125-
a.to_dict() for a in self.alert_manager.get_recent_alerts(30)
125+
a.to_summary_dict()
126+
for a in self.alert_manager.get_recent_alerts(50)
126127
],
127128
},
128129
"buffers": {
@@ -206,6 +207,37 @@ def set_user_preferences(self, body: dict) -> dict:
206207
return {"ok": False, "error": str(exc)}
207208
return {"ok": True, "preferences": prefs.to_dict()}
208209

210+
@staticmethod
211+
def _safeguard_target_keys(names: list[str]) -> set[str]:
212+
"""Expand user-entered / UI-picked names for loose matching to psutil names."""
213+
out: set[str] = set()
214+
for raw in names:
215+
s = str(raw).strip().lower()
216+
if not s:
217+
continue
218+
out.add(canonical_process_name(s))
219+
out.add(s)
220+
base = s.removesuffix(".exe")
221+
out.add(base)
222+
out.add(f"{base}.exe")
223+
return {x for x in out if x}
224+
225+
@staticmethod
226+
def _process_name_matches_safeguard(process_name: str, targets: set[str]) -> bool:
227+
p = process_name.strip().lower()
228+
if not p:
229+
return False
230+
if canonical_process_name(process_name) in targets:
231+
return True
232+
if p in targets:
233+
return True
234+
base = p.removesuffix(".exe")
235+
if base in targets:
236+
return True
237+
if f"{base}.exe" in targets:
238+
return True
239+
return False
240+
209241
def _apply_safeguard(
210242
self,
211243
prefs: UserPreferences,
@@ -219,11 +251,7 @@ def _apply_safeguard(
219251
"""
220252
if not prefs.safeguard_enabled:
221253
return
222-
targets = {
223-
canonical_process_name(str(n))
224-
for n in prefs.safeguard_process_names
225-
if str(n).strip()
226-
}
254+
targets = self._safeguard_target_keys(prefs.safeguard_process_names)
227255
if not targets:
228256
return
229257

@@ -241,8 +269,7 @@ def _apply_safeguard(
241269

242270
terminated = 0
243271
for pid, name in ordered:
244-
key = canonical_process_name(name)
245-
if key not in targets:
272+
if not self._process_name_matches_safeguard(name, targets):
246273
continue
247274
res = self.process_action(pid, "terminate")
248275
if res.get("ok"):

0 commit comments

Comments
 (0)