From 68f34dc7b77bd0a63d50548b2eccd6108e09ae20 Mon Sep 17 00:00:00 2001 From: NaiJi Date: Fri, 2 Aug 2024 18:37:00 +0400 Subject: [PATCH] feat(metrics): Implement disk usage metrics - Refactor metrics_cubit - Implement fallback to legacy when less than 20 dots --- .../server_api/monitoring_api.dart | 56 +++++ lib/logic/cubit/metrics/metrics_cubit.dart | 31 +-- .../cubit/metrics/metrics_repository.dart | 53 +++- lib/logic/models/metrics.dart | 30 ++- lib/ui/pages/server_details/charts/chart.dart | 79 ++++++ .../server_details/charts/disk_charts.dart | 230 ++++++++++++++++++ .../server_details/server_details_screen.dart | 1 + 7 files changed, 457 insertions(+), 23 deletions(-) create mode 100644 lib/ui/pages/server_details/charts/disk_charts.dart diff --git a/lib/logic/api_maps/graphql_maps/server_api/monitoring_api.dart b/lib/logic/api_maps/graphql_maps/server_api/monitoring_api.dart index 66f1e02a..31008395 100644 --- a/lib/logic/api_maps/graphql_maps/server_api/monitoring_api.dart +++ b/lib/logic/api_maps/graphql_maps/server_api/monitoring_api.dart @@ -119,4 +119,60 @@ mixin MonitoringApi on GraphQLApiMap { ); } } + + Future> getDiskMetrics({ + required final int step, + required final DateTime start, + required final DateTime end, + }) async { + QueryResult response; + + try { + final GraphQLClient client = await getClient(); + final variables = Variables$Query$GetDiskMetrics( + step: step, + start: start, + end: end, + ); + final query = Options$Query$GetDiskMetrics(variables: variables); + response = await client.query$GetDiskMetrics(query); + if (response.hasException) { + print(response.exception.toString()); + return GenericResult( + success: false, + data: null, + ); + } + if (response.parsedData == null) { + return GenericResult( + success: false, + data: null, + ); + } + if (response.parsedData?.monitoring.diskUsage.overallUsage + is Fragment$MonitoringQueryError) { + return GenericResult( + success: false, + data: null, + ); + } + final metrics = DiskMetrics.fromGraphQL( + data: response.parsedData!.monitoring, + stepsInSecond: step, + start: start, + end: end, + ); + return GenericResult( + success: true, + data: metrics, + ); + } catch (e) { + print(e); + return GenericResult( + success: false, + data: null, + message: e.toString(), + ); + } + } } diff --git a/lib/logic/cubit/metrics/metrics_cubit.dart b/lib/logic/cubit/metrics/metrics_cubit.dart index 15d21eba..8404b181 100644 --- a/lib/logic/cubit/metrics/metrics_cubit.dart +++ b/lib/logic/cubit/metrics/metrics_cubit.dart @@ -39,30 +39,21 @@ class MetricsCubit extends Cubit { void load(final Period period) async { try { - final MetricsLoaded newState = await repository.getServerMetrics(period); + final MetricsStateUpdate newStateUpdate = + await repository.getRelevantServerMetrics(period); + + int duration = newStateUpdate.nextCheckInSeconds; + if (duration <= 0) { + duration = state.period.stepPeriodInSeconds; + } timer = Timer( - Duration(seconds: newState.metrics.stepsInSecond.toInt()), - () => load(newState.period), + Duration(seconds: duration), + () => load(period), ); - emit(newState); - return; - } catch (_) {} - try { - final MetricsLoaded newState = await repository.getLegacyMetrics(period); - timer = Timer( - Duration(seconds: newState.metrics.stepsInSecond.toInt()), - () => load(newState.period), - ); - emit(newState); + + emit(newStateUpdate.newState); } on StateError { print('Tried to emit metrics when cubit is closed'); - } on MetricsLoadException { - timer = Timer( - Duration(seconds: state.period.stepPeriodInSeconds), - () => load(state.period), - ); - } on MetricsUnsupportedException { - emit(MetricsUnsupported(period)); } } } diff --git a/lib/logic/cubit/metrics/metrics_repository.dart b/lib/logic/cubit/metrics/metrics_repository.dart index 9e8652a0..cf99d3f1 100644 --- a/lib/logic/cubit/metrics/metrics_repository.dart +++ b/lib/logic/cubit/metrics/metrics_repository.dart @@ -17,9 +17,50 @@ class MetricsUnsupportedException implements Exception { final String message; } +class MetricsStateUpdate { + MetricsStateUpdate(this.newState, this.nextCheckInSeconds); + final MetricsState newState; + final int nextCheckInSeconds; +} + class MetricsRepository { static const String metricsSupportedVersion = '>=3.3.0'; - Future getServerMetrics(final Period period) async { + + Future getRelevantServerMetrics( + final Period period, + ) async { + MetricsLoaded? state; + int nextUpdate = 0; + + try { + final stateLoaded = await _getServerMetrics(period); + nextUpdate = stateLoaded.metrics.stepsInSecond.toInt(); + state = stateLoaded; + } catch (_) {} + + const minAmountForRendering = 20; + + if (state != null && + state.metrics.cpu.length >= minAmountForRendering && + state.metrics.bandwidthIn.length >= minAmountForRendering && + state.metrics.bandwidthOut.length >= minAmountForRendering) { + return MetricsStateUpdate(state, nextUpdate); + } + + try { + final stateLoaded = await _getLegacyMetrics(period); + nextUpdate = stateLoaded.metrics.stepsInSecond.toInt(); + state = stateLoaded; + } catch (_) {} + + if (state != null) { + return MetricsStateUpdate(state, nextUpdate); + } + + return MetricsStateUpdate(MetricsUnsupported(period), nextUpdate); + } + + Future _getServerMetrics(final Period period) async { final String? apiVersion = getIt().apiData.apiVersion.data; if (apiVersion == null) { @@ -69,15 +110,23 @@ class MetricsRepository { step: end.difference(start).inSeconds ~/ 120, ); + final diskResult = + await getIt().api.getDiskMetrics( + start: start, + end: end, + step: end.difference(start).inSeconds ~/ 120, + ); + return MetricsLoaded( period: period, metrics: result.data!, source: MetricsDataSource.server, memoryMetrics: memoryResult.data, + diskMetrics: diskResult.data, ); } - Future getLegacyMetrics(final Period period) async { + Future _getLegacyMetrics(final Period period) async { if (!(ProvidersController.currentServerProvider?.isAuthorized ?? false)) { throw MetricsUnsupportedException('Server Provider data is null'); } diff --git a/lib/logic/models/metrics.dart b/lib/logic/models/metrics.dart index acb0d5e7..2a4ce4ec 100644 --- a/lib/logic/models/metrics.dart +++ b/lib/logic/models/metrics.dart @@ -146,8 +146,36 @@ class DiskMetrics { required this.end, }); + DiskMetrics.fromGraphQL({ + required final Query$GetDiskMetrics$monitoring data, + required final int stepsInSecond, + required final DateTime start, + required final DateTime end, + }) : this( + stepsInSecond: stepsInSecond, + diskMetrics: Map.fromEntries( + (data.diskUsage.overallUsage as Fragment$MonitoringMetrics) + .metrics + .map( + (final metric) => MapEntry( + metric.metricId, + metric.values + .map( + (final value) => TimeSeriesData( + value.timestamp.millisecondsSinceEpoch ~/ 1000, + double.parse(value.value), + ), + ) + .toList(), + ), + ), + ), + start: start, + end: end, + ); + final num stepsInSecond; - final List diskMetrics; + final Map> diskMetrics; final DateTime start; final DateTime end; diff --git a/lib/ui/pages/server_details/charts/chart.dart b/lib/ui/pages/server_details/charts/chart.dart index bb2733e4..dc060bfb 100644 --- a/lib/ui/pages/server_details/charts/chart.dart +++ b/lib/ui/pages/server_details/charts/chart.dart @@ -145,6 +145,68 @@ class _Chart extends StatelessWidget { ), ), ), + const SizedBox(height: 8), + if (state is MetricsLoaded && state.diskMetrics != null) + FilledCard( + clipped: false, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + 'resource_chart.network_title'.tr(), + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + Flexible( + fit: FlexFit.loose, + child: Wrap( + spacing: 8.0, + runSpacing: 8.0, + alignment: WrapAlignment.end, + runAlignment: WrapAlignment.end, + children: [ + Legend( + color: Theme.of(context).colorScheme.primary, + text: 'resource_chart.in'.tr(), + ), + Legend( + color: Theme.of(context).colorScheme.tertiary, + text: 'resource_chart.out'.tr(), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Stack( + alignment: Alignment.center, + children: [ + getDiskChart(state), + AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: state is MetricsLoading ? 1 : 0, + child: const _GraphLoadingCardContent(), + ), + ], + ), + ], + ), + ), + ), ]; } else if (state is MetricsUnsupported) { charts = [ @@ -249,6 +311,23 @@ class _Chart extends StatelessWidget { } } +Widget getDiskChart(final MetricsLoaded state) { + final data = state.diskMetrics; + + if (data == null) { + return const SizedBox(); + } + + return SizedBox( + height: 200, + child: DiskChart( + listData: data.diskMetrics.values.toList(), + period: state.period, + start: state.metrics.start, + ), + ); +} + class _GraphLoadingCardContent extends StatelessWidget { const _GraphLoadingCardContent(); diff --git a/lib/ui/pages/server_details/charts/disk_charts.dart b/lib/ui/pages/server_details/charts/disk_charts.dart new file mode 100644 index 00000000..9e170ea1 --- /dev/null +++ b/lib/ui/pages/server_details/charts/disk_charts.dart @@ -0,0 +1,230 @@ +import 'dart:math'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/common_enum/common_enum.dart'; +import 'package:selfprivacy/logic/models/disk_size.dart'; +import 'package:selfprivacy/logic/models/metrics.dart'; +import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart'; + +class DiskChart extends StatelessWidget { + const DiskChart({ + required this.listData, + required this.period, + required this.start, + super.key, + }); + + final List> listData; + final Period period; + final DateTime start; + + List getSpots(final data) { + var i = 0; + final List res = []; + + for (final d in data) { + res.add(FlSpot(i.toDouble(), d.value)); + i++; + } + + return res; + } + + @override + Widget build(final BuildContext context) => LineChart( + LineChartData( + lineTouchData: LineTouchData( + enabled: true, + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (final LineBarSpot _) => + Theme.of(context).colorScheme.surface, + tooltipPadding: const EdgeInsets.all(8), + getTooltipItems: (final List touchedBarSpots) { + final List res = []; + + bool timeShown = false; + + for (final spot in touchedBarSpots) { + final value = spot.y; + final date = listData[0][spot.x.toInt()].time; + + res.add( + LineTooltipItem( + '${timeShown ? '' : DateFormat('HH:mm dd.MM.yyyy').format(date)} ${spot.barIndex == 0 ? 'resource_chart.in'.tr() : 'resource_chart.out'.tr()} ${DiskSize(byte: value.toInt()).toString()}', + TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ); + + timeShown = true; + } + + return res; + }, + ), + ), + lineBarsData: [ + // IN + LineChartBarData( + spots: getSpots(listData[0]), + isCurved: false, + barWidth: 2, + color: Theme.of(context).colorScheme.primary, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary.withOpacity(0.5), + Theme.of(context).colorScheme.primary.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + ), + // OUT + LineChartBarData( + spots: getSpots(listData[1]), + isCurved: false, + barWidth: 2, + color: Theme.of(context).colorScheme.tertiary, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.tertiary.withOpacity(0.5), + Theme.of(context).colorScheme.tertiary.withOpacity(0.0), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + ), + ], + minY: 0, + maxY: [ + ...listData[0].map((final e) => e.value), + ...listData[1].map((final e) => e.value), + ].reduce(max) * + 1.2, + minX: 0, + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + interval: 40, + reservedSize: 30, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + bottomTitle( + value.toInt(), + listData[0], + period, + ), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + showTitles: true, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles( + reservedSize: 50, + getTitlesWidget: (final value, final titleMeta) => Padding( + padding: const EdgeInsets.only(left: 5), + child: Text( + DiskSize(byte: value.toInt()).toString(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + interval: [ + ...listData[0].map((final e) => e.value), + ...listData[1].map((final e) => e.value), + ].reduce(max) * + 2 / + 6.5, + showTitles: true, + ), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + verticalInterval: 40, + horizontalInterval: [ + ...listData[0].map((final e) => e.value), + ...listData[1].map((final e) => e.value), + ].reduce(max) * + 2 / + 6.5, + getDrawingHorizontalLine: (final value) => FlLine( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + strokeWidth: 1, + ), + getDrawingVerticalLine: (final value) => FlLine( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + strokeWidth: 1, + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + left: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + right: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + ), + ), + ), + ); + + bool checkToShowTitle( + final double minValue, + final double maxValue, + final SideTitles sideTitles, + final double appliedInterval, + final double value, + ) { + if (value < 0) { + return false; + } else if (value == 0) { + return true; + } + + final diff = value - minValue; + final finalValue = diff / 20; + return finalValue - finalValue.floor() == 0; + } +} diff --git a/lib/ui/pages/server_details/server_details_screen.dart b/lib/ui/pages/server_details/server_details_screen.dart index 00871c32..2ef43887 100644 --- a/lib/ui/pages/server_details/server_details_screen.dart +++ b/lib/ui/pages/server_details/server_details_screen.dart @@ -13,6 +13,7 @@ import 'package:selfprivacy/ui/components/cards/filled_card.dart'; import 'package:selfprivacy/ui/components/list_tiles/list_tile_on_surface_variant.dart'; import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/cpu_chart.dart'; +import 'package:selfprivacy/ui/pages/server_details/charts/disk_charts.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/memory_chart.dart'; import 'package:selfprivacy/ui/pages/server_details/charts/network_charts.dart'; import 'package:selfprivacy/ui/pages/server_storage/storage_card.dart';