mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add DeviceChanged and DeviceBackOnline event types
Record what happened on each known-device sighting instead of only "seen": a baseline DeviceSeen (history heartbeat, no notification) plus DeviceChanged and DeviceBackOnline events, each deduplicated independently so a recent routine sighting no longer suppresses a genuine change or return notification. The frontend chart now trusts the event type for its marker and tooltip rather than comparing each event's snapshot against the device's current state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
27cb2e1d62
commit
9efe84c099
@@ -9,7 +9,6 @@ import '../model/device_event_type.dart';
|
||||
import '../utils/oott_api.dart';
|
||||
import '../widgets/filter_selector.dart';
|
||||
import '../theme/dimens.dart';
|
||||
import '../utils/placeholders.dart';
|
||||
|
||||
enum _TimeRange {
|
||||
today('Today'),
|
||||
@@ -150,11 +149,7 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
|
||||
else
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: _EventChart(
|
||||
events: events,
|
||||
device: widget.device,
|
||||
range: _selectedRange,
|
||||
),
|
||||
child: _EventChart(events: events, range: _selectedRange),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -163,14 +158,29 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
|
||||
|
||||
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,
|
||||
});
|
||||
const _EventChart({required this.events, required this.range});
|
||||
|
||||
// Marker radius and colour per event type. The type itself conveys what happened, so the chart
|
||||
// trusts it rather than comparing each event's snapshot against the device's current state.
|
||||
static ({double radius, Color color}) _markerStyle(
|
||||
DeviceEventType type,
|
||||
ColorScheme scheme,
|
||||
) => switch (type) {
|
||||
DeviceEventType.newDevice => (radius: 9.0, color: scheme.tertiary),
|
||||
DeviceEventType.deviceSeen => (radius: 6.0, color: scheme.tertiary),
|
||||
DeviceEventType.deviceChanged => (radius: 7.0, color: scheme.error),
|
||||
DeviceEventType.deviceBackOnline => (radius: 7.0, color: scheme.primary),
|
||||
};
|
||||
|
||||
// Human-readable label for an event type, shown in the tooltip.
|
||||
static String _eventLabel(DeviceEventType type) => switch (type) {
|
||||
DeviceEventType.newDevice => 'First seen',
|
||||
DeviceEventType.deviceSeen => 'Device seen',
|
||||
DeviceEventType.deviceChanged => 'Device changed',
|
||||
DeviceEventType.deviceBackOnline => 'Device back online',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -187,13 +197,13 @@ class _EventChart extends StatelessWidget {
|
||||
final maxX = (nowMs / intervalMs).ceil() * intervalMs;
|
||||
|
||||
final spots = events.map((e) {
|
||||
final isNew = e.eventType == DeviceEventType.newDevice;
|
||||
final style = _markerStyle(e.eventType, theme.colorScheme);
|
||||
return ScatterSpot(
|
||||
e.createdOn.millisecondsSinceEpoch.toDouble(),
|
||||
1.0,
|
||||
dotPainter: FlDotCirclePainter(
|
||||
radius: isNew ? 9.0 : 6.0,
|
||||
color: theme.colorScheme.tertiary,
|
||||
radius: style.radius,
|
||||
color: style.color,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
@@ -257,42 +267,8 @@ class _EventChart extends StatelessWidget {
|
||||
final event = events[idx];
|
||||
final dt = event.createdOn.toLocal();
|
||||
final dateStr = DateFormat('MMM d, yyyy HH:mm').format(dt);
|
||||
final baseLabel = event.eventType == DeviceEventType.newDevice
|
||||
? 'First seen'
|
||||
: 'Device seen';
|
||||
final typeLabel = '$baseLabel (${event.scannerLabel})';
|
||||
|
||||
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
|
||||
? Placeholders.emptyValue
|
||||
: event.vendor;
|
||||
final currentVendor = device.vendor.isEmpty
|
||||
? Placeholders.emptyValue
|
||||
: device.vendor;
|
||||
diffs.add(
|
||||
TextSpan(
|
||||
text: '\nVendor: $eventVendor → $currentVendor',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final typeLabel =
|
||||
'${_eventLabel(event.eventType)} (${event.scannerLabel})';
|
||||
|
||||
return ScatterTooltipItem(
|
||||
'$dateStr\n$typeLabel',
|
||||
@@ -300,7 +276,6 @@ class _EventChart extends StatelessWidget {
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 12,
|
||||
),
|
||||
children: diffs.isEmpty ? null : diffs,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/// Type of a recorded device event, mirroring the backend's `DeviceEventType`.
|
||||
enum DeviceEventType {
|
||||
newDevice('NewDevice'),
|
||||
deviceSeen('DeviceSeen');
|
||||
deviceSeen('DeviceSeen'),
|
||||
deviceChanged('DeviceChanged'),
|
||||
deviceBackOnline('DeviceBackOnline');
|
||||
|
||||
const DeviceEventType(this.wireValue);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ void main() {
|
||||
|
||||
expect(parsed('NewDevice'), DeviceEventType.newDevice);
|
||||
expect(parsed('DeviceSeen'), DeviceEventType.deviceSeen);
|
||||
expect(parsed('DeviceChanged'), DeviceEventType.deviceChanged);
|
||||
expect(parsed('DeviceBackOnline'), DeviceEventType.deviceBackOnline);
|
||||
expect(parsed('something_unexpected'), DeviceEventType.deviceSeen);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/devices/device_event_history.dart';
|
||||
import 'package:frontend/model/device.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
|
||||
import '../helpers/backend_test_harness.dart';
|
||||
import '../helpers/fixtures.dart';
|
||||
import '../helpers/pump_app.dart';
|
||||
|
||||
void main() {
|
||||
late DioAdapter adapter;
|
||||
const mac = 'aa:bb:cc:dd:ee:ff';
|
||||
|
||||
setUp(() async {
|
||||
adapter = await setUpBackendForTest();
|
||||
});
|
||||
|
||||
Device device() => Device.fromJson(deviceJson(macAddress: mac));
|
||||
|
||||
testWidgets('renders the chart for events of every type', (tester) async {
|
||||
adapter.onGet(
|
||||
'/devices/$mac/events',
|
||||
(server) => server.reply(200, [
|
||||
deviceEventJson(id: 1, eventType: 'NewDevice'),
|
||||
deviceEventJson(id: 2, eventType: 'DeviceSeen'),
|
||||
deviceEventJson(id: 3, eventType: 'DeviceChanged'),
|
||||
deviceEventJson(id: 4, eventType: 'DeviceBackOnline'),
|
||||
]),
|
||||
// The widget always sends a (dynamic, now-relative) created_from filter.
|
||||
queryParameters: {'created_from': Matchers.any},
|
||||
);
|
||||
|
||||
await pumpScreen(tester, DeviceEventHistory(device: device()));
|
||||
await pumpUntilFound(tester, find.byType(ScatterChart));
|
||||
|
||||
expect(find.byType(ScatterChart), findsOneWidget);
|
||||
expect(find.text('No events in this time range'), findsNothing);
|
||||
|
||||
await tearDownTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('shows an empty-state message when there are no events', (
|
||||
tester,
|
||||
) async {
|
||||
adapter.onGet(
|
||||
'/devices/$mac/events',
|
||||
(server) => server.reply(200, <dynamic>[]),
|
||||
queryParameters: {'created_from': Matchers.any},
|
||||
);
|
||||
|
||||
await pumpScreen(tester, DeviceEventHistory(device: device()));
|
||||
await pumpUntilFound(tester, find.text('No events in this time range'));
|
||||
|
||||
expect(find.byType(ScatterChart), findsNothing);
|
||||
|
||||
await tearDownTree(tester);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user