From 8e3eb963711f2001da5cd6495713b56594f6d331 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Thu, 28 May 2026 10:05:49 -0400 Subject: [PATCH] Add device event history chart to device details page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Displays a scatter chart timeline of when the device was seen online, with a time range selector (Today → Last Year). Tooltips show event date/time, type, and highlight any IP or vendor changes relative to the current device data. Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/devices/device_detail.dart | 19 ++ .../lib/devices/device_event_history.dart | 313 ++++++++++++++++++ frontend/lib/model/device_event.dart | 28 ++ frontend/lib/utils/oott_api.dart | 10 + frontend/pubspec.lock | 16 + frontend/pubspec.yaml | 1 + 6 files changed, 387 insertions(+) create mode 100644 frontend/lib/devices/device_event_history.dart create mode 100644 frontend/lib/model/device_event.dart diff --git a/frontend/lib/devices/device_detail.dart b/frontend/lib/devices/device_detail.dart index 12a0ca2..0170466 100644 --- a/frontend/lib/devices/device_detail.dart +++ b/frontend/lib/devices/device_detail.dart @@ -6,6 +6,7 @@ import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; import '../widgets/status_badge.dart'; import 'device_actions.dart'; +import 'device_event_history.dart'; class DeviceDetail extends StatefulWidget { final String macAddress; @@ -105,6 +106,10 @@ class _DeviceDetailState extends State { _DeviceHeader(device: device), const SizedBox(height: 24), _DeviceInfoCard(device: device), + const SizedBox(height: 24), + _SectionHeader(title: 'Event History'), + const SizedBox(height: 12), + DeviceEventHistory(device: device), ], ), ), @@ -112,6 +117,20 @@ class _DeviceDetailState extends State { } } +class _SectionHeader extends StatelessWidget { + final String title; + + const _SectionHeader({required this.title}); + + @override + Widget build(BuildContext context) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ); + } +} + class _DeviceHeader extends StatelessWidget { final Device device; diff --git a/frontend/lib/devices/device_event_history.dart b/frontend/lib/devices/device_event_history.dart new file mode 100644 index 0000000..6ba6919 --- /dev/null +++ b/frontend/lib/devices/device_event_history.dart @@ -0,0 +1,313 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../model/device.dart'; +import '../model/device_event.dart'; +import '../utils/oott_api.dart'; + +enum _TimeRange { + today('Today'), + lastWeek('Last week'), + lastThreeMonths('Last 3 months'), + lastSixMonths('Last 6 months'), + lastYear('Last year'); + + const _TimeRange(this.label); + + final String label; + + DateTime get cutoff { + final now = DateTime.now(); + return switch (this) { + _TimeRange.today => DateTime(now.year, now.month, now.day), + _TimeRange.lastWeek => now.subtract(const Duration(days: 7)), + _TimeRange.lastThreeMonths => now.subtract(const Duration(days: 90)), + _TimeRange.lastSixMonths => now.subtract(const Duration(days: 180)), + _TimeRange.lastYear => now.subtract(const Duration(days: 365)), + }; + } + + double get xIntervalMs { + const hour = 3600000.0; + const day = 86400000.0; + return switch (this) { + _TimeRange.today => 4 * hour, + _TimeRange.lastWeek => day, + _TimeRange.lastThreeMonths => 14 * day, + _TimeRange.lastSixMonths => 30 * day, + _TimeRange.lastYear => 60 * day, + }; + } + + String formatLabel(DateTime dt) { + final isCurrentYear = dt.year == DateTime.now().year; + return switch (this) { + _TimeRange.today => DateFormat('HH:mm').format(dt), + _TimeRange.lastWeek => + isCurrentYear + ? DateFormat('EEE').format(dt) + : DateFormat('EEE yyyy').format(dt), + _TimeRange.lastThreeMonths || _TimeRange.lastSixMonths => + isCurrentYear + ? DateFormat('MMM d').format(dt) + : DateFormat('MMM d, yyyy').format(dt), + _TimeRange.lastYear => + isCurrentYear + ? DateFormat('MMM').format(dt) + : DateFormat('MMM yyyy').format(dt), + }; + } +} + +class DeviceEventHistory extends StatefulWidget { + final Device device; + + const DeviceEventHistory({super.key, required this.device}); + + @override + State createState() => _DeviceEventHistoryState(); +} + +class _DeviceEventHistoryState extends State { + List? _allEvents; + bool _isLoading = true; + String? _error; + _TimeRange _selectedRange = _TimeRange.lastWeek; + + @override + void initState() { + super.initState(); + _loadEvents(); + } + + Future _loadEvents() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final events = await BackendAPI.instance.getDeviceEvents( + widget.device.macAddress, + ); + if (!mounted) return; + setState(() { + _allEvents = events; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + List get _filteredEvents { + if (_allEvents == null) return []; + final cutoff = _selectedRange.cutoff; + return _allEvents!.where((e) => e.createdOn.isAfter(cutoff)).toList(); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (_error != null) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: Text('Failed to load event history')), + ); + } + + final events = _filteredEvents; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton<_TimeRange>( + segments: + _TimeRange.values + .map( + (r) => ButtonSegment(value: r, label: Text(r.label)), + ) + .toList(), + selected: {_selectedRange}, + onSelectionChanged: (selection) { + setState(() => _selectedRange = selection.first); + }, + ), + ), + const SizedBox(height: 16), + if (events.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('No events in this time range')), + ) + else + SizedBox( + height: 160, + child: _EventChart( + events: events, + device: widget.device, + range: _selectedRange, + ), + ), + ], + ); + } +} + +class _EventChart extends StatelessWidget { + final List events; + final Device device; + final _TimeRange range; + + const _EventChart({ + required this.events, + required this.device, + required this.range, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final now = DateTime.now(); + final cutoff = range.cutoff; + final intervalMs = range.xIntervalMs; + final cutoffMs = cutoff.millisecondsSinceEpoch.toDouble(); + final nowMs = now.millisecondsSinceEpoch.toDouble(); + + // Snap axis bounds to interval boundaries so every tick falls within the chart. + // This may add a small margin on each side, which is intentional. + final minX = (cutoffMs / intervalMs).floor() * intervalMs; + final maxX = (nowMs / intervalMs).ceil() * intervalMs; + + final spots = + events.map((e) { + final isNew = e.eventType == 'NewDevice'; + return ScatterSpot( + e.createdOn.millisecondsSinceEpoch.toDouble(), + 1.0, + dotPainter: FlDotCirclePainter( + radius: isNew ? 9.0 : 6.0, + color: theme.colorScheme.tertiary, + ), + ); + }).toList(); + + return ScatterChart( + ScatterChartData( + scatterSpots: spots, + minX: minX, + maxX: maxX, + minY: 0.0, + maxY: 2.0, + borderData: FlBorderData(show: false), + gridData: FlGridData( + show: true, + drawHorizontalLine: false, + verticalInterval: range.xIntervalMs, + ), + titlesData: FlTitlesData( + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: range.xIntervalMs, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final dt = DateTime.fromMillisecondsSinceEpoch(value.toInt()); + return SideTitleWidget( + meta: meta, + fitInside: SideTitleFitInsideData.fromTitleMeta(meta), + child: Text( + range.formatLabel(dt), + style: theme.textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + scatterTouchData: ScatterTouchData( + touchTooltipData: ScatterTouchTooltipData( + maxContentWidth: 260, + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => theme.colorScheme.surfaceContainerHighest, + getTooltipItems: (ScatterSpot touchedSpot) { + final idx = events.indexWhere( + (e) => + e.createdOn.millisecondsSinceEpoch.toDouble() == + touchedSpot.x, + ); + if (idx < 0) return null; + + final event = events[idx]; + final dt = event.createdOn.toLocal(); + final dateStr = DateFormat('MMM d, yyyy HH:mm').format(dt); + final typeLabel = + event.eventType == 'NewDevice' ? 'First seen' : 'Device seen'; + + final diffs = []; + if (event.ipv4Address != device.ipv4Address) { + diffs.add( + TextSpan( + text: '\nIP: ${event.ipv4Address} → ${device.ipv4Address}', + style: TextStyle( + color: theme.colorScheme.error, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } + if (event.vendor != device.vendor) { + final eventVendor = + event.vendor.isEmpty ? '(unknown)' : event.vendor; + final currentVendor = + device.vendor.isEmpty ? '(unknown)' : device.vendor; + diffs.add( + TextSpan( + text: '\nVendor: $eventVendor → $currentVendor', + style: TextStyle( + color: theme.colorScheme.error, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + return ScatterTooltipItem( + '$dateStr\n$typeLabel', + textStyle: TextStyle( + color: theme.colorScheme.onSurface, + fontSize: 12, + ), + children: diffs.isEmpty ? null : diffs, + ); + }, + ), + ), + ), + ); + } +} diff --git a/frontend/lib/model/device_event.dart b/frontend/lib/model/device_event.dart new file mode 100644 index 0000000..697c991 --- /dev/null +++ b/frontend/lib/model/device_event.dart @@ -0,0 +1,28 @@ +class DeviceEvent { + final int id; + final String macAddress; + final DateTime createdOn; + final String eventType; + final String ipv4Address; + final String vendor; + + const DeviceEvent({ + required this.id, + required this.macAddress, + required this.createdOn, + required this.eventType, + required this.ipv4Address, + required this.vendor, + }); + + factory DeviceEvent.fromJson(Map json) { + return DeviceEvent( + id: json['id'] as int, + macAddress: json['mac_address'] as String, + createdOn: DateTime.parse(json['created_on'] as String), + eventType: json['event_type'] as String, + ipv4Address: json['ipv4_address'] as String, + vendor: json['vendor'] as String, + ); + } +} diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 339f047..5d0e0c9 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -5,6 +5,7 @@ import 'package:encrypter/encrypter/xor.dart'; import 'package:flutter/foundation.dart'; import 'package:frontend/utils/pref_utils.dart'; import '../model/device.dart'; +import '../model/device_event.dart'; import '../model/device_type.dart'; import '../model/notification.dart'; @@ -136,6 +137,15 @@ class BackendAPI { await _dio.delete('/devices/$macAddress'); } + Future> getDeviceEvents(String macAddress) async { + debugPrint('About to call GET /devices/$macAddress/events'); + final response = await _dio.get('/devices/$macAddress/events'); + debugPrint('Received: ${response.data}'); + return (response.data as List) + .map((item) => DeviceEvent.fromJson(item as Map)) + .toList(); + } + Future> listNotifications(bool? isNew, int offset) async { debugPrint('About to call /notifications'); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index f7d9361..ad8a6df 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter: dependency: "direct main" description: flutter diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 63be7e0..083dfce 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: encrypter: ^2.0.0 shared_preferences: ^2.5.4 infinite_scroll_pagination: ^5.1.1 + fl_chart: ^1.2.0 dev_dependencies: flutter_test: