mirror of
https://git.selfprivacy.org/kherel/selfprivacy.org.app.git
synced 2024-11-19 07:09:14 +00:00
feat: CPU, Network and RAM stats from the server
This commit is contained in:
parent
8fe0de0c9e
commit
e065463ffb
|
@ -139,7 +139,12 @@
|
|||
"network_title": "Network Usage",
|
||||
"in": "In",
|
||||
"out": "Out",
|
||||
"unsupported": "You can't view resource usage charts without the server provider token."
|
||||
"memory": "Memory usage",
|
||||
"view_usage_by_service": "View usage by service",
|
||||
"unsupported": "You can't view resource usage charts without the server provider token.",
|
||||
"system": "System",
|
||||
"ssh_users": "SSH users",
|
||||
"ram_usage": "Average usage: {average}. Maximum: {max}."
|
||||
},
|
||||
"server": {
|
||||
"card_title": "Server",
|
||||
|
|
68
lib/logic/api_maps/graphql_maps/schema/monitoring.graphql
Normal file
68
lib/logic/api_maps/graphql_maps/schema/monitoring.graphql
Normal file
|
@ -0,0 +1,68 @@
|
|||
fragment MonitoringMetrics on MonitoringMetrics {
|
||||
metrics {
|
||||
metricId
|
||||
values {
|
||||
timestamp
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment MonitoringValues on MonitoringValues {
|
||||
values {
|
||||
value
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
|
||||
fragment MonitoringQueryError on MonitoringQueryError {
|
||||
error
|
||||
}
|
||||
|
||||
|
||||
query GetOverallCpuAndNetworkMetrics($start: DateTime, $end: DateTime, $step: Int!) {
|
||||
monitoring {
|
||||
cpuUsage(start: $start, end: $end, step: $step) {
|
||||
overallUsage {
|
||||
... MonitoringQueryError
|
||||
... MonitoringValues
|
||||
}
|
||||
}
|
||||
networkUsage(start: $start, end: $end, step: $step) {
|
||||
overallUsage {
|
||||
... MonitoringQueryError
|
||||
... MonitoringMetrics
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetMemoryMetrics($start: DateTime, $end: DateTime, $step: Int!) {
|
||||
monitoring {
|
||||
memoryUsage(start: $start, end: $end, step: $step) {
|
||||
overallUsage {
|
||||
... MonitoringQueryError
|
||||
... MonitoringValues
|
||||
}
|
||||
averageUsageByService {
|
||||
... MonitoringQueryError
|
||||
... MonitoringMetrics
|
||||
}
|
||||
maxUsageByService {
|
||||
... MonitoringQueryError
|
||||
... MonitoringMetrics
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetDiskMetrics($start: DateTime, $end: DateTime, $step: Int!) {
|
||||
monitoring {
|
||||
diskUsage(start: $start, end: $end, step: $step) {
|
||||
overallUsage {
|
||||
... MonitoringQueryError
|
||||
... MonitoringMetrics
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8914
lib/logic/api_maps/graphql_maps/schema/monitoring.graphql.dart
Normal file
8914
lib/logic/api_maps/graphql_maps/schema/monitoring.graphql.dart
Normal file
File diff suppressed because it is too large
Load diff
|
@ -280,7 +280,7 @@ type Monitoring {
|
|||
}
|
||||
|
||||
type MonitoringMetric {
|
||||
id: String!
|
||||
metricId: String!
|
||||
values: [MonitoringValue!]!
|
||||
}
|
||||
|
||||
|
|
122
lib/logic/api_maps/graphql_maps/server_api/monitoring_api.dart
Normal file
122
lib/logic/api_maps/graphql_maps/server_api/monitoring_api.dart
Normal file
|
@ -0,0 +1,122 @@
|
|||
part of 'server_api.dart';
|
||||
|
||||
mixin MonitoringApi on GraphQLApiMap {
|
||||
Future<GenericResult<ServerMetrics?>> getServerMetrics({
|
||||
required final int step,
|
||||
required final DateTime start,
|
||||
required final DateTime end,
|
||||
}) async {
|
||||
QueryResult<Query$GetOverallCpuAndNetworkMetrics> response;
|
||||
|
||||
try {
|
||||
final GraphQLClient client = await getClient();
|
||||
final variables = Variables$Query$GetOverallCpuAndNetworkMetrics(
|
||||
step: step,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
final query =
|
||||
Options$Query$GetOverallCpuAndNetworkMetrics(variables: variables);
|
||||
response = await client.query$GetOverallCpuAndNetworkMetrics(query);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
return GenericResult<ServerMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
if (response.parsedData == null) {
|
||||
return GenericResult<ServerMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
if (response.parsedData?.monitoring.cpuUsage.overallUsage
|
||||
is Fragment$MonitoringQueryError ||
|
||||
response.parsedData?.monitoring.networkUsage.overallUsage
|
||||
is Fragment$MonitoringQueryError) {
|
||||
return GenericResult<ServerMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
final metrics = ServerMetrics.fromGraphQL(
|
||||
data: response.parsedData!.monitoring,
|
||||
stepsInSecond: step,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
return GenericResult<ServerMetrics?>(
|
||||
success: true,
|
||||
data: metrics,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
return GenericResult<ServerMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<GenericResult<MemoryMetrics?>> getMemoryMetrics({
|
||||
required final int step,
|
||||
required final DateTime start,
|
||||
required final DateTime end,
|
||||
}) async {
|
||||
QueryResult<Query$GetMemoryMetrics> response;
|
||||
|
||||
try {
|
||||
final GraphQLClient client = await getClient();
|
||||
final variables = Variables$Query$GetMemoryMetrics(
|
||||
step: step,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
final query = Options$Query$GetMemoryMetrics(variables: variables);
|
||||
response = await client.query$GetMemoryMetrics(query);
|
||||
if (response.hasException) {
|
||||
print(response.exception.toString());
|
||||
return GenericResult<MemoryMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
if (response.parsedData == null) {
|
||||
return GenericResult<MemoryMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
if (response.parsedData?.monitoring.memoryUsage.overallUsage
|
||||
is Fragment$MonitoringQueryError ||
|
||||
response.parsedData?.monitoring.memoryUsage.averageUsageByService
|
||||
is Fragment$MonitoringQueryError ||
|
||||
response.parsedData?.monitoring.memoryUsage.maxUsageByService
|
||||
is Fragment$MonitoringQueryError) {
|
||||
return GenericResult<MemoryMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
);
|
||||
}
|
||||
final metrics = MemoryMetrics.fromGraphQL(
|
||||
data: response.parsedData!.monitoring,
|
||||
stepsInSecond: step,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
return GenericResult<MemoryMetrics?>(
|
||||
success: true,
|
||||
data: metrics,
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
return GenericResult<MemoryMetrics?>(
|
||||
success: false,
|
||||
data: null,
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import 'package:selfprivacy/logic/api_maps/graphql_maps/graphql_api_map.dart';
|
|||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/backups.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/disk_volumes.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/logs.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/monitoring.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/schema.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/server_api.graphql.dart';
|
||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/server_settings.graphql.dart';
|
||||
|
@ -23,6 +24,7 @@ import 'package:selfprivacy/logic/models/json/dns_records.dart';
|
|||
import 'package:selfprivacy/logic/models/json/recovery_token_status.dart';
|
||||
import 'package:selfprivacy/logic/models/json/server_disk_volume.dart';
|
||||
import 'package:selfprivacy/logic/models/json/server_job.dart';
|
||||
import 'package:selfprivacy/logic/models/metrics.dart';
|
||||
import 'package:selfprivacy/logic/models/server_logs.dart';
|
||||
import 'package:selfprivacy/logic/models/service.dart';
|
||||
import 'package:selfprivacy/logic/models/ssh_settings.dart';
|
||||
|
@ -37,6 +39,7 @@ part 'services_api.dart';
|
|||
part 'users_api.dart';
|
||||
part 'volume_api.dart';
|
||||
part 'logs_api.dart';
|
||||
part 'monitoring_api.dart';
|
||||
|
||||
class ServerApi extends GraphQLApiMap
|
||||
with
|
||||
|
@ -46,7 +49,8 @@ class ServerApi extends GraphQLApiMap
|
|||
ServicesApi,
|
||||
UsersApi,
|
||||
BackupsApi,
|
||||
LogsApi {
|
||||
LogsApi,
|
||||
MonitoringApi {
|
||||
ServerApi({
|
||||
this.hasLogger = false,
|
||||
this.isWithToken = true,
|
||||
|
|
|
@ -39,7 +39,16 @@ class MetricsCubit extends Cubit<MetricsState> {
|
|||
|
||||
void load(final Period period) async {
|
||||
try {
|
||||
final MetricsLoaded newState = await repository.getMetrics(period);
|
||||
final MetricsLoaded newState = await repository.getServerMetrics(period);
|
||||
timer = Timer(
|
||||
Duration(seconds: newState.metrics.stepsInSecond.toInt()),
|
||||
() => load(newState.period),
|
||||
);
|
||||
emit(newState);
|
||||
return;
|
||||
} catch (_) {}
|
||||
try {
|
||||
final MetricsLoaded newState = await repository.getLegacyMetrics(period);
|
||||
timer = Timer(
|
||||
Duration(seconds: newState.metrics.stepsInSecond.toInt()),
|
||||
() => load(newState.period),
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:pub_semver/pub_semver.dart';
|
||||
import 'package:selfprivacy/config/get_it_config.dart';
|
||||
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
|
||||
|
||||
|
@ -16,7 +18,66 @@ class MetricsUnsupportedException implements Exception {
|
|||
}
|
||||
|
||||
class MetricsRepository {
|
||||
Future<MetricsLoaded> getMetrics(final Period period) async {
|
||||
static const String metricsSupportedVersion = '>=3.3.0';
|
||||
Future<MetricsLoaded> getServerMetrics(final Period period) async {
|
||||
final String? apiVersion =
|
||||
getIt<ApiConnectionRepository>().apiData.apiVersion.data;
|
||||
if (apiVersion == null) {
|
||||
throw Exception('basis.network_error'.tr());
|
||||
}
|
||||
if (!VersionConstraint.parse(metricsSupportedVersion)
|
||||
.allows(Version.parse(apiVersion))) {
|
||||
throw Exception(
|
||||
'basis.feature_unsupported_on_api_version'.tr(
|
||||
namedArgs: {
|
||||
'versionConstraint': metricsSupportedVersion,
|
||||
'currentVersion': apiVersion,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime end = DateTime.now();
|
||||
DateTime start;
|
||||
|
||||
switch (period) {
|
||||
case Period.hour:
|
||||
start = end.subtract(const Duration(hours: 1));
|
||||
break;
|
||||
case Period.day:
|
||||
start = end.subtract(const Duration(days: 1));
|
||||
break;
|
||||
case Period.month:
|
||||
start = end.subtract(const Duration(days: 15));
|
||||
break;
|
||||
}
|
||||
|
||||
final result = await getIt<ApiConnectionRepository>().api.getServerMetrics(
|
||||
start: start,
|
||||
end: end,
|
||||
step: end.difference(start).inSeconds ~/ 120,
|
||||
);
|
||||
|
||||
if (result.data == null || !result.success) {
|
||||
throw MetricsLoadException('Metrics data is null');
|
||||
}
|
||||
|
||||
final memoryResult =
|
||||
await getIt<ApiConnectionRepository>().api.getMemoryMetrics(
|
||||
start: start,
|
||||
end: end,
|
||||
step: end.difference(start).inSeconds ~/ 120,
|
||||
);
|
||||
|
||||
return MetricsLoaded(
|
||||
period: period,
|
||||
metrics: result.data!,
|
||||
source: MetricsDataSource.server,
|
||||
memoryMetrics: memoryResult.data,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MetricsLoaded> getLegacyMetrics(final Period period) async {
|
||||
if (!(ProvidersController.currentServerProvider?.isAuthorized ?? false)) {
|
||||
throw MetricsUnsupportedException('Server Provider data is null');
|
||||
}
|
||||
|
@ -50,6 +111,7 @@ class MetricsRepository {
|
|||
return MetricsLoaded(
|
||||
period: period,
|
||||
metrics: result.data!,
|
||||
source: MetricsDataSource.legacy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,19 +15,31 @@ class MetricsLoading extends MetricsState {
|
|||
List<Object?> get props => [period];
|
||||
}
|
||||
|
||||
enum MetricsDataSource {
|
||||
server,
|
||||
legacy,
|
||||
}
|
||||
|
||||
class MetricsLoaded extends MetricsState {
|
||||
const MetricsLoaded({
|
||||
required this.period,
|
||||
required this.metrics,
|
||||
required this.source,
|
||||
this.memoryMetrics,
|
||||
this.diskMetrics,
|
||||
});
|
||||
|
||||
@override
|
||||
final Period period;
|
||||
|
||||
final ServerMetrics metrics;
|
||||
final MemoryMetrics? memoryMetrics;
|
||||
final DiskMetrics? diskMetrics;
|
||||
final MetricsDataSource source;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [period, metrics];
|
||||
List<Object?> get props =>
|
||||
[period, metrics, memoryMetrics, diskMetrics, source];
|
||||
}
|
||||
|
||||
class MetricsUnsupported extends MetricsState {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/monitoring.graphql.dart';
|
||||
|
||||
class TimeSeriesData {
|
||||
TimeSeriesData(
|
||||
this.secondsSinceEpoch,
|
||||
|
@ -20,6 +22,52 @@ class ServerMetrics {
|
|||
required this.end,
|
||||
});
|
||||
|
||||
ServerMetrics.fromGraphQL({
|
||||
required final Query$GetOverallCpuAndNetworkMetrics$monitoring data,
|
||||
required final int stepsInSecond,
|
||||
required final DateTime start,
|
||||
required final DateTime end,
|
||||
}) : this(
|
||||
stepsInSecond: stepsInSecond,
|
||||
cpu: (data.cpuUsage.overallUsage as Fragment$MonitoringValues)
|
||||
.values
|
||||
.map(
|
||||
(final metric) => TimeSeriesData(
|
||||
// Convert DateTime to seconds since epoch
|
||||
metric.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
// Parse string as a float
|
||||
double.parse(metric.value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
bandwidthIn:
|
||||
(data.networkUsage.overallUsage as Fragment$MonitoringMetrics)
|
||||
.metrics
|
||||
.firstWhere((final element) => element.metricId == 'receive')
|
||||
.values
|
||||
.map(
|
||||
(final metric) => TimeSeriesData(
|
||||
metric.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
double.parse(metric.value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
bandwidthOut:
|
||||
(data.networkUsage.overallUsage as Fragment$MonitoringMetrics)
|
||||
.metrics
|
||||
.firstWhere((final element) => element.metricId == 'transmit')
|
||||
.values
|
||||
.map(
|
||||
(final metric) => TimeSeriesData(
|
||||
metric.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
double.parse(metric.value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
|
||||
final num stepsInSecond;
|
||||
final List<TimeSeriesData> cpu;
|
||||
final List<TimeSeriesData> bandwidthIn;
|
||||
|
@ -28,3 +76,79 @@ class ServerMetrics {
|
|||
final DateTime start;
|
||||
final DateTime end;
|
||||
}
|
||||
|
||||
class MemoryMetrics {
|
||||
MemoryMetrics({
|
||||
required this.stepsInSecond,
|
||||
required this.overallMetrics,
|
||||
required this.averageMetricsByService,
|
||||
required this.maxMetricsByService,
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
MemoryMetrics.fromGraphQL({
|
||||
required final Query$GetMemoryMetrics$monitoring data,
|
||||
required final int stepsInSecond,
|
||||
required final DateTime start,
|
||||
required final DateTime end,
|
||||
}) : this(
|
||||
stepsInSecond: stepsInSecond,
|
||||
overallMetrics:
|
||||
(data.memoryUsage.overallUsage as Fragment$MonitoringValues)
|
||||
.values
|
||||
.map(
|
||||
(final metric) => TimeSeriesData(
|
||||
metric.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
double.parse(metric.value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
averageMetricsByService: Map.fromEntries(
|
||||
(data.memoryUsage.averageUsageByService
|
||||
as Fragment$MonitoringMetrics)
|
||||
.metrics
|
||||
.map(
|
||||
(final metric) => MapEntry(
|
||||
metric.metricId,
|
||||
double.parse(metric.values.first.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
maxMetricsByService: Map.fromEntries(
|
||||
(data.memoryUsage.maxUsageByService as Fragment$MonitoringMetrics)
|
||||
.metrics
|
||||
.map(
|
||||
(final metric) => MapEntry(
|
||||
metric.metricId,
|
||||
double.parse(metric.values.first.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
|
||||
final num stepsInSecond;
|
||||
final List<TimeSeriesData> overallMetrics;
|
||||
final Map<String, double> averageMetricsByService;
|
||||
final Map<String, double> maxMetricsByService;
|
||||
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
}
|
||||
|
||||
class DiskMetrics {
|
||||
DiskMetrics({
|
||||
required this.stepsInSecond,
|
||||
required this.diskMetrics,
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
final num stepsInSecond;
|
||||
final List<TimeSeriesData> diskMetrics;
|
||||
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
}
|
||||
|
|
|
@ -39,6 +39,50 @@ class _Chart extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledCard(
|
||||
clipped: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'resource_chart.memory'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (state is MetricsLoaded && state.memoryMetrics != null)
|
||||
getMemoryChart(state),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: state is MetricsLoading ? 1 : 0,
|
||||
child: const _GraphLoadingCardContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text('resource_chart.view_usage_by_service'.tr()),
|
||||
leading: Icon(
|
||||
Icons.area_chart_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () {
|
||||
context.pushRoute(
|
||||
const MemoryUsageByServiceRoute(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledCard(
|
||||
clipped: false,
|
||||
child: Padding(
|
||||
|
@ -171,6 +215,23 @@ class _Chart extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget getMemoryChart(final MetricsLoaded state) {
|
||||
final data = state.memoryMetrics;
|
||||
|
||||
if (data == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: MemoryChart(
|
||||
data: data.overallMetrics,
|
||||
period: state.period,
|
||||
start: state.metrics.start,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBandwidthChart(final MetricsLoaded state) {
|
||||
final ppsIn = state.metrics.bandwidthIn;
|
||||
final ppsOut = state.metrics.bandwidthOut;
|
||||
|
|
179
lib/ui/pages/server_details/charts/memory_chart.dart
Normal file
179
lib/ui/pages/server_details/charts/memory_chart.dart
Normal file
|
@ -0,0 +1,179 @@
|
|||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
|
||||
import 'package:selfprivacy/logic/models/metrics.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_details/charts/bottom_title.dart';
|
||||
|
||||
class MemoryChart extends StatelessWidget {
|
||||
const MemoryChart({
|
||||
required this.data,
|
||||
required this.period,
|
||||
required this.start,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<TimeSeriesData> data;
|
||||
final Period period;
|
||||
final DateTime start;
|
||||
|
||||
List<FlSpot> getSpots() {
|
||||
var i = 0;
|
||||
final List<FlSpot> 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<LineBarSpot> touchedBarSpots) {
|
||||
final List<LineTooltipItem> res = [];
|
||||
|
||||
for (final spot in touchedBarSpots) {
|
||||
final value = spot.y;
|
||||
final date = data[spot.x.toInt()].time;
|
||||
|
||||
res.add(
|
||||
LineTooltipItem(
|
||||
'${value.toStringAsFixed(2)}% at ${DateFormat('HH:mm dd.MM.yyyy').format(date)}',
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: getSpots(),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
minY: 0,
|
||||
// Maximal value of data by 100 step
|
||||
maxY: 100,
|
||||
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(),
|
||||
data,
|
||||
period,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
showTitles: true,
|
||||
),
|
||||
),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: false,
|
||||
),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: true,
|
||||
horizontalInterval: 25,
|
||||
verticalInterval: 40,
|
||||
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 localValue = value - minValue;
|
||||
final v = localValue / 20;
|
||||
return v - v.floor() == 0;
|
||||
}
|
||||
}
|
156
lib/ui/pages/server_details/memory_usage_by_service_screen.dart
Normal file
156
lib/ui/pages/server_details/memory_usage_by_service_screen.dart
Normal file
|
@ -0,0 +1,156 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:selfprivacy/logic/bloc/services/services_bloc.dart';
|
||||
import 'package:selfprivacy/logic/common_enum/common_enum.dart';
|
||||
import 'package:selfprivacy/logic/cubit/metrics/metrics_cubit.dart';
|
||||
import 'package:selfprivacy/logic/models/disk_size.dart';
|
||||
import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart';
|
||||
import 'package:selfprivacy/ui/components/buttons/segmented_buttons.dart';
|
||||
import 'package:selfprivacy/ui/helpers/empty_page_placeholder.dart';
|
||||
import 'package:selfprivacy/ui/layouts/brand_hero_screen.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MemoryUsageByServiceScreen extends StatelessWidget {
|
||||
const MemoryUsageByServiceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) => BlocProvider(
|
||||
create: (final context) => MetricsCubit()..restart(),
|
||||
child: const _MemoryUsageByServiceContents(),
|
||||
);
|
||||
}
|
||||
|
||||
class _MemoryUsageByServiceContents extends StatelessWidget {
|
||||
const _MemoryUsageByServiceContents();
|
||||
|
||||
@override
|
||||
Widget build(final BuildContext context) {
|
||||
final MetricsCubit cubit = context.watch<MetricsCubit>();
|
||||
final Period period = cubit.state.period;
|
||||
final MetricsState state = cubit.state;
|
||||
|
||||
if (state is MetricsUnsupported ||
|
||||
(state is MetricsLoaded && state.memoryMetrics == null)) {
|
||||
return BrandHeroScreen(
|
||||
heroTitle: 'resource_chart.memory'.tr(),
|
||||
children: [
|
||||
Center(
|
||||
child: Center(
|
||||
child: EmptyPagePlaceholder(
|
||||
title: 'basis.error'.tr(),
|
||||
iconData: Icons.error_outline_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (state is MetricsLoading) {
|
||||
return BrandHeroScreen(
|
||||
heroTitle: 'resource_chart.memory'.tr(),
|
||||
children: const [
|
||||
Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final averageUsageByServices =
|
||||
(state as MetricsLoaded).memoryMetrics!.averageMetricsByService;
|
||||
final maxUsageByServices = state.memoryMetrics!.maxMetricsByService;
|
||||
|
||||
// For each service, gather average and max usages
|
||||
final List<Widget> children = [];
|
||||
for (final slice in averageUsageByServices.keys.sorted()) {
|
||||
final DiskSize averageUsage =
|
||||
DiskSize(byte: averageUsageByServices[slice]?.toInt() ?? 0);
|
||||
final DiskSize maxUsage =
|
||||
DiskSize(byte: maxUsageByServices[slice]?.toInt() ?? 0);
|
||||
String? serviceName;
|
||||
Widget? icon;
|
||||
if (slice == 'system') {
|
||||
serviceName = 'resource_chart.system'.tr();
|
||||
icon = const Icon(BrandIcons.server);
|
||||
} else if (slice == 'user') {
|
||||
serviceName = 'resource_chart.ssh_users'.tr();
|
||||
icon = const Icon(BrandIcons.terminal);
|
||||
} else {
|
||||
final service = context
|
||||
.read<ServicesBloc>()
|
||||
.state
|
||||
.getServiceById(slice.replaceAll('_', '-'));
|
||||
serviceName = service?.displayName ?? slice;
|
||||
icon = service?.svgIcon != null
|
||||
? SvgPicture.string(
|
||||
service!.svgIcon,
|
||||
width: 22.0,
|
||||
height: 24.0,
|
||||
colorFilter: ColorFilter.mode(
|
||||
Theme.of(context).colorScheme.onBackground,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
)
|
||||
: const Icon(BrandIcons.box);
|
||||
}
|
||||
|
||||
if (serviceName == slice &&
|
||||
averageUsage.byte == 0 &&
|
||||
maxUsage.byte == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
children.add(
|
||||
ListTile(
|
||||
title: Text(serviceName),
|
||||
subtitle: Text(
|
||||
'resource_chart.ram_usage'.tr(
|
||||
namedArgs: {
|
||||
'average': averageUsage.toString(),
|
||||
'max': maxUsage.toString(),
|
||||
},
|
||||
),
|
||||
),
|
||||
dense: true,
|
||||
leading: icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BrandHeroScreen(
|
||||
heroTitle: 'resource_chart.memory'.tr(),
|
||||
children: [
|
||||
SegmentedButtons(
|
||||
isSelected: [
|
||||
period == Period.month,
|
||||
period == Period.day,
|
||||
period == Period.hour,
|
||||
],
|
||||
onPressed: (final index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
cubit.changePeriod(Period.month);
|
||||
break;
|
||||
case 1:
|
||||
cubit.changePeriod(Period.day);
|
||||
break;
|
||||
case 2:
|
||||
cubit.changePeriod(Period.hour);
|
||||
break;
|
||||
}
|
||||
},
|
||||
titles: [
|
||||
'resource_chart.month'.tr(),
|
||||
'resource_chart.day'.tr(),
|
||||
'resource_chart.hour'.tr(),
|
||||
],
|
||||
),
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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/memory_chart.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_details/charts/network_charts.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_storage/storage_card.dart';
|
||||
import 'package:selfprivacy/ui/router/router.dart';
|
||||
|
|
|
@ -18,6 +18,7 @@ import 'package:selfprivacy/ui/pages/providers/providers.dart';
|
|||
import 'package:selfprivacy/ui/pages/recovery_key/recovery_key.dart';
|
||||
import 'package:selfprivacy/ui/pages/root_route.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_details/logs/logs_screen.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_details/memory_usage_by_service_screen.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_details/server_details_screen.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_details/server_settings_screen.dart';
|
||||
import 'package:selfprivacy/ui/pages/server_storage/binds_migration/services_migration.dart';
|
||||
|
@ -109,6 +110,7 @@ class RootRouter extends _$RootRouter {
|
|||
AutoRoute(page: ServerSettingsRoute.page),
|
||||
AutoRoute(page: ServerLogsRoute.page),
|
||||
AutoRoute(page: TokensRoute.page),
|
||||
AutoRoute(page: MemoryUsageByServiceRoute.page),
|
||||
],
|
||||
),
|
||||
AutoRoute(page: ServicesMigrationRoute.page),
|
||||
|
@ -166,6 +168,8 @@ String getRouteTitle(final String routeName) {
|
|||
return 'storage.extending_volume_title';
|
||||
case 'TokensRoute':
|
||||
return 'tokens.title';
|
||||
case 'MemoryUsageByServiceRoute':
|
||||
return 'resource_chart.memory';
|
||||
default:
|
||||
return routeName;
|
||||
}
|
||||
|
|
|
@ -84,6 +84,12 @@ abstract class _$RootRouter extends RootStackRouter {
|
|||
child: const InitializingPage(),
|
||||
);
|
||||
},
|
||||
MemoryUsageByServiceRoute.name: (routeData) {
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const MemoryUsageByServiceScreen(),
|
||||
);
|
||||
},
|
||||
MoreRoute.name: (routeData) {
|
||||
return AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
|
@ -415,6 +421,20 @@ class InitializingRoute extends PageRouteInfo<void> {
|
|||
static const PageInfo<void> page = PageInfo<void>(name);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [MemoryUsageByServiceScreen]
|
||||
class MemoryUsageByServiceRoute extends PageRouteInfo<void> {
|
||||
const MemoryUsageByServiceRoute({List<PageRouteInfo>? children})
|
||||
: super(
|
||||
MemoryUsageByServiceRoute.name,
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'MemoryUsageByServiceRoute';
|
||||
|
||||
static const PageInfo<void> page = PageInfo<void>(name);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [MorePage]
|
||||
class MoreRoute extends PageRouteInfo<void> {
|
||||
|
|
Loading…
Reference in a new issue