diff --git a/extras/log_file_client/lib/components/graph_view.dart b/extras/log_file_client/lib/components/graph_view.dart index 6ceb2fb0..8ce89a33 100644 --- a/extras/log_file_client/lib/components/graph_view.dart +++ b/extras/log_file_client/lib/components/graph_view.dart @@ -1,6 +1,5 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:log_file_client/components/chart_series_selector.dart'; import 'package:log_file_client/components/time_range_selector.dart'; import 'package:log_file_client/utils/http_client.dart'; @@ -9,10 +8,12 @@ import 'package:syncfusion_flutter_charts/charts.dart'; class GraphView extends StatefulWidget { const GraphView({ required this.logData, + this.now, super.key, }); final List logData; + final DateTime? now; @override State createState() => _GraphViewState(); @@ -21,19 +22,24 @@ class GraphView extends StatefulWidget { class _GraphViewState extends State { bool _showPH = true; bool _showTemp = true; + late final DateTime now; late final DateTimeRange avaliableTimeRange; - late List _displayedTimeRange = [ - max(widget.logData.length - 1400, 0), - widget.logData.length, - ]; // 1 day + late DateTimeRange displayedTimeRange; + late List _displayedTimeRangeIndices; + late DateTimeIntervalType timeIntervalType; + late double timeInterval; + late DateFormat timeFormat; @override void initState() { super.initState(); + now = widget.now ?? DateTime.now(); avaliableTimeRange = DateTimeRange( start: widget.logData.first.time, end: widget.logData.last.time, ); + toggleTimeRange(2, null); + calculateTimeAxisFormat(); } void toggleSeriesView(int index) { @@ -47,32 +53,69 @@ class _GraphViewState extends State { } void toggleTimeRange(int index, DateTimeRange? customRange) { - final ranges = [ - 360, // 6 hours - 720, // 12 hours - 1440, // 1 day - 4320, // 3 days - 10080, // 7 days - 43200, // 30 days - 9999999, // Max + // index: 0-5 are predefined ranges, 6 is max, 7 is custom + final List durations = [ + Duration(hours: 6), + Duration(hours: 12), + Duration(days: 1), + Duration(days: 3), + Duration(days: 7), + Duration(days: 30), ]; + late DateTimeRange timeRange; + if (index < 6) { + timeRange = DateTimeRange( + start: now.subtract(durations[index]), + end: now, + ); + } else if (index == 6) { + timeRange = avaliableTimeRange; + } else { + timeRange = customRange!; + } + setState(() { - if (index < 7) { - _displayedTimeRange = [ - max(widget.logData.length - ranges[index], 0), - widget.logData.length, - ]; - } else if (index == 7) { - final int endOffset = - avaliableTimeRange.end.difference(customRange!.end).inMinutes; - final int endIndex = widget.logData.length - endOffset; - final int startIndex = endIndex - customRange.duration.inMinutes; - _displayedTimeRange = [startIndex, endIndex]; - } + displayedTimeRange = timeRange; + _displayedTimeRangeIndices = calculateTimeRange(timeRange); + calculateTimeAxisFormat(); }); } + List calculateTimeRange(DateTimeRange range) { + int startIndex = 0; + int endIndex = widget.logData.length; + for (int i = widget.logData.length - 1; i >= 0; i--) { + if (widget.logData[i].time.compareTo(range.start) < 0) { + startIndex = i + 1; + break; + } + } + for (int i = widget.logData.length - 1; i >= 0; i--) { + if (widget.logData[i].time.compareTo(range.end) > 0) { + endIndex = i + 1; + break; + } + } + return [startIndex, endIndex]; + } + + void calculateTimeAxisFormat() { + timeInterval = 1; + + if (displayedTimeRange.duration <= Duration(hours: 24)) { + timeIntervalType = DateTimeIntervalType.hours; + timeFormat = DateFormat('h a'); + } else if (displayedTimeRange.duration <= Duration(days: 3)) { + timeIntervalType = DateTimeIntervalType.days; + timeFormat = DateFormat('h a\nMMM d'); + timeInterval = 0.5; + } else { + timeIntervalType = DateTimeIntervalType.days; + timeFormat = DateFormat('MMM d'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -83,8 +126,8 @@ class _GraphViewState extends State { _topRow(widget.logData), _graph( widget.logData.sublist( - _displayedTimeRange[0], - _displayedTimeRange[1], + _displayedTimeRangeIndices[0], + _displayedTimeRangeIndices[1], ), ), ], @@ -122,20 +165,18 @@ class _GraphViewState extends State { } Widget _graph(List logData) { + final List> trackballData = []; final trackballBehavior = TrackballBehavior( enable: true, tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, + activationMode: ActivationMode.singleTap, markerSettings: TrackballMarkerSettings( markerVisibility: TrackballVisibilityMode.visible, ), - tooltipSettings: InteractiveTooltip( - enable: true, - format: 'series.name : point.y', - ), - activationMode: ActivationMode.singleTap, lineColor: Colors.grey.shade600, lineWidth: 1.5, lineDashArray: [2, 2], + // builder: _trackballBuilder, ); return Expanded( @@ -143,8 +184,11 @@ class _GraphViewState extends State { backgroundColor: Colors.white, primaryXAxis: DateTimeAxis( title: AxisTitle(text: 'Time'), - intervalType: DateTimeIntervalType.hours, - interval: 1, + intervalType: timeIntervalType, + interval: timeInterval, + dateFormat: timeFormat, + minimum: displayedTimeRange.start, + maximum: displayedTimeRange.end, ), primaryYAxis: NumericAxis( name: 'pHAxis', @@ -160,14 +204,52 @@ class _GraphViewState extends State { ), ], trackballBehavior: trackballBehavior, + onTrackballPositionChanging: (TrackballArgs details) { + // Clear previously stored data on each update. + trackballData.clear(); + + // Store the details for the current trackball position. + if (details.chartPointInfo.series != null && + details.chartPointInfo.chartPoint != null) { + final String seriesName = details.chartPointInfo.series!.name; + final DateTime date = + details.chartPointInfo.chartPoint!.x as DateTime; + final double yValue = + details.chartPointInfo.chartPoint!.y as double; + + // Add this series' details to the trackballData list. + trackballData.add({ + 'seriesName': seriesName, + 'yValue': yValue, + 'date': date, + }); + + // Construct the tooltip dynamically from the stored data. + String tooltipText = ''; + String seriesText = ''; + for (final data in trackballData) { + seriesText += '${data['seriesName']} : ${data['yValue']}'; + } + + // Combine the series data into a compact format + tooltipText += seriesText; + + // Update the trackball tooltip information + details.chartPointInfo.label = tooltipText; + details.chartPointInfo.header = + DateFormat('MMM d hh:mm a').format(date); + } + }, series: _chartSeries(logData), ), ); } List _chartSeries(List logData) { + final MarkerSettings markerSettings = MarkerSettings(height: 2, width: 2); + return [ - LineSeries( + ScatterSeries( legendItemText: 'pH', name: 'pH', dataSource: logData, @@ -176,8 +258,10 @@ class _GraphViewState extends State { color: _showPH ? Colors.green : Colors.transparent, yAxisName: 'pHAxis', animationDuration: 0, + markerSettings: markerSettings, + enableTrackball: _showPH, ), - LineSeries( + ScatterSeries( legendItemText: 'pH setpoint', name: 'pH setpoint', dataSource: logData, @@ -186,8 +270,10 @@ class _GraphViewState extends State { color: _showPH ? Colors.green.shade800 : Colors.transparent, yAxisName: 'pHAxis', animationDuration: 0, + markerSettings: markerSettings, + enableTrackball: _showPH, ), - LineSeries( + ScatterSeries( legendItemText: 'temp', name: 'temp', dataSource: logData, @@ -196,8 +282,10 @@ class _GraphViewState extends State { color: _showTemp ? Colors.blue : Colors.transparent, yAxisName: 'TemperatureAxis', animationDuration: 0, + markerSettings: markerSettings, + enableTrackball: _showTemp, ), - LineSeries( + ScatterSeries( legendItemText: 'temp setpoint', name: 'temp setpoint', dataSource: logData, @@ -206,6 +294,8 @@ class _GraphViewState extends State { color: _showTemp ? Colors.blue.shade800 : Colors.transparent, yAxisName: 'TemperatureAxis', animationDuration: 0, + markerSettings: markerSettings, + enableTrackball: _showTemp, ), ]; } diff --git a/extras/log_file_client/lib/components/page_header.dart b/extras/log_file_client/lib/components/page_header.dart index 4a07eaed..c203af97 100644 --- a/extras/log_file_client/lib/components/page_header.dart +++ b/extras/log_file_client/lib/components/page_header.dart @@ -7,12 +7,15 @@ class PageHeader extends StatelessWidget { @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; + final double fontSize = screenWidth >= 500 ? 60 : 40; + final double sideMargins = screenWidth >= 500 ? 135 : 40; + final double topMargin = screenWidth >= 500 ? 40 : 30; return Container( margin: EdgeInsets.only( - top: 40, - left: 135, - right: 135, + top: topMargin, + left: sideMargins, + right: sideMargins, ), padding: EdgeInsets.only(bottom: 16), width: screenWidth, @@ -27,7 +30,7 @@ class PageHeader extends StatelessWidget { child: Text( text, style: TextStyle( - fontSize: 60, + fontSize: fontSize, letterSpacing: -2, color: const Color(0xFF0C2D48), ), diff --git a/extras/log_file_client/lib/components/tank_card.dart b/extras/log_file_client/lib/components/tank_card.dart index e5303dfe..1ef79eeb 100644 --- a/extras/log_file_client/lib/components/tank_card.dart +++ b/extras/log_file_client/lib/components/tank_card.dart @@ -33,7 +33,7 @@ class TankCard extends StatelessWidget { onTap: onTap, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - final double cardWidth = constraints.maxWidth * 0.93; + final double cardWidth = constraints.maxWidth; final double titleFontSize = 20; final double tankInfoFontSize = 16; final double tankInfoHeaderFontSize = 14; @@ -101,7 +101,7 @@ class TankCard extends StatelessWidget { AsyncSnapshot? snapshot, ) { return SizedBox( - height: cardWidth * 0.6, + height: cardWidth * 0.53, child: ClipRRect( borderRadius: BorderRadius.vertical( top: Radius.circular(20), @@ -241,18 +241,18 @@ class TankCard extends StatelessWidget { String textData = 'mock'; if (mode == TankInfoMode.current) { if (type == TankInfoType.pH) { - textData = 'pH ${snapshot?.data!.pH ?? 'mock'}'; + textData = 'pH ${snapshot?.data!.pH ?? '----'}'; } else { - textData = '${snapshot?.data!.temperature ?? 'mock'}°C'; + textData = '${snapshot?.data!.temperature ?? '----'}°C'; } } else if (mode == TankInfoMode.range) { if (type == TankInfoType.pH) { - final min = snapshot?.data!.minPH ?? 'm.k'; - final max = snapshot?.data!.maxPH ?? 'm.k'; + final min = snapshot?.data!.minPH ?? '---'; + final max = snapshot?.data!.maxPH ?? '---'; textData = 'pH $min - $max'; } else { - final min = snapshot?.data!.minTemp ?? 'mk'; - final max = snapshot?.data!.maxTemp ?? 'mk'; + final min = snapshot?.data!.minTemp ?? '--'; + final max = snapshot?.data!.maxTemp ?? '--'; textData = '$min - $max°C'; } } diff --git a/extras/log_file_client/lib/components/tank_thumbnail.dart b/extras/log_file_client/lib/components/tank_thumbnail.dart index f751037f..8c1b5965 100644 --- a/extras/log_file_client/lib/components/tank_thumbnail.dart +++ b/extras/log_file_client/lib/components/tank_thumbnail.dart @@ -3,21 +3,36 @@ import 'package:log_file_client/utils/http_client.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; class TankThumbnail extends StatelessWidget { - const TankThumbnail({required this.snapshot, super.key}); + TankThumbnail({required this.snapshot, DateTime? now, super.key}) + : now = now ?? DateTime.now(); final TankSnapshot snapshot; + final DateTime now; @override Widget build(BuildContext context) { - return Column( - children: [ - _graph(_pHSeries(), 'pHAxis'), - _graph(_tempSeries(), 'TemperatureAxis'), - ], - ); + if (snapshot.latestData.isEmpty) { + return Center( + child: Text( + 'No data available within past 12 hrs', + style: TextStyle(color: Colors.grey.shade600, fontSize: 16), + textDirection: TextDirection.ltr, + ), + ); + } else { + return Column( + children: [ + _graph(_pHSeries(), 'pHAxis'), + _graph(_tempSeries(), 'TemperatureAxis'), + ], + ); + } } Widget _graph(series, String axis) { + final double setpoint = + axis == 'pHAxis' ? snapshot.pHSetpoint! : snapshot.temperatureSetpoint!; + return Expanded( child: SfCartesianChart( backgroundColor: Colors.white, @@ -26,19 +41,13 @@ class TankThumbnail extends StatelessWidget { intervalType: DateTimeIntervalType.hours, interval: 6, labelStyle: TextStyle(color: Colors.grey.shade700), - // isVisible: !(axis == 'pHAxis'), + maximum: now, + minimum: now.subtract(const Duration(hours: 12)), ), primaryYAxis: NumericAxis( name: axis, - // title: axis == 'pHAxis' - // ? const AxisTitle(text: 'pH') - // : const AxisTitle(text: 'temp'), - minimum: axis == 'pHAxis' - ? snapshot.pHSetpoint! - 0.5 - : snapshot.temperatureSetpoint! - 0.5, - maximum: axis == 'pHAxis' - ? snapshot.pHSetpoint! + 0.5 - : snapshot.temperatureSetpoint! + 0.5, + minimum: setpoint - 0.5, + maximum: setpoint + 0.5, anchorRangeToVisiblePoints: false, labelStyle: TextStyle(color: Colors.grey.shade700), ), diff --git a/extras/log_file_client/lib/pages/graph_page.dart b/extras/log_file_client/lib/pages/graph_page.dart index 849ffb13..e31bdca6 100644 --- a/extras/log_file_client/lib/pages/graph_page.dart +++ b/extras/log_file_client/lib/pages/graph_page.dart @@ -3,8 +3,14 @@ import 'package:log_file_client/components/graph_view.dart'; import 'package:log_file_client/utils/http_client.dart'; class GraphPage extends StatelessWidget { - const GraphPage({required this.log, required this.httpClient, super.key}); + const GraphPage({ + required this.log, + required this.httpClient, + this.now, + super.key, + }); + final DateTime? now; final HttpClient httpClient; final Log log; @@ -41,6 +47,7 @@ class GraphPage extends StatelessWidget { final logData = snapshot.data!; return GraphView( logData: logData, + now: now, ); } }, diff --git a/extras/log_file_client/lib/pages/home_page.dart b/extras/log_file_client/lib/pages/home_page.dart index ace8942f..0b789698 100644 --- a/extras/log_file_client/lib/pages/home_page.dart +++ b/extras/log_file_client/lib/pages/home_page.dart @@ -58,9 +58,6 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final gridCrossAxis = screenWidth > 750 ? 3 : 2; - return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, @@ -73,7 +70,7 @@ class _HomePageState extends State { child: Column( children: [ PageHeader(text: 'Projects'), - _projectCards(gridCrossAxis), + _projectCards(), ], ), ), @@ -103,9 +100,17 @@ class _HomePageState extends State { // ); // } - Widget _projectCards(int gridCrossAxis) { + Widget _projectCards() { + final screenWidth = MediaQuery.of(context).size.width; + final int gridCrossAxis = screenWidth > 800 + ? 3 + : screenWidth > 500 + ? 2 + : 1; + final double sideMargins = screenWidth > 500 ? 100 : 40; + return _isLoading - ? _skeletonLoader(gridCrossAxis) + ? _skeletonLoader(gridCrossAxis, sideMargins) : Expanded( child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -118,16 +123,12 @@ class _HomePageState extends State { onTap: () => unawaited(openProject(_projectList![index])), ); }, - padding: EdgeInsets.only( - left: 100, - right: 100, - top: 16, - ), + padding: _gridViewPadding(sideMargins), ), ); } - Widget _skeletonLoader(int gridCrossAxis) { + Widget _skeletonLoader(int gridCrossAxis, double sideMargins) { return Expanded( child: Skeletonizer( effect: ShimmerEffect( @@ -146,13 +147,17 @@ class _HomePageState extends State { onTap: () {}, ); }, - padding: EdgeInsets.only( - left: 100, - right: 100, - top: 16, - ), + padding: _gridViewPadding(sideMargins), ), ), ); } + + EdgeInsets _gridViewPadding(sideMargins) { + return EdgeInsets.only( + left: sideMargins, + right: sideMargins, + top: 16, + ); + } } diff --git a/extras/log_file_client/lib/pages/project_page.dart b/extras/log_file_client/lib/pages/project_page.dart index de74a033..744ffab7 100644 --- a/extras/log_file_client/lib/pages/project_page.dart +++ b/extras/log_file_client/lib/pages/project_page.dart @@ -37,14 +37,12 @@ class _ProjectPageState extends State { @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; - final int gridCrossAxis; - if (screenWidth > 1370) { - gridCrossAxis = 3; - } else if (screenWidth > 980) { - gridCrossAxis = 2; - } else { - gridCrossAxis = 1; - } + final int gridCrossAxis = screenWidth > 1024 + ? 3 + : screenWidth > 500 + ? 2 + : 1; + final double sideMargins = screenWidth > 500 ? 100 : 10; return Scaffold( appBar: AppBar( @@ -55,14 +53,14 @@ class _ProjectPageState extends State { child: Column( children: [ PageHeader(text: '${widget.project.name} Tanks'), - _tankCards(gridCrossAxis), + _tankCards(gridCrossAxis, sideMargins), ], ), ), ); } - Expanded _tankCards(int gridCrossAxis) { + Expanded _tankCards(int gridCrossAxis, double sideMargins) { return Expanded( child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -77,8 +75,8 @@ class _ProjectPageState extends State { ); }, padding: EdgeInsets.only( - left: 100, - right: 100, + left: sideMargins, + right: sideMargins, top: 16, ), ), diff --git a/extras/log_file_client/lib/utils/http_client.dart b/extras/log_file_client/lib/utils/http_client.dart index d375e011..addef817 100644 --- a/extras/log_file_client/lib/utils/http_client.dart +++ b/extras/log_file_client/lib/utils/http_client.dart @@ -189,7 +189,7 @@ abstract class HttpClient { // Filter out header and empty rows logTable.removeWhere((row) => row.isEmpty); - logTable.removeWhere((row) => row[0].substring(0, 1) != 'v'); + logTable.removeWhere((row) => row[0].toString().substring(0, 1) != 'v'); // Parse the date strings for (int i = 0; i < logTable.length; i++) { @@ -255,7 +255,7 @@ class HttpClientProd extends HttpClient { Future fetchData(String filePath) async { Uri url; if (Uri.base.toString() == 'https://oap.cs.wallawalla.edu/') { - url = Uri.https('oap.cs.wallawalla.edu/', filePath); + url = Uri.https('oap.cs.wallawalla.edu', filePath); } else { url = Uri.http('localhost:8080', filePath); } diff --git a/extras/log_file_client/logs/index.html b/extras/log_file_client/logs/index.html index a819d849..3ac95504 100644 --- a/extras/log_file_client/logs/index.html +++ b/extras/log_file_client/logs/index.html @@ -11,6 +11,8 @@

