mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add device event history chart to device details page
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e8df0a3980
commit
8e3eb96371
@@ -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<DeviceDetail> {
|
||||
_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<DeviceDetail> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<DeviceEventHistory> createState() => _DeviceEventHistoryState();
|
||||
}
|
||||
|
||||
class _DeviceEventHistoryState extends State<DeviceEventHistory> {
|
||||
List<DeviceEvent>? _allEvents;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
_TimeRange _selectedRange = _TimeRange.lastWeek;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEvents();
|
||||
}
|
||||
|
||||
Future<void> _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<DeviceEvent> 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<DeviceEvent> 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 = <TextSpan>[];
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<DeviceEvent>> 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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<Notification>> listNotifications(bool? isNew, int offset) async {
|
||||
debugPrint('About to call /notifications');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user