diff --git a/TODO.md b/TODO.md index 70fa3f6..1068885 100644 --- a/TODO.md +++ b/TODO.md @@ -26,15 +26,15 @@ - [x] Style app title like favicon (font Barlow Condensed in a pill format with "primary" background) - [x] Update the device type management to consider the following list: phone, laptop, tablet, server, tv, printer, network_appliance (router, switch, firewall, etc.), home_security (camera, doorbell, etc.), home_appliance (fridge, dish washer, washer, dryer, etc.), watch, pc, gaming_console, unknown (use when a vendor cannot be clearly identified with any of the device types or a device is of a vendor not in the vendors json file) - [x] Change the unkown device icon (maybe just a question mark) -- [ ] Scan process monitor and summary page - - [ ] Is the scan process running - - [ ] Last run (when, how long did it take, how many devices did it found) - - [ ] When is the next run +- [x] Scan process monitor and summary page + - [x] Is the scan process running + - [x] Last run (when, how long did it take, how many devices did it found) + - [x] When is the next run - [ ] Rework notifications page into a homepage for the app - [ ] Notifications list - [ ] Summary of devices recorded (how many in total, how many seen in the last day, how many not seen for a week) - [ ] Scanning process summary (is it running, when will it run again) -- [ ] About page +- [x] About page ## Improve engine diff --git a/backend/database_migrations/07-add_devices_indexes/up.sql b/backend/database_migrations/07-add_devices_indexes/up.sql new file mode 100644 index 0000000..2cb3d97 --- /dev/null +++ b/backend/database_migrations/07-add_devices_indexes/up.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_devices_is_registered ON devices (is_registered); +CREATE INDEX idx_devices_last_seen_is_registered ON devices (last_seen, is_registered); diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index 58231bf..1a9232e 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -1,10 +1,10 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use log::{debug, error}; use rusqlite::{params, params_from_iter}; use crate::{ db::{self, error::DbError}, - model::devices::Device, + model::devices::{Device, DeviceSummary}, }; pub fn list_devices( @@ -122,6 +122,47 @@ pub fn insert(device: Device) -> Result<(), DbError> { } } +pub fn get_summary() -> Result { + debug!("Getting device summary"); + let conn = db::get_db_connection(); + let one_day_ago = (Utc::now() - Duration::days(1)).to_rfc3339(); + let one_week_ago = (Utc::now() - Duration::weeks(1)).to_rfc3339(); + + let total_registered: i64 = conn.query_row( + "SELECT COUNT(*) FROM devices WHERE is_registered = 1", + [], + |row| row.get(0), + )?; + let seen_last_day_registered: i64 = conn.query_row( + "SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 1", + params![one_day_ago], + |row| row.get(0), + )?; + let seen_last_day_unregistered: i64 = conn.query_row( + "SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 0", + params![one_day_ago], + |row| row.get(0), + )?; + let seen_last_week_registered: i64 = conn.query_row( + "SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 1", + params![one_week_ago], + |row| row.get(0), + )?; + let seen_last_week_unregistered: i64 = conn.query_row( + "SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 0", + params![one_week_ago], + |row| row.get(0), + )?; + + Ok(DeviceSummary { + total_registered, + seen_last_day_registered, + seen_last_day_unregistered, + seen_last_week_registered, + seen_last_week_unregistered, + }) +} + pub fn update(device: Device) -> Result<(), DbError> { let conn = db::get_db_connection(); match conn.execute( @@ -432,6 +473,59 @@ mod tests { ); } + #[tokio::test] + async fn test_get_summary() { + tests_common::setup().await; + + // Insert a registered device seen now + insert(Device { + mac_address: "su:mm:ar:y1:01:01".to_string(), + ipv4_address: "192.168.50.1".to_string(), + vendor: "Test".to_string(), + last_seen: Utc::now(), + is_registered: true, + owner: "Test".to_string(), + device_type: "Server".to_string(), + }) + .unwrap(); + + // Insert an unregistered device seen now + insert(Device { + mac_address: "su:mm:ar:y1:02:02".to_string(), + ipv4_address: "192.168.50.2".to_string(), + vendor: "Test".to_string(), + last_seen: Utc::now(), + is_registered: false, + owner: "".to_string(), + device_type: "".to_string(), + }) + .unwrap(); + + let summary = get_summary().unwrap(); + + // bb and cc from seed data are registered, plus our new one + assert!( + summary.total_registered >= 3, + "Should have at least 3 registered devices" + ); + assert!( + summary.seen_last_day_registered >= 1, + "Should have at least 1 registered device seen in last day" + ); + assert!( + summary.seen_last_day_unregistered >= 1, + "Should have at least 1 unregistered device seen in last day" + ); + assert!( + summary.seen_last_week_registered >= 1, + "Should have at least 1 registered device seen in last week" + ); + assert!( + summary.seen_last_week_unregistered >= 1, + "Should have at least 1 unregistered device seen in last week" + ); + } + fn validate_device( device: Device, mac_address: String, diff --git a/backend/src/model/devices.rs b/backend/src/model/devices.rs index 1da25de..0c24710 100644 --- a/backend/src/model/devices.rs +++ b/backend/src/model/devices.rs @@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize}; use std::fmt; use utoipa::ToSchema; +#[derive(Serialize, ToSchema)] +pub struct DeviceSummary { + pub total_registered: i64, + pub seen_last_day_registered: i64, + pub seen_last_day_unregistered: i64, + pub seen_last_week_registered: i64, + pub seen_last_week_unregistered: i64, +} + #[derive(Clone, Serialize, Deserialize, ToSchema)] pub struct Device { pub mac_address: String, diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index a5e07aa..330b6e5 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -1,7 +1,7 @@ use std::error::Error; use crate::model::device_events::{DeviceEvent, DeviceEventType}; -use crate::model::devices::Device; +use crate::model::devices::{Device, DeviceSummary}; use crate::model::notifications::{Notification, NotificationType}; use crate::settings::get_settings; use crate::web_server::arp_scanner::ArpScannerStatusResponse; @@ -38,6 +38,7 @@ pub mod utils; paths( test_api, devices::list, + devices::summary, devices::read, devices::register, devices::unregister, @@ -51,6 +52,7 @@ pub mod utils; ), components(schemas( Device, + DeviceSummary, Notification, NotificationType, RegisterDevicePayload, @@ -103,6 +105,7 @@ pub async fn serve() -> Result<(), Box> { .route("/api/test", get(test_api)) .route("/api/devices", get(devices::list)) .route("/api/devices", put(devices::register)) + .route("/api/devices/summary", get(devices::summary)) .route("/api/devices/{mac_address}", delete(devices::unregister)) .route("/api/devices/{mac_address}", get(devices::read)) .route("/api/devices/{mac_address}/events", get(device_events::list)) diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index 9207d47..60818eb 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -8,7 +8,7 @@ use log::{debug, error}; use serde::Deserialize; use utoipa::ToSchema; -use crate::{db, model::devices::Device}; +use crate::{db, model::devices::{Device, DeviceSummary}}; use crate::web_server::utils; @@ -177,6 +177,26 @@ pub async fn unregister(Path(mac_address): Path) -> impl IntoResponse { } } +#[utoipa::path( + get, + path = "/api/devices/summary", + tag = "devices", + responses( + (status = 200, description = "Device summary counts", body = DeviceSummary), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn summary() -> Result, StatusCode> { + match db::devices::get_summary() { + Ok(value) => Ok(Json(value)), + Err(err) => { + error!("Error getting device summary: {}", err); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + // Payload structs #[derive(Deserialize, ToSchema)] pub struct RegisterDevicePayload { diff --git a/frontend/lib/home/home_screen.dart b/frontend/lib/home/home_screen.dart new file mode 100644 index 0000000..ae695c3 --- /dev/null +++ b/frontend/lib/home/home_screen.dart @@ -0,0 +1,514 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +import '../model/arp_scanner_status.dart'; +import '../model/device_summary.dart'; +import '../model/notification.dart' as oott_model; +import '../utils/friendly_date_formatter.dart'; +import '../utils/oott_api.dart'; +import '../utils/ui_snackbars.dart'; +import '../widgets/arp_scanner_card.dart'; + +const _twoColumnBreakpoint = 700.0; + +enum _NotificationFilter { + newOnly('New'), + oldOnly('Old'), + all('All'); + + const _NotificationFilter(this.label); + + final String label; + + bool? get isNew => switch (this) { + _NotificationFilter.newOnly => true, + _NotificationFilter.oldOnly => false, + _NotificationFilter.all => null, + }; +} + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + // Notification state + _NotificationFilter _filter = _NotificationFilter.newOnly; + Timer? _notificationTimer; + + late final _pagingController = + PagingController( + getNextPageKey: (state) => state.lastPageIsEmpty + ? null + : (state.items == null ? 0 : state.items?.length), + fetchPage: (pageKey) => + BackendAPI.instance.listNotifications(_filter.isNew, pageKey), + ); + + // Device summary state + DeviceSummary? _deviceSummary; + bool _isLoadingDeviceSummary = true; + String? _deviceSummaryError; + Timer? _deviceSummaryTimer; + + // Scanner status state + ArpScannerStatus? _scannerStatus; + DateTime? _scannerStatusReceivedAt; + bool _isLoadingScanner = true; + String? _scannerError; + Timer? _scannerRefreshTimer; + Timer? _scannerTickTimer; + + @override + void initState() { + super.initState(); + _notificationTimer = Timer.periodic( + const Duration(minutes: 1), + (_) => _pagingController.refresh(), + ); + _loadDeviceSummary(); + _deviceSummaryTimer = Timer.periodic( + const Duration(minutes: 1), + (_) => _loadDeviceSummary(), + ); + _loadScannerStatus(); + _scannerRefreshTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => _loadScannerStatus(), + ); + _scannerTickTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + if (mounted) setState(() {}); + }, + ); + } + + @override + void dispose() { + _notificationTimer?.cancel(); + _pagingController.dispose(); + _deviceSummaryTimer?.cancel(); + _scannerRefreshTimer?.cancel(); + _scannerTickTimer?.cancel(); + super.dispose(); + } + + Future _loadDeviceSummary() async { + try { + final summary = await BackendAPI.instance.getDeviceSummary(); + if (!mounted) return; + setState(() { + _deviceSummary = summary; + _deviceSummaryError = null; + _isLoadingDeviceSummary = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _deviceSummaryError = e.toString(); + _isLoadingDeviceSummary = false; + }); + } + } + + Future _loadScannerStatus() async { + try { + final status = await BackendAPI.instance.getArpScannerStatus(); + if (!mounted) return; + setState(() { + _scannerStatus = status; + _scannerStatusReceivedAt = DateTime.now(); + _scannerError = null; + _isLoadingScanner = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _scannerError = e.toString(); + _isLoadingScanner = false; + }); + } + } + + Future _markAllAsRead(BuildContext context) async { + await BackendAPI.instance.markAllNotificationsAsRead(); + _pagingController.refresh(); + if (context.mounted) { + UISnackbars.showSuccess(context, 'All notifications marked as read'); + } + } + + Future _setRead( + BuildContext context, + oott_model.Notification item, + bool read, + ) async { + if (item.isNew == !read) { + UISnackbars.showWarning( + context, + 'Notification was already marked as ${read ? 'read' : 'unread'}', + ); + return false; + } + if (read) { + await BackendAPI.instance.markNotificationAsRead(item.id); + } else { + await BackendAPI.instance.markNotificationAsNew(item.id); + } + if (!context.mounted) return false; + UISnackbars.showSuccess( + context, + 'Event marked as ${read ? 'read' : 'unread'}', + ); + if (_filter != _NotificationFilter.all) { + _pagingController.value = _pagingController.value.filterItems( + (n) => n.id != item.id, + ); + return true; + } + _pagingController.mapItems( + (n) => n.id == item.id ? n.copyWith(isNew: !read) : n, + ); + return false; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint; + return PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => isTwoColumn + ? _buildTwoColumn(context, state, fetchNextPage) + : _buildSingleColumn(context, state, fetchNextPage), + ); + }, + ); + } + + Widget _buildTwoColumn( + BuildContext context, + PagingState state, + void Function() fetchNextPage, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildNotificationsHeader(context, state), + Expanded( + child: CustomScrollView( + slivers: [_buildNotificationSliver(state, fetchNextPage)], + ), + ), + ], + ), + ), + const VerticalDivider(width: 32), + SizedBox( + width: 300, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDeviceSummaryCard(context), + const SizedBox(height: 16), + _buildScannerCard(), + ], + ), + ), + ), + ], + ); + } + + Widget _buildSingleColumn( + BuildContext context, + PagingState state, + void Function() fetchNextPage, + ) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: _buildNotificationsHeader(context, state)), + _buildNotificationSliver(state, fetchNextPage), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: _buildDeviceSummaryCard(context), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 16, bottom: 20), + child: _buildScannerCard(), + ), + ), + ], + ); + } + + Widget _buildNotificationsHeader( + BuildContext context, + PagingState state, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Notifications', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + if (_filter == _NotificationFilter.newOnly && + (state.items?.isNotEmpty ?? false)) + IconButton( + onPressed: () => _markAllAsRead(context), + icon: const Icon(Icons.done_all), + tooltip: 'Mark all as read', + ), + ], + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 8.0, + children: _NotificationFilter.values + .map( + (f) => ChoiceChip( + label: Text(f.label), + selected: _filter == f, + onSelected: (_) { + setState(() => _filter = f); + _pagingController.refresh(); + }, + ), + ) + .toList(), + ), + ), + const SizedBox(height: 8), + ], + ); + } + + Widget _buildNotificationSliver( + PagingState state, + void Function() fetchNextPage, + ) { + final formatter = FriendlyDateFormatter(); + return PagedSliverList( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => _NotificationCard( + item: item, + formatter: formatter, + onSetRead: (ctx, read) => _setRead(ctx, item, read), + ), + ), + ); + } + + Widget _buildDeviceSummaryCard(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Devices', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + if (_isLoadingDeviceSummary) + const Center(child: CircularProgressIndicator()) + else if (_deviceSummaryError != null) + Text( + 'Error loading device summary', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ) + else if (_deviceSummary != null) ...[ + _SummaryRow( + label: 'Registered', + value: '${_deviceSummary!.totalRegistered}', + ), + const Divider(height: 20), + Text( + 'Seen in the last 24 hours', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 6), + _SummaryRow( + label: 'Registered', + value: '${_deviceSummary!.seenLastDayRegistered}', + ), + _SummaryRow( + label: 'Unregistered', + value: '${_deviceSummary!.seenLastDayUnregistered}', + ), + const Divider(height: 20), + Text( + 'Seen in the last 7 days', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 6), + _SummaryRow( + label: 'Registered', + value: '${_deviceSummary!.seenLastWeekRegistered}', + ), + _SummaryRow( + label: 'Unregistered', + value: '${_deviceSummary!.seenLastWeekUnregistered}', + ), + ], + ], + ), + ), + ); + } + + Widget _buildScannerCard() { + return ArpScannerCard( + status: _scannerStatus, + statusReceivedAt: _scannerStatusReceivedAt, + error: _scannerError, + isLoading: _isLoadingScanner, + ); + } +} + +class _SummaryRow extends StatelessWidget { + final String label; + final String value; + + const _SummaryRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +class _NotificationCard extends StatelessWidget { + final oott_model.Notification item; + final FriendlyDateFormatter formatter; + final Future Function(BuildContext, bool read) onSetRead; + + const _NotificationCard({ + required this.item, + required this.formatter, + required this.onSetRead, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + color: item.isNew ? theme.colorScheme.secondaryContainer : null, + child: Dismissible( + key: UniqueKey(), + confirmDismiss: (direction) => direction == DismissDirection.startToEnd + ? onSetRead(context, false) + : onSetRead(context, true), + background: Container( + color: theme.colorScheme.tertiaryContainer, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 16), + child: const Icon(Icons.mark_email_unread), + ), + secondaryBackground: Container( + color: theme.colorScheme.primaryContainer, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon(Icons.done), + ), + child: ListTile( + leading: Icon( + item.notificationType.icon, + color: item.isNew ? theme.colorScheme.primary : null, + ), + title: Text( + '${formatter.format(item.createdOn)} - ${item.title}', + style: item.isNew + ? const TextStyle(fontWeight: FontWeight.bold) + : null, + ), + subtitle: Text(item.body, maxLines: 5), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + if (value == 'view_device') { + if (item.isNew) await onSetRead(context, true); + if (context.mounted) { + context.push('/devices/${item.macAddress}'); + } + } else if (value == 'mark_read') { + await onSetRead(context, true); + } else if (value == 'mark_new') { + await onSetRead(context, false); + } + }, + itemBuilder: (context) => [ + if (item.macAddress != null) + const PopupMenuItem( + value: 'view_device', + child: Text('View device'), + ), + if (item.isNew) + const PopupMenuItem( + value: 'mark_read', + child: Text('Mark as read'), + ), + if (!item.isNew) + const PopupMenuItem( + value: 'mark_new', + child: Text('Mark as unread'), + ), + ], + ), + onTap: item.macAddress != null + ? () async { + if (item.isNew) await onSetRead(context, true); + if (context.mounted) { + context.push('/devices/${item.macAddress}'); + } + } + : null, + isThreeLine: true, + ), + ), + ); + } +} diff --git a/frontend/lib/model/device_summary.dart b/frontend/lib/model/device_summary.dart new file mode 100644 index 0000000..57ec0ce --- /dev/null +++ b/frontend/lib/model/device_summary.dart @@ -0,0 +1,22 @@ +class DeviceSummary { + final int totalRegistered; + final int seenLastDayRegistered; + final int seenLastDayUnregistered; + final int seenLastWeekRegistered; + final int seenLastWeekUnregistered; + + const DeviceSummary({ + required this.totalRegistered, + required this.seenLastDayRegistered, + required this.seenLastDayUnregistered, + required this.seenLastWeekRegistered, + required this.seenLastWeekUnregistered, + }); + + DeviceSummary.fromJson(Map json) + : totalRegistered = json['total_registered'] as int, + seenLastDayRegistered = json['seen_last_day_registered'] as int, + seenLastDayUnregistered = json['seen_last_day_unregistered'] as int, + seenLastWeekRegistered = json['seen_last_week_registered'] as int, + seenLastWeekUnregistered = json['seen_last_week_unregistered'] as int; +} diff --git a/frontend/lib/navigation.dart b/frontend/lib/navigation.dart index 37bde3b..7ac9829 100644 --- a/frontend/lib/navigation.dart +++ b/frontend/lib/navigation.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'devices/device_detail.dart'; import 'devices/device_list.dart'; -import 'notifications/notification_list.dart'; +import 'home/home_screen.dart'; import 'status/status_screen.dart'; import 'utils/pref_utils.dart'; @@ -22,7 +22,7 @@ final GoRouter router = GoRouter( GoRoute( path: '/notifications', name: 'notifications', - builder: (context, state) => NotificationList(), + builder: (context, state) => const HomeScreen(), redirect: (context, state) => _redirectToSettings(), ), GoRoute( @@ -103,9 +103,9 @@ class MainShell extends StatelessWidget { extended: constraints.maxWidth >= 600, destinations: [ NavigationRailDestination( - icon: Icon(Icons.notifications_outlined), - selectedIcon: Icon(Icons.notifications), - label: Text('Notifications'), + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: Text('Home'), ), NavigationRailDestination( icon: Icon(Icons.devices_other_outlined), diff --git a/frontend/lib/notifications/notification_list.dart b/frontend/lib/notifications/notification_list.dart deleted file mode 100644 index 91ce5d5..0000000 --- a/frontend/lib/notifications/notification_list.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; - -import '../model/notification.dart' as oott_model; -import '../utils/friendly_date_formatter.dart'; -import '../utils/oott_api.dart'; -import '../utils/ui_snackbars.dart'; - -enum _NotificationFilter { - newOnly('New'), - oldOnly('Old'), - all('All'); - - const _NotificationFilter(this.label); - - final String label; - - bool? get isNew => switch (this) { - _NotificationFilter.newOnly => true, - _NotificationFilter.oldOnly => false, - _NotificationFilter.all => null, - }; -} - -class NotificationList extends StatefulWidget { - const NotificationList({super.key}); - - @override - State createState() => _NotificationListState(); -} - -class _NotificationListState extends State { - _NotificationFilter _filter = _NotificationFilter.newOnly; - Timer? _refreshTimer; - - @override - void initState() { - super.initState(); - _refreshTimer = Timer.periodic( - const Duration(minutes: 1), - (_) => _pagingController.refresh(), - ); - } - - late final _pagingController = PagingController( - getNextPageKey: (state) => state.lastPageIsEmpty - ? null - : (state.items == null ? 0 : state.items?.length), - fetchPage: (pageKey) => - BackendAPI.instance.listNotifications(_filter.isNew, pageKey), - ); - - Future _markAllAsRead(BuildContext context) async { - await BackendAPI.instance.markAllNotificationsAsRead(); - _pagingController.refresh(); - if (context.mounted) { - UISnackbars.showSuccess(context, 'All notifications marked as read'); - } - } - - Future _setRead( - BuildContext context, - oott_model.Notification item, - bool read, - ) async { - if (item.isNew == !read) { - UISnackbars.showWarning( - context, - 'Notification was already marked as ${read ? 'read' : 'unread'}', - ); - return false; - } - if (read) { - await BackendAPI.instance.markNotificationAsRead(item.id); - } else { - await BackendAPI.instance.markNotificationAsNew(item.id); - } - if (!context.mounted) return false; - UISnackbars.showSuccess( - context, - 'Event marked as ${read ? 'read' : 'unread'}', - ); - if (_filter != _NotificationFilter.all) { - _pagingController.value = _pagingController.value.filterItems( - (n) => n.id != item.id, - ); - return true; - } - _pagingController.mapItems( - (n) => n.id == item.id ? n.copyWith(isNew: !read) : n, - ); - return false; - } - - @override - Widget build(BuildContext context) { - final formatter = FriendlyDateFormatter(); - - return PagingListener( - controller: _pagingController, - builder: (context, state, fetchNextPage) => Scaffold( - appBar: AppBar( - title: const Text('Notifications'), - actions: [ - if (_filter == _NotificationFilter.newOnly && - (state.items?.isNotEmpty ?? false)) - IconButton( - onPressed: () => _markAllAsRead(context), - icon: const Icon(Icons.done_all), - tooltip: 'Mark all as read', - ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(48), - child: Align( - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Wrap( - spacing: 8.0, - children: _NotificationFilter.values - .map( - (f) => ChoiceChip( - label: Text(f.label), - selected: _filter == f, - onSelected: (_) { - setState(() => _filter = f); - _pagingController.refresh(); - }, - ), - ) - .toList(), - ), - ), - ), - ), - ), - body: CustomScrollView( - slivers: [ - PagedSliverList( - state: state, - fetchNextPage: fetchNextPage, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => _NotificationCard( - item: item, - formatter: formatter, - onSetRead: (ctx, read) => _setRead(ctx, item, read), - ), - ), - ), - ], - ), - ), - ); - } - - @override - void dispose() { - _refreshTimer?.cancel(); - _pagingController.dispose(); - super.dispose(); - } -} - -class _NotificationCard extends StatelessWidget { - final oott_model.Notification item; - final FriendlyDateFormatter formatter; - final Future Function(BuildContext, bool read) onSetRead; - - const _NotificationCard({ - required this.item, - required this.formatter, - required this.onSetRead, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - color: item.isNew ? theme.colorScheme.secondaryContainer : null, - child: Dismissible( - key: UniqueKey(), - confirmDismiss: (direction) => direction == DismissDirection.startToEnd - ? onSetRead(context, false) - : onSetRead(context, true), - background: Container( - color: theme.colorScheme.tertiaryContainer, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 16), - child: const Icon(Icons.mark_email_unread), - ), - secondaryBackground: Container( - color: theme.colorScheme.primaryContainer, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - child: const Icon(Icons.done), - ), - child: ListTile( - leading: Icon( - item.notificationType.icon, - color: item.isNew ? theme.colorScheme.primary : null, - ), - title: Text( - '${formatter.format(item.createdOn)} - ${item.title}', - style: item.isNew - ? const TextStyle(fontWeight: FontWeight.bold) - : null, - ), - subtitle: Text(item.body, maxLines: 5), - trailing: PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) async { - if (value == 'view_device') { - if (item.isNew) await onSetRead(context, true); - if (context.mounted) { - context.push('/devices/${item.macAddress}'); - } - } else if (value == 'mark_read') { - await onSetRead(context, true); - } else if (value == 'mark_new') { - await onSetRead(context, false); - } - }, - itemBuilder: (context) => [ - if (item.macAddress != null) - const PopupMenuItem( - value: 'view_device', - child: Text('View device'), - ), - if (item.isNew) - const PopupMenuItem( - value: 'mark_read', - child: Text('Mark as read'), - ), - if (!item.isNew) - const PopupMenuItem( - value: 'mark_new', - child: Text('Mark as unread'), - ), - ], - ), - onTap: item.macAddress != null - ? () async { - if (item.isNew) await onSetRead(context, true); - if (context.mounted) { - context.push('/devices/${item.macAddress}'); - } - } - : null, - isThreeLine: true, - ), - ), - ); - } -} diff --git a/frontend/lib/status/status_screen.dart b/frontend/lib/status/status_screen.dart index f52f623..e1907d2 100644 --- a/frontend/lib/status/status_screen.dart +++ b/frontend/lib/status/status_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:frontend/model/arp_scanner_status.dart'; import 'package:frontend/utils/oott_api.dart'; +import 'package:frontend/widgets/arp_scanner_card.dart'; class StatusScreen extends StatefulWidget { const StatusScreen({super.key}); @@ -29,7 +30,9 @@ class _StatusScreenState extends State { ); _tickTimer = Timer.periodic( const Duration(seconds: 1), - (_) { if (mounted) setState(() {}); }, + (_) { + if (mounted) setState(() {}); + }, ); } @@ -66,100 +69,13 @@ class _StatusScreenState extends State { children: [ Text('Status', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 20), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else - _ArpScannerCard( - status: _status, - statusReceivedAt: _statusReceivedAt, - error: _error, - ), + ArpScannerCard( + status: _status, + statusReceivedAt: _statusReceivedAt, + error: _error, + isLoading: _isLoading, + ), ], ); } } - -class _ArpScannerCard extends StatelessWidget { - final ArpScannerStatus? status; - final DateTime? statusReceivedAt; - final String? error; - - const _ArpScannerCard({this.status, this.statusReceivedAt, this.error}); - - @override - Widget build(BuildContext context) { - final (color, label, sublabel) = _resolveState(context); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.circle, color: color, size: 14), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'ARP Scanner', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 2), - Text(label, style: Theme.of(context).textTheme.bodyMedium), - if (sublabel != null) ...[ - const SizedBox(height: 2), - Text( - sublabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ); - } - - (Color, String, String?) _resolveState(BuildContext context) { - if (error != null || status == null) { - return ( - Colors.red, - 'Error', - 'Unable to reach the server or a server-side error occurred. Check the logs for details.', - ); - } - - final elapsed = statusReceivedAt != null - ? DateTime.now().difference(statusReceivedAt!).inSeconds.toDouble() - : 0.0; - - if (status!.isRunning) { - final sub = status!.runningForSeconds != null - ? 'Running for ${_formatSeconds(status!.runningForSeconds! + elapsed)}' - : null; - return (Colors.green, 'Running', sub); - } - if (status!.nextRunInSeconds != null) { - final remaining = (status!.nextRunInSeconds! - elapsed).clamp(0.0, double.infinity); - return ( - Colors.amber, - 'Waiting for next run', - 'Next run in ${_formatSeconds(remaining)}', - ); - } - return (Colors.grey, 'Not yet started', null); - } -} - -String _formatSeconds(double seconds) { - final total = seconds.round().clamp(0, double.maxFinite.toInt()); - if (total < 60) return '${total}s'; - final m = total ~/ 60; - final s = total % 60; - return '${m}m ${s}s'; -} diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 43d4ba3..dc2b3b4 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -7,6 +7,7 @@ import 'package:frontend/utils/pref_utils.dart'; import '../model/arp_scanner_status.dart'; import '../model/device.dart'; import '../model/device_event.dart'; +import '../model/device_summary.dart'; import '../model/device_type.dart'; import '../model/notification.dart'; @@ -157,6 +158,13 @@ class BackendAPI { .toList(); } + Future getDeviceSummary() async { + debugPrint('About to call GET /devices/summary'); + final response = await _dio.get('/devices/summary'); + debugPrint('Received: ${response.data}'); + return DeviceSummary.fromJson(response.data as Map); + } + Future getArpScannerStatus() async { debugPrint('About to call GET /arp_scanner/status'); final response = await _dio.get('/arp_scanner/status'); diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart new file mode 100644 index 0000000..332bfc1 --- /dev/null +++ b/frontend/lib/widgets/arp_scanner_card.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:frontend/model/arp_scanner_status.dart'; + +class ArpScannerCard extends StatelessWidget { + final ArpScannerStatus? status; + final DateTime? statusReceivedAt; + final String? error; + final bool isLoading; + + const ArpScannerCard({ + super.key, + this.status, + this.statusReceivedAt, + this.error, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + final (color, label, sublabel) = _resolveState(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.circle, color: color, size: 14), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ARP Scanner', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + if (sublabel != null) ...[ + const SizedBox(height: 2), + Text( + sublabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + (Color, String, String?) _resolveState(BuildContext context) { + if (error != null || status == null) { + return ( + Colors.red, + 'Error', + 'Unable to reach the server or a server-side error occurred. Check the logs for details.', + ); + } + + final elapsed = statusReceivedAt != null + ? DateTime.now().difference(statusReceivedAt!).inSeconds.toDouble() + : 0.0; + + if (status!.isRunning) { + final sub = status!.runningForSeconds != null + ? 'Running for ${_formatSeconds(status!.runningForSeconds! + elapsed)}' + : null; + return (Colors.green, 'Running', sub); + } + if (status!.nextRunInSeconds != null) { + final remaining = (status!.nextRunInSeconds! - elapsed).clamp( + 0.0, + double.infinity, + ); + return ( + Colors.amber, + 'Waiting for next run', + 'Next run in ${_formatSeconds(remaining)}', + ); + } + return (Colors.grey, 'Not yet started', null); + } +} + +String _formatSeconds(double seconds) { + final total = seconds.round().clamp(0, double.maxFinite.toInt()); + if (total < 60) return '${total}s'; + final m = total ~/ 60; + final s = total % 60; + return '${m}m ${s}s'; +}