Skip to content

Commit 602e08e

Browse files
feat: add anomaly sensitivity settings and recent alert history display for improved resource monitoring
1 parent 7c9177c commit 602e08e

14 files changed

Lines changed: 491 additions & 116 deletions

dashboard/lib/models/system_state.dart

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,30 @@ class DiskIoData {
192192
}
193193
}
194194

195+
/// One fired resource alert (from engine history).
196+
class AlertRecord {
197+
final double timestamp;
198+
final double stressScore;
199+
final String level;
200+
final String message;
201+
202+
AlertRecord({
203+
required this.timestamp,
204+
required this.stressScore,
205+
required this.level,
206+
required this.message,
207+
});
208+
209+
factory AlertRecord.fromJson(Map<String, dynamic> json) {
210+
return AlertRecord(
211+
timestamp: (json['timestamp'] ?? 0).toDouble(),
212+
stressScore: (json['stress_score'] ?? 0).toDouble(),
213+
level: json['level']?.toString() ?? '',
214+
message: json['message']?.toString() ?? '',
215+
);
216+
}
217+
}
218+
195219
class AlertInfo {
196220
final int totalFired;
197221
final bool inCooldown;
@@ -200,6 +224,7 @@ class AlertInfo {
200224
final int consecutiveHigh;
201225
final String? lastMessage;
202226
final RootCauseAnalysis? lastRootCause;
227+
final List<AlertRecord> recentAlerts;
203228

204229
AlertInfo({
205230
required this.totalFired,
@@ -209,9 +234,11 @@ class AlertInfo {
209234
required this.consecutiveHigh,
210235
this.lastMessage,
211236
this.lastRootCause,
237+
this.recentAlerts = const [],
212238
});
213239

214240
factory AlertInfo.fromJson(Map<String, dynamic> json) {
241+
final recent = json['recent_alerts'];
215242
return AlertInfo(
216243
totalFired: json['total_fired'] ?? 0,
217244
inCooldown: json['in_cooldown'] ?? false,
@@ -224,6 +251,12 @@ class AlertInfo {
224251
json['last_alert'] != null && json['last_alert']['root_cause'] != null
225252
? RootCauseAnalysis.fromJson(json['last_alert']['root_cause'])
226253
: 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 [],
227260
);
228261
}
229262
}
@@ -416,28 +449,47 @@ class TrendResult {
416449

417450
/// Anomaly detection result from the Python AnomalyDetector.
418451
class AnomalyResult {
452+
final double score;
453+
final bool isSustained;
419454
final double cpuZScore;
420455
final double memoryZScore;
421456
final double diskZScore;
422457
final String level;
423458

424459
AnomalyResult({
460+
this.score = 0,
461+
this.isSustained = false,
425462
required this.cpuZScore,
426463
required this.memoryZScore,
427464
required this.diskZScore,
428465
required this.level,
429466
});
430467

431468
factory AnomalyResult.fromJson(Map<String, dynamic> json) {
432-
return AnomalyResult(
433-
cpuZScore:
434-
(json['cpu_z_score'] ?? json['cpu_zscore'] ?? 0).toDouble().abs(),
435-
memoryZScore: (json['memory_z_score'] ?? json['memory_zscore'] ?? 0)
469+
double cpuZ;
470+
double memZ;
471+
double diskZ;
472+
final zs = json['z_scores'];
473+
if (zs is Map) {
474+
cpuZ = ((zs['cpu'] ?? 0) as num).toDouble().abs();
475+
memZ = ((zs['memory'] ?? 0) as num).toDouble().abs();
476+
diskZ = ((zs['disk'] ?? 0) as num).toDouble().abs();
477+
} else {
478+
cpuZ = (json['cpu_z_score'] ?? json['cpu_zscore'] ?? 0).toDouble().abs();
479+
memZ = (json['memory_z_score'] ?? json['memory_zscore'] ?? 0)
436480
.toDouble()
437-
.abs(),
438-
diskZScore:
439-
(json['disk_z_score'] ?? json['disk_zscore'] ?? 0).toDouble().abs(),
440-
level: json['level'] ?? 'normal',
481+
.abs();
482+
diskZ =
483+
(json['disk_z_score'] ?? json['disk_zscore'] ?? 0).toDouble().abs();
484+
}
485+
486+
return AnomalyResult(
487+
score: (json['score'] ?? 0).toDouble(),
488+
isSustained: json['is_sustained'] == true,
489+
cpuZScore: cpuZ,
490+
memoryZScore: memZ,
491+
diskZScore: diskZ,
492+
level: json['level']?.toString() ?? 'normal',
441493
);
442494
}
443495
}

dashboard/lib/providers/settings_provider.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class SettingsProvider extends ChangeNotifier {
1111
static const _kAlertDisk = 'alert_disk_pressure';
1212
static const _kSafeguardOn = 'safeguard_enabled';
1313
static const _kSafeguardNames = 'safeguard_process_names';
14+
static const _kAnomalySensitivity = 'anomaly_sensitivity';
1415

1516
ThemeMode _themeMode = ThemeMode.dark;
1617
bool _desktopNotifications = true;
@@ -20,6 +21,9 @@ class SettingsProvider extends ChangeNotifier {
2021
double _alertDiskPressure = 80;
2122
bool _safeguardEnabled = false;
2223
String _safeguardProcessNames = '';
24+
25+
/// Engine + UI: lenient | normal | strict (anomaly label bands).
26+
String _anomalySensitivity = 'normal';
2327
int _lastEngineHttpPort = 8740;
2428

2529
ThemeMode get themeMode => _themeMode;
@@ -31,6 +35,7 @@ class SettingsProvider extends ChangeNotifier {
3135
double get alertDiskPressure => _alertDiskPressure;
3236
bool get safeguardEnabled => _safeguardEnabled;
3337
String get safeguardProcessNames => _safeguardProcessNames;
38+
String get anomalySensitivity => _anomalySensitivity;
3439
int get lastEngineHttpPort => _lastEngineHttpPort;
3540

3641
Future<void> load() async {
@@ -49,6 +54,10 @@ class SettingsProvider extends ChangeNotifier {
4954
_alertDiskPressure = p.getDouble(_kAlertDisk) ?? 80;
5055
_safeguardEnabled = p.getBool(_kSafeguardOn) ?? false;
5156
_safeguardProcessNames = p.getString(_kSafeguardNames) ?? '';
57+
_anomalySensitivity = p.getString(_kAnomalySensitivity) ?? 'normal';
58+
if (!_isValidAnomalySensitivity(_anomalySensitivity)) {
59+
_anomalySensitivity = 'normal';
60+
}
5261
_lastEngineHttpPort = p.getInt(_kLastEnginePort) ?? 8740;
5362
notifyListeners();
5463
}
@@ -67,6 +76,7 @@ class SettingsProvider extends ChangeNotifier {
6776
await p.setDouble(_kAlertDisk, _alertDiskPressure);
6877
await p.setBool(_kSafeguardOn, _safeguardEnabled);
6978
await p.setString(_kSafeguardNames, _safeguardProcessNames);
79+
await p.setString(_kAnomalySensitivity, _anomalySensitivity);
7080
await p.setInt(_kLastEnginePort, _lastEngineHttpPort);
7181
}
7282

@@ -118,6 +128,16 @@ class SettingsProvider extends ChangeNotifier {
118128
notifyListeners();
119129
}
120130

131+
static bool _isValidAnomalySensitivity(String v) {
132+
return v == 'lenient' || v == 'normal' || v == 'strict';
133+
}
134+
135+
void setAnomalySensitivity(String v) {
136+
final s = v.toLowerCase().trim();
137+
_anomalySensitivity = _isValidAnomalySensitivity(s) ? s : 'normal';
138+
notifyListeners();
139+
}
140+
121141
List<String> _parseSafeguardLines() {
122142
return _safeguardProcessNames
123143
.split(RegExp(r'[\r\n,;]+'))
@@ -177,6 +197,9 @@ class SettingsProvider extends ChangeNotifier {
177197
} else {
178198
_safeguardProcessNames = '';
179199
}
200+
final sens =
201+
'${json['anomaly_sensitivity'] ?? 'normal'}'.toLowerCase().trim();
202+
_anomalySensitivity = _isValidAnomalySensitivity(sens) ? sens : 'normal';
180203
notifyListeners();
181204
}
182205

@@ -188,6 +211,7 @@ class SettingsProvider extends ChangeNotifier {
188211
'alert_disk_pressure': _alertDiskPressure,
189212
'safeguard_enabled': _safeguardEnabled,
190213
'safeguard_process_names': lines,
214+
'anomaly_sensitivity': _anomalySensitivity,
191215
};
192216
}
193217
}

dashboard/lib/screens/diagnostics_screen.dart

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,16 +369,51 @@ class _AlertsRcaTab extends StatelessWidget {
369369
}
370370

371371
final rca = alert.lastRootCause;
372+
final history = alert.recentAlerts;
372373

373374
return SingleChildScrollView(
374375
padding: const EdgeInsets.all(16),
375376
child: Column(
376377
crossAxisAlignment: CrossAxisAlignment.start,
377378
children: [
378-
// Alert summary card
379379
_AlertSummaryCard(alert: alert),
380+
if (history.isNotEmpty) ...[
381+
const SizedBox(height: 20),
382+
Text(
383+
'Recent alerts (newest first)',
384+
style: TextStyle(
385+
color: AppTheme.textPrimaryFor(context),
386+
fontSize: 13,
387+
fontWeight: FontWeight.w600,
388+
),
389+
),
390+
const SizedBox(height: 4),
391+
Text(
392+
'Each line is a fired resource alert while the engine was running.',
393+
style: TextStyle(
394+
color: AppTheme.textMutedFor(context),
395+
fontSize: 11,
396+
),
397+
),
398+
const SizedBox(height: 10),
399+
...history.map(
400+
(a) => Padding(
401+
padding: const EdgeInsets.only(bottom: 8),
402+
child: _AlertHistoryTile(record: a),
403+
),
404+
),
405+
] else if (alert.totalFired > 0)
406+
Padding(
407+
padding: const EdgeInsets.only(top: 12),
408+
child: Text(
409+
'Detailed history will appear after the next live update from the engine.',
410+
style: TextStyle(
411+
color: AppTheme.textMutedFor(context),
412+
fontSize: 12,
413+
),
414+
),
415+
),
380416
const SizedBox(height: 16),
381-
// RCA card
382417
if (rca != null)
383418
_RcaDetailCard(rca: rca, message: alert.lastMessage ?? ''),
384419
],
@@ -387,6 +422,78 @@ class _AlertsRcaTab extends StatelessWidget {
387422
}
388423
}
389424

425+
String _formatAlertTime(double ts) {
426+
try {
427+
final dt =
428+
DateTime.fromMillisecondsSinceEpoch((ts * 1000).toInt()).toLocal();
429+
return '${dt.hour.toString().padLeft(2, '0')}:'
430+
'${dt.minute.toString().padLeft(2, '0')}:'
431+
'${dt.second.toString().padLeft(2, '0')}';
432+
} catch (_) {
433+
return ts.toStringAsFixed(0);
434+
}
435+
}
436+
437+
class _AlertHistoryTile extends StatelessWidget {
438+
final AlertRecord record;
439+
const _AlertHistoryTile({required this.record});
440+
441+
@override
442+
Widget build(BuildContext context) {
443+
return Card(
444+
child: Padding(
445+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
446+
child: Column(
447+
crossAxisAlignment: CrossAxisAlignment.start,
448+
children: [
449+
Row(
450+
children: [
451+
Icon(Icons.notifications_outlined,
452+
size: 16, color: AppTheme.error.withValues(alpha: 0.85)),
453+
const SizedBox(width: 8),
454+
Text(
455+
_formatAlertTime(record.timestamp),
456+
style: TextStyle(
457+
color: AppTheme.textMutedFor(context),
458+
fontSize: 11,
459+
fontFeatures: const [FontFeature.tabularFigures()],
460+
),
461+
),
462+
const SizedBox(width: 10),
463+
Text(
464+
'Stress ${record.stressScore.toStringAsFixed(0)}',
465+
style: TextStyle(
466+
color: AppTheme.textSecondaryFor(context),
467+
fontSize: 11,
468+
),
469+
),
470+
const SizedBox(width: 8),
471+
Text(
472+
record.level.toUpperCase(),
473+
style: TextStyle(
474+
color: AppTheme.warning,
475+
fontSize: 10,
476+
fontWeight: FontWeight.w700,
477+
),
478+
),
479+
],
480+
),
481+
const SizedBox(height: 6),
482+
Text(
483+
record.message,
484+
style: TextStyle(
485+
color: AppTheme.textPrimaryFor(context),
486+
fontSize: 12,
487+
height: 1.35,
488+
),
489+
),
490+
],
491+
),
492+
),
493+
);
494+
}
495+
}
496+
390497
class _AlertSummaryCard extends StatelessWidget {
391498
final AlertInfo alert;
392499
const _AlertSummaryCard({required this.alert});

0 commit comments

Comments
 (0)