diff --git a/assets/translations/en.json b/assets/translations/en.json index 94b250fa..9cc5b883 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -370,7 +370,8 @@ "invalid_input": "Invalid input", "create_job": "Create job", "update_job": "Update job", - "wait_for_jobs": "Server is busy with other jobs. Please wait until they are finished." + "wait_for_jobs": "Server is busy with other jobs. Please wait until they are finished.", + "logs": "Service logs" }, "mail": { "login_info": "Use username and password from users tab. IMAP port is 143 with STARTTLS, SMTP port is 587 with STARTTLS." diff --git a/lib/logic/api_maps/graphql_maps/schema/logs.graphql b/lib/logic/api_maps/graphql_maps/schema/logs.graphql index bad9a19e..ea538e4c 100644 --- a/lib/logic/api_maps/graphql_maps/schema/logs.graphql +++ b/lib/logic/api_maps/graphql_maps/schema/logs.graphql @@ -7,9 +7,9 @@ fragment LogEntry on LogEntry { cursor } -query Logs($limit: Int!, $upCursor: String, $downCursor: String) { +query Logs($limit: Int!, $upCursor: String, $downCursor: String, $filterBySlice: String) { logs { - paginated(limit: $limit, upCursor: $upCursor, downCursor: $downCursor) { + paginated(limit: $limit, upCursor: $upCursor, downCursor: $downCursor, filterBySlice: $filterBySlice) { pageMeta { upCursor downCursor diff --git a/lib/logic/api_maps/graphql_maps/schema/logs.graphql.dart b/lib/logic/api_maps/graphql_maps/schema/logs.graphql.dart index b92e876f..549af713 100644 --- a/lib/logic/api_maps/graphql_maps/schema/logs.graphql.dart +++ b/lib/logic/api_maps/graphql_maps/schema/logs.graphql.dart @@ -329,11 +329,13 @@ class Variables$Query$Logs { required int limit, String? upCursor, String? downCursor, + String? filterBySlice, }) => Variables$Query$Logs._({ r'limit': limit, if (upCursor != null) r'upCursor': upCursor, if (downCursor != null) r'downCursor': downCursor, + if (filterBySlice != null) r'filterBySlice': filterBySlice, }); Variables$Query$Logs._(this._$data); @@ -350,6 +352,10 @@ class Variables$Query$Logs { final l$downCursor = data['downCursor']; result$data['downCursor'] = (l$downCursor as String?); } + if (data.containsKey('filterBySlice')) { + final l$filterBySlice = data['filterBySlice']; + result$data['filterBySlice'] = (l$filterBySlice as String?); + } return Variables$Query$Logs._(result$data); } @@ -361,6 +367,8 @@ class Variables$Query$Logs { String? get downCursor => (_$data['downCursor'] as String?); + String? get filterBySlice => (_$data['filterBySlice'] as String?); + Map toJson() { final result$data = {}; final l$limit = limit; @@ -373,6 +381,10 @@ class Variables$Query$Logs { final l$downCursor = downCursor; result$data['downCursor'] = l$downCursor; } + if (_$data.containsKey('filterBySlice')) { + final l$filterBySlice = filterBySlice; + result$data['filterBySlice'] = l$filterBySlice; + } return result$data; } @@ -413,6 +425,15 @@ class Variables$Query$Logs { if (l$downCursor != lOther$downCursor) { return false; } + final l$filterBySlice = filterBySlice; + final lOther$filterBySlice = other.filterBySlice; + if (_$data.containsKey('filterBySlice') != + other._$data.containsKey('filterBySlice')) { + return false; + } + if (l$filterBySlice != lOther$filterBySlice) { + return false; + } return true; } @@ -421,10 +442,12 @@ class Variables$Query$Logs { final l$limit = limit; final l$upCursor = upCursor; final l$downCursor = downCursor; + final l$filterBySlice = filterBySlice; return Object.hashAll([ l$limit, _$data.containsKey('upCursor') ? l$upCursor : const {}, _$data.containsKey('downCursor') ? l$downCursor : const {}, + _$data.containsKey('filterBySlice') ? l$filterBySlice : const {}, ]); } } @@ -442,6 +465,7 @@ abstract class CopyWith$Variables$Query$Logs { int? limit, String? upCursor, String? downCursor, + String? filterBySlice, }); } @@ -462,12 +486,15 @@ class _CopyWithImpl$Variables$Query$Logs Object? limit = _undefined, Object? upCursor = _undefined, Object? downCursor = _undefined, + Object? filterBySlice = _undefined, }) => _then(Variables$Query$Logs._({ ..._instance._$data, if (limit != _undefined && limit != null) 'limit': (limit as int), if (upCursor != _undefined) 'upCursor': (upCursor as String?), if (downCursor != _undefined) 'downCursor': (downCursor as String?), + if (filterBySlice != _undefined) + 'filterBySlice': (filterBySlice as String?), })); } @@ -481,6 +508,7 @@ class _CopyWithStubImpl$Variables$Query$Logs int? limit, String? upCursor, String? downCursor, + String? filterBySlice, }) => _res; } @@ -645,6 +673,15 @@ const documentNodeQueryLogs = DocumentNode(definitions: [ defaultValue: DefaultValueNode(value: null), directives: [], ), + VariableDefinitionNode( + variable: VariableNode(name: NameNode(value: 'filterBySlice')), + type: NamedTypeNode( + name: NameNode(value: 'String'), + isNonNull: false, + ), + defaultValue: DefaultValueNode(value: null), + directives: [], + ), ], directives: [], selectionSet: SelectionSetNode(selections: [ @@ -670,6 +707,10 @@ const documentNodeQueryLogs = DocumentNode(definitions: [ name: NameNode(value: 'downCursor'), value: VariableNode(name: NameNode(value: 'downCursor')), ), + ArgumentNode( + name: NameNode(value: 'filterBySlice'), + value: VariableNode(name: NameNode(value: 'filterBySlice')), + ), ], directives: [], selectionSet: SelectionSetNode(selections: [ diff --git a/lib/logic/api_maps/graphql_maps/schema/schema.graphql b/lib/logic/api_maps/graphql_maps/schema/schema.graphql index d904c569..7d9b15dc 100644 --- a/lib/logic/api_maps/graphql_maps/schema/schema.graphql +++ b/lib/logic/api_maps/graphql_maps/schema/schema.graphql @@ -146,6 +146,13 @@ interface ConfigItem { type: String! } +type CpuMonitoring { + start: DateTime + end: DateTime + step: Int! + overallUsage: MonitoringValuesResult! +} + """Date with time (isoformat)""" scalar DateTime @@ -156,6 +163,13 @@ type DeviceApiTokenMutationReturn implements MutationReturnInterface { token: String } +type DiskMonitoring { + start: DateTime + end: DateTime + step: Int! + overallUsage: MonitoringMetricsResult! +} + enum DnsProvider { CLOUDFLARE DIGITALOCEAN @@ -210,9 +224,9 @@ input InitializeRepositoryInput { } """ -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf). """ -scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") +scalar JSON @specifiedBy(url: "https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf") type Job { getJobs: [ApiJob!]! @@ -233,7 +247,7 @@ type LogEntry { } type Logs { - paginated(limit: Int! = 20, upCursor: String = null, downCursor: String = null): PaginatedEntries! + paginated(limit: Int! = 20, upCursor: String = null, downCursor: String = null, filterBySlice: String = null, filterByUnit: String = null): PaginatedEntries! } type LogsPageMeta { @@ -241,6 +255,15 @@ type LogsPageMeta { downCursor: String } +type MemoryMonitoring { + start: DateTime + end: DateTime + step: Int! + overallUsage: MonitoringValuesResult! + averageUsageByService: MonitoringMetricsResult! + maxUsageByService: MonitoringMetricsResult! +} + input MigrateToBindsInput { emailBlockDevice: String! bitwardenBlockDevice: String! @@ -249,6 +272,39 @@ input MigrateToBindsInput { pleromaBlockDevice: String! } +type Monitoring { + cpuUsage(start: DateTime = null, end: DateTime = null, step: Int! = 60): CpuMonitoring! + memoryUsage(start: DateTime = null, end: DateTime = null, step: Int! = 60): MemoryMonitoring! + diskUsage(start: DateTime = null, end: DateTime = null, step: Int! = 60): DiskMonitoring! + networkUsage(start: DateTime = null, end: DateTime = null, step: Int! = 60): NetworkMonitoring! +} + +type MonitoringMetric { + id: String! + values: [MonitoringValue!]! +} + +type MonitoringMetrics { + metrics: [MonitoringMetric!]! +} + +union MonitoringMetricsResult = MonitoringMetrics | MonitoringQueryError + +type MonitoringQueryError { + error: String! +} + +type MonitoringValue { + timestamp: DateTime! + value: String! +} + +type MonitoringValues { + values: [MonitoringValue!]! +} + +union MonitoringValuesResult = MonitoringValues | MonitoringQueryError + input MoveServiceInput { serviceId: String! location: String! @@ -301,6 +357,13 @@ interface MutationReturnInterface { code: Int! } +type NetworkMonitoring { + start: DateTime + end: DateTime + step: Int! + overallUsage: MonitoringMetricsResult! +} + type PaginatedEntries { """Metadata to aid in pagination.""" pageMeta: LogsPageMeta! @@ -318,6 +381,7 @@ type Query { jobs: Job! services: Services! backup: Backup! + monitoring: Monitoring! } input RecoveryKeyLimitsInput { @@ -357,6 +421,7 @@ type Service { isMovable: Boolean! isRequired: Boolean! isEnabled: Boolean! + isInstalled: Boolean! canBeBackedUp: Boolean! backupDescription: String! status: ServiceStatusEnum! diff --git a/lib/logic/api_maps/graphql_maps/schema/schema.graphql.dart b/lib/logic/api_maps/graphql_maps/schema/schema.graphql.dart index 71feddeb..d224dbb5 100644 --- a/lib/logic/api_maps/graphql_maps/schema/schema.graphql.dart +++ b/lib/logic/api_maps/graphql_maps/schema/schema.graphql.dart @@ -2289,5 +2289,13 @@ const possibleTypesMap = >{ 'EnumConfigItem', 'StringConfigItem', }, + 'MonitoringMetricsResult': { + 'MonitoringMetrics', + 'MonitoringQueryError', + }, + 'MonitoringValuesResult': { + 'MonitoringValues', + 'MonitoringQueryError', + }, 'StorageUsageInterface': {'ServiceStorageUsage'}, }; diff --git a/lib/logic/api_maps/graphql_maps/server_api/logs_api.dart b/lib/logic/api_maps/graphql_maps/server_api/logs_api.dart index 2da7b091..23f1104d 100644 --- a/lib/logic/api_maps/graphql_maps/server_api/logs_api.dart +++ b/lib/logic/api_maps/graphql_maps/server_api/logs_api.dart @@ -5,6 +5,7 @@ mixin LogsApi on GraphQLApiMap { required final int limit, final String? upCursor, final String? downCursor, + final String? slice, }) async { QueryResult response; List logsList = []; @@ -17,6 +18,7 @@ mixin LogsApi on GraphQLApiMap { upCursor: upCursor, downCursor: downCursor, limit: limit, + filterBySlice: slice, ); final query = Options$Query$Logs(variables: variables); response = await client.query$Logs(query); diff --git a/lib/logic/bloc/server_logs/server_logs_bloc.dart b/lib/logic/bloc/server_logs/server_logs_bloc.dart index 0741dfe8..978ba465 100644 --- a/lib/logic/bloc/server_logs/server_logs_bloc.dart +++ b/lib/logic/bloc/server_logs/server_logs_bloc.dart @@ -15,16 +15,20 @@ class ServerLogsBloc extends Bloc { ServerLogsBloc() : super(ServerLogsInitial()) { on((final event, final emit) async { emit(ServerLogsLoading()); + final String? slice = event.serviceId != null + ? '${event.serviceId?.replaceAll('-', '_')}.slice' + : null; try { - final (logsData, meta) = await _getLogs(limit: 50); + final (logsData, meta) = await _getLogs(limit: 50, slice: slice); emit( ServerLogsLoaded( - logsData.sorted( + oldEntries: logsData.sorted( (final a, final b) => b.timestamp.compareTo(a.timestamp), ), - List.empty(growable: true), - meta, - false, + newEntries: List.empty(growable: true), + meta: meta, + loadingMore: false, + slice: slice, ), ); if (_apiLogsSubscription != null) { @@ -49,17 +53,21 @@ class ServerLogsBloc extends Bloc { !currentState.loadingMore && currentState.meta.upCursor != null) { try { - final (logsData, meta) = - await _getLogs(limit: 50, downCursor: currentState.meta.upCursor); + final (logsData, meta) = await _getLogs( + limit: 50, + downCursor: currentState.meta.upCursor, + slice: currentState.slice, + ); final allEntries = currentState.oldEntries ..addAll(logsData) ..sort((final a, final b) => b.timestamp.compareTo(a.timestamp)); emit( ServerLogsLoaded( - allEntries.toSet().toList(), - currentState.newEntries, - meta, - false, + oldEntries: allEntries.toSet().toList(), + newEntries: currentState.newEntries, + meta: meta, + loadingMore: false, + slice: currentState.slice, ), ); } catch (e) { @@ -71,6 +79,10 @@ class ServerLogsBloc extends Bloc { on((final event, final emit) { final currentState = state; if (currentState is ServerLogsLoaded) { + if (currentState.slice != null && + event.entry.systemdSlice != currentState.slice) { + return; + } final allEntries = currentState.newEntries ..add(event.entry) ..sort( @@ -78,10 +90,11 @@ class ServerLogsBloc extends Bloc { ); emit( ServerLogsLoaded( - currentState.oldEntries, - allEntries.toSet().toList(), - currentState.meta, - currentState.loadingMore, + oldEntries: currentState.oldEntries, + newEntries: allEntries.toSet().toList(), + meta: currentState.meta, + loadingMore: currentState.loadingMore, + slice: currentState.slice, ), ); } @@ -103,6 +116,7 @@ class ServerLogsBloc extends Bloc { // All entries returned will be greater than this cursor. Sets lower bound on results. final String? downCursor, // Only one cursor can be set at a time. + final String? slice, }) { final String? apiVersion = getIt().apiData.apiVersion.data; @@ -124,6 +138,7 @@ class ServerLogsBloc extends Bloc { limit: limit, upCursor: upCursor, downCursor: downCursor, + slice: slice, ); } diff --git a/lib/logic/bloc/server_logs/server_logs_event.dart b/lib/logic/bloc/server_logs/server_logs_event.dart index cf1f9127..1e0d2f07 100644 --- a/lib/logic/bloc/server_logs/server_logs_event.dart +++ b/lib/logic/bloc/server_logs/server_logs_event.dart @@ -5,6 +5,10 @@ sealed class ServerLogsEvent extends Equatable { } final class ServerLogsFetch extends ServerLogsEvent { + const ServerLogsFetch({this.serviceId}); + + final String? serviceId; + @override List get props => []; } diff --git a/lib/logic/bloc/server_logs/server_logs_state.dart b/lib/logic/bloc/server_logs/server_logs_state.dart index 9f8c6d15..3f64c743 100644 --- a/lib/logic/bloc/server_logs/server_logs_state.dart +++ b/lib/logic/bloc/server_logs/server_logs_state.dart @@ -15,18 +15,20 @@ final class ServerLogsLoading extends ServerLogsState { } final class ServerLogsLoaded extends ServerLogsState { - ServerLogsLoaded( - this.oldEntries, - this.newEntries, - this.meta, - this.loadingMore, - ) : _lastCursor = newEntries.isEmpty ? '' : newEntries.first.cursor; + ServerLogsLoaded({ + required this.oldEntries, + required this.newEntries, + required this.meta, + required this.loadingMore, + this.slice, + }) : _lastCursor = newEntries.isEmpty ? '' : newEntries.first.cursor; final List oldEntries; final List newEntries; final ServerLogsPageMeta meta; final bool loadingMore; final String _lastCursor; + final String? slice; List get systemdUnits => oldEntries .map((final entry) => entry.systemdUnit ?? 'kernel') @@ -56,7 +58,7 @@ final class ServerLogsLoaded extends ServerLogsState { } @override - List get props => [oldEntries, newEntries, meta, _lastCursor]; + List get props => [oldEntries, newEntries, meta, _lastCursor, slice]; } final class ServerLogsError extends ServerLogsState { diff --git a/lib/ui/helpers/empty_page_placeholder.dart b/lib/ui/helpers/empty_page_placeholder.dart index 5abc8434..10ba21cf 100644 --- a/lib/ui/helpers/empty_page_placeholder.dart +++ b/lib/ui/helpers/empty_page_placeholder.dart @@ -6,7 +6,7 @@ class EmptyPagePlaceholder extends StatelessWidget { const EmptyPagePlaceholder({ required this.title, required this.iconData, - required this.description, + this.description, this.showReadyCard = false, super.key, }); @@ -14,7 +14,7 @@ class EmptyPagePlaceholder extends StatelessWidget { final bool showReadyCard; final IconData iconData; final String title; - final String description; + final String? description; @override Widget build(final BuildContext context) => showReadyCard @@ -54,7 +54,7 @@ class _ContentWidget extends StatelessWidget { final IconData iconData; final String title; - final String description; + final String? description; @override Widget build(final BuildContext context) => Container( @@ -76,14 +76,15 @@ class _ContentWidget extends StatelessWidget { ), textAlign: TextAlign.center, ), - const Gap(8), - Text( - description, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.onBackground, - ), - ), + if (description != null) const Gap(8), + if (description != null) + Text( + description!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.onBackground, + ), + ), ], ), ); diff --git a/lib/ui/pages/server_details/logs/logs_screen.dart b/lib/ui/pages/server_details/logs/logs_screen.dart index 89e94218..41323ef4 100644 --- a/lib/ui/pages/server_details/logs/logs_screen.dart +++ b/lib/ui/pages/server_details/logs/logs_screen.dart @@ -10,7 +10,9 @@ import 'package:selfprivacy/utils/platform_adapter.dart'; @RoutePage() class ServerLogsScreen extends StatefulWidget { - const ServerLogsScreen({super.key}); + const ServerLogsScreen({this.serviceId, super.key}); + + final String? serviceId; @override State createState() => _ServerLogsScreenState(); @@ -27,7 +29,7 @@ class _ServerLogsScreenState extends State { super.initState(); _serverLogsBloc = BlocProvider.of(context); _scrollController.addListener(_onScroll); - _serverLogsBloc.add(ServerLogsFetch()); + _serverLogsBloc.add(ServerLogsFetch(serviceId: widget.serviceId)); } @override @@ -82,7 +84,7 @@ class _ServerLogsScreenState extends State { const Key centerKey = ValueKey('server-logs-center-key'); return Scaffold( appBar: AppBar( - title: Text('server.logs'.tr()), + title: Text(widget.serviceId == null ? 'server.logs'.tr() : 'service_page.logs'.tr()), ), endDrawer: BlocBuilder( builder: (final context, final state) { @@ -107,6 +109,14 @@ class _ServerLogsScreenState extends State { _selectedSystemdUnit == null ? state.oldEntries : state.oldEntriesForUnit(_selectedSystemdUnit!); + if (filteredOldLogs.isEmpty && filteredNewLogs.isEmpty) { + return Center( + child: EmptyPagePlaceholder( + title: 'server.logs_empty'.tr(), + iconData: Icons.info_outline, + ), + ); + } return CustomScrollView( center: centerKey, controller: _scrollController, diff --git a/lib/ui/pages/server_details/server_details_screen.dart b/lib/ui/pages/server_details/server_details_screen.dart index 49543991..a0d28ad7 100644 --- a/lib/ui/pages/server_details/server_details_screen.dart +++ b/lib/ui/pages/server_details/server_details_screen.dart @@ -83,7 +83,7 @@ class _ServerDetailsScreenState extends State ListTile( title: Text('server.logs'.tr()), leading: const Icon(Icons.manage_search_outlined), - onTap: () => context.pushRoute(const ServerLogsRoute()), + onTap: () => context.pushRoute(ServerLogsRoute()), ), const Divider(height: 32), Text( diff --git a/lib/ui/pages/services/service_page.dart b/lib/ui/pages/services/service_page.dart index 8cfe905f..a01e45f5 100644 --- a/lib/ui/pages/services/service_page.dart +++ b/lib/ui/pages/services/service_page.dart @@ -172,6 +172,17 @@ class _ServicePageState extends State { style: Theme.of(context).textTheme.titleMedium, ), ), + ListTile( + iconColor: Theme.of(context).colorScheme.onBackground, + onTap: () => context.pushRoute( + ServerLogsRoute(serviceId: service.id), + ), + leading: const Icon(Icons.manage_search_outlined), + title: Text( + 'service_page.logs'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + ), ], ); } diff --git a/lib/ui/router/router.gr.dart b/lib/ui/router/router.gr.dart index d30ac51c..ff67e07b 100644 --- a/lib/ui/router/router.gr.dart +++ b/lib/ui/router/router.gr.dart @@ -133,9 +133,14 @@ abstract class _$RootRouter extends RootStackRouter { ); }, ServerLogsRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const ServerLogsRouteArgs()); return AutoRoutePage( routeData: routeData, - child: const ServerLogsScreen(), + child: ServerLogsScreen( + serviceId: args.serviceId, + key: args.key, + ), ); }, ServerSettingsRoute.name: (routeData) { @@ -524,16 +529,40 @@ class ServerDetailsRoute extends PageRouteInfo { /// generated route for /// [ServerLogsScreen] -class ServerLogsRoute extends PageRouteInfo { - const ServerLogsRoute({List? children}) - : super( +class ServerLogsRoute extends PageRouteInfo { + ServerLogsRoute({ + String? serviceId, + Key? key, + List? children, + }) : super( ServerLogsRoute.name, + args: ServerLogsRouteArgs( + serviceId: serviceId, + key: key, + ), initialChildren: children, ); static const String name = 'ServerLogsRoute'; - static const PageInfo page = PageInfo(name); + static const PageInfo page = + PageInfo(name); +} + +class ServerLogsRouteArgs { + const ServerLogsRouteArgs({ + this.serviceId, + this.key, + }); + + final String? serviceId; + + final Key? key; + + @override + String toString() { + return 'ServerLogsRouteArgs{serviceId: $serviceId, key: $key}'; + } } /// generated route for