Index of /logs/

ProjectA-tank-24.log ProjectA-tank-70.log ProjectB-tank-58.log + fostja-tank-1.log + fostja-tank-2.log
diff --git a/extras/log_file_client/test/main_test.dart b/extras/log_file_client/test/main_test.dart index 05f03263..90451f17 100644 --- a/extras/log_file_client/test/main_test.dart +++ b/extras/log_file_client/test/main_test.dart @@ -52,6 +52,8 @@ void main() { testWidgets('ProjectCard opens project page when selected', (WidgetTester tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; await tester.pumpWidget( MaterialApp( home: HomePage( @@ -114,6 +116,30 @@ void main() { // Verify that the TankThumbnail widget is displayed expect(find.byType(TankThumbnail), findsOneWidget); }); + + testWidgets('TankThumbnail is able to build from empty snapshot', + (WidgetTester tester) async { + await tester.pumpWidget( + TankThumbnail( + snapshot: TankSnapshot( + Log('test', 'test.log'), + [], + null, + null, + null, + null, + null, + null, + null, + null, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TankThumbnail), findsOneWidget); + expect(find.text('No data available within past 12 hrs'), findsOneWidget); + }); testWidgets('TankCard opens graph when selected', (WidgetTester tester) async { tester.view.physicalSize = const Size(1920, 1080); @@ -231,14 +257,14 @@ void main() { expect(find.byType(GraphView), findsOneWidget); expect(find.byType(SfCartesianChart), findsOneWidget); - // Verify that the chart contains the correct line series for temperature and pH + // Verify that the chart contains the correct series for temperature and pH expect(find.text('temp'), findsOneWidget); expect(find.text('pH'), findsOneWidget); - // Check for LineSeries widget presence + // Check for ScatterSeries widget presence expect( find.byWidgetPredicate( - (widget) => widget is LineSeries, + (widget) => widget is ScatterSeries, ), findsNWidgets(4), ); @@ -262,10 +288,10 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ChartSeriesSelector), findsOneWidget); - // Check that ChartSeriesSelector removes line series + // Check that ChartSeriesSelector removes series expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.initialIsVisible, + (widget) => widget is ScatterSeries && widget.initialIsVisible, ), findsNWidgets(4), ); @@ -275,17 +301,19 @@ void main() { expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.color != Colors.transparent, + (widget) => + widget is ScatterSeries && widget.color != Colors.transparent, ), findsNWidgets(2), ); - // Check that ChartSeriesSelector adds line series + // Check that ChartSeriesSelector adds series await tester.tap(find.text('pH')); await tester.pumpAndSettle(); expect( find.byWidgetPredicate( - (widget) => widget is LineSeries && widget.color != Colors.transparent, + (widget) => + widget is ScatterSeries && widget.color != Colors.transparent, ), findsNWidgets(4), ); @@ -300,6 +328,7 @@ void main() { body: GraphPage( log: Log('sample_long.log', 'sample_long.log'), httpClient: HttpClientTest(), + now: DateTime(2025, 1, 24, 16, 33), ), ), ), @@ -312,8 +341,8 @@ void main() { // Check that 24H (or max) is shown by default await checkOption(tester, '24H', 1440, false); - // Check that selecting 6H shows 6H of data - await checkOption(tester, '6H', 360, true); + // Check that selecting 6H shows last 6H of data (missing minutes in test file add up to 222 lines over the last 6 hrs) + await checkOption(tester, '6H', 222, true); // Check that selecting Max shows all data await checkOption(tester, 'Max', 576, true); diff --git a/extras/log_file_server/bin/log_file_server.dart b/extras/log_file_server/bin/log_file_server.dart index 7109e630..7fbb81ea 100644 --- a/extras/log_file_server/bin/log_file_server.dart +++ b/extras/log_file_server/bin/log_file_server.dart @@ -2,6 +2,7 @@ import 'dart:async' show Future; import 'dart:io'; import 'package:csv/csv.dart'; +import 'package:intl/intl.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; @@ -25,6 +26,9 @@ Future _get(Request req, String path) async { final snapshotGranularity = uri.queryParameters['granularity'] == null ? 5 : int.parse(uri.queryParameters['granularity']!); + final now = uri.queryParameters['now'] == null + ? DateTime.now() + : DateTime.tryParse(uri.queryParameters['now']!) ?? DateTime.now(); if (!file.existsSync()) { return Response.notFound(null); @@ -37,12 +41,8 @@ Future _get(Request req, String path) async { const CsvToListConverter(fieldDelimiter: '\t', eol: '\n').convert(body); logTable.removeWhere((row) => row[2] != 'I'); - // Remove old lines - if (!(logTable.isEmpty || logTable.length < snapshotLength)) { - logTable.removeRange(0, logTable.length - snapshotLength); - } - - // Condense to granularity and return + // Condense to time range and granularity + logTable = trimToTimeRange(logTable, snapshotLength, now); logTable = condenseToGranularity(logTable, snapshotGranularity); final finalBody = const ListToCsvConverter(fieldDelimiter: '\t', eol: '\n') .convert(logTable); @@ -78,6 +78,31 @@ Future _post(Request req, String path) async { return Response.ok(null); } +List trimToTimeRange( + List logTable, + int snapshotLength, + DateTime now, +) { + if (logTable.isEmpty || logTable[0].isEmpty) { + return []; + } + + final String targetTime = DateFormat('yyyy-MM-dd HH:mm:ss') + .format(now.subtract(Duration(minutes: snapshotLength))); + + // Find the start index of the log entries that are within the time range + int startIndex = 0; + for (int i = logTable.length - 1; i >= 0; i--) { + final rowTime = logTable[i][3] as String; + if (rowTime.compareTo(targetTime) < 0) { + startIndex = i + 1; + break; + } + } + + return logTable.sublist(startIndex); +} + List condenseToGranularity(List logTable, int snapshotGranularity) { if (snapshotGranularity > 1) { final List condensedLogTable = []; diff --git a/extras/log_file_server/pubspec.lock b/extras/log_file_server/pubspec.lock index 1b67e394..ecf0e291 100644 --- a/extras/log_file_server/pubspec.lock +++ b/extras/log_file_server/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/extras/log_file_server/pubspec.yaml b/extras/log_file_server/pubspec.yaml index f3ec8094..bf536afe 100644 --- a/extras/log_file_server/pubspec.yaml +++ b/extras/log_file_server/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: args: ^2.3.0 csv: ^6.0.0 + intl: ^0.20.2 shelf: ^1.4.0 shelf_router: ^1.1.0 diff --git a/extras/log_file_server/test/server_test.dart b/extras/log_file_server/test/server_test.dart index 8cc3e1e7..92d4c5bc 100644 --- a/extras/log_file_server/test/server_test.dart +++ b/extras/log_file_server/test/server_test.dart @@ -10,7 +10,9 @@ void main() { // Start the server in a different shell before running tests test('Get snapshot from /api/snapshotTest.log', () async { - final response = await get(Uri.parse('$host/api/snapshotTest.log')); + final response = await get( + Uri.parse('$host/api/snapshotTest.log?now=2025-01-09T16:05:00'), + ); expect(response.statusCode, 200); final logTable = const CsvToListConverter(fieldDelimiter: '\t', eol: '\n') @@ -22,7 +24,9 @@ void main() { test('Get snapshot using parameters', () async { final response = await get( - Uri.parse('$host/api/snapshotTest.log?length=10&granularity=1'), + Uri.parse( + '$host/api/snapshotTest.log?now=2025-01-09T16:05:00&length=10&granularity=1', + ), ); expect(response.statusCode, 200); @@ -33,6 +37,91 @@ void main() { expect(logTable.length, 10); // 10 lines }); + test('trimToTimeRange trims normal list', () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:00:00'], + ['v0', 0, 'I', '2023-10-01 00:01:00'], + ['v0', 0, 'I', '2023-10-01 00:02:00'], + ['v0', 0, 'I', '2023-10-01 00:03:00'], + ['v0', 0, 'I', '2023-10-01 00:04:00'], + ['v0', 0, 'I', '2023-10-01 00:05:00'], + ['v0', 0, 'I', '2023-10-01 00:06:00'], + ['v0', 0, 'I', '2023-10-01 00:07:00'], + ['v0', 0, 'I', '2023-10-01 00:08:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 5); + expect(newList[0][3], '2023-10-01 00:05:00'); + expect(newList[4][3], '2023-10-01 00:09:00'); + }); + + test('trimToTimeRange trims empty list', () async { + final testList = [[]]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 0); + }); + + test('trimToTimeRange trims list shorter than target range', () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:07:00'], + ['v0', 0, 'I', '2023-10-01 00:08:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 3); + expect(newList[0][3], '2023-10-01 00:07:00'); + expect(newList[2][3], '2023-10-01 00:09:00'); + }); + + test('trimToTimeRange trims list with missing minutes', () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:00:00'], + ['v0', 0, 'I', '2023-10-01 00:01:00'], + ['v0', 0, 'I', '2023-10-01 00:02:00'], + ['v0', 0, 'I', '2023-10-01 00:03:00'], + ['v0', 0, 'I', '2023-10-01 00:04:00'], + ['v0', 0, 'I', '2023-10-01 00:05:00'], + ['v0', 0, 'I', '2023-10-01 00:06:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:10:00')); + + expect(newList.length, 3); + expect(newList[0][3], '2023-10-01 00:05:00'); + expect(newList[2][3], '2023-10-01 00:09:00'); + }); + + test('trimToTimeRange returns empty list when last data is too old', + () async { + final testList = [ + ['v0', 0, 'I', '2023-10-01 00:00:00'], + ['v0', 0, 'I', '2023-10-01 00:01:00'], + ['v0', 0, 'I', '2023-10-01 00:02:00'], + ['v0', 0, 'I', '2023-10-01 00:03:00'], + ['v0', 0, 'I', '2023-10-01 00:04:00'], + ['v0', 0, 'I', '2023-10-01 00:05:00'], + ['v0', 0, 'I', '2023-10-01 00:06:00'], + ['v0', 0, 'I', '2023-10-01 00:09:00'], + ]; + + final newList = + trimToTimeRange(testList, 5, DateTime.parse('2023-10-01 00:20:00')); + + expect(newList, equals([])); + }); + test('condenseToGranularity', () async { final testList = [ ['2023-10-01 00:00:00', 'I', 'test'],