feat: Allow viewing service logs from the service screen

This commit is contained in:
Inex Code 2024-07-30 01:47:27 +03:00
parent 74eb1135df
commit 894d23bb7c
14 changed files with 237 additions and 48 deletions

View file

@ -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."

View file

@ -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

View file

@ -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<String, dynamic> toJson() {
final result$data = <String, dynamic>{};
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<TRes> {
int? limit,
String? upCursor,
String? downCursor,
String? filterBySlice,
});
}
@ -462,12 +486,15 @@ class _CopyWithImpl$Variables$Query$Logs<TRes>
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<TRes>
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: [

View file

@ -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!

View file

@ -2289,5 +2289,13 @@ const possibleTypesMap = <String, Set<String>>{
'EnumConfigItem',
'StringConfigItem',
},
'MonitoringMetricsResult': {
'MonitoringMetrics',
'MonitoringQueryError',
},
'MonitoringValuesResult': {
'MonitoringValues',
'MonitoringQueryError',
},
'StorageUsageInterface': {'ServiceStorageUsage'},
};

View file

@ -5,6 +5,7 @@ mixin LogsApi on GraphQLApiMap {
required final int limit,
final String? upCursor,
final String? downCursor,
final String? slice,
}) async {
QueryResult<Query$Logs> response;
List<ServerLogEntry> 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);

View file

@ -15,16 +15,20 @@ class ServerLogsBloc extends Bloc<ServerLogsEvent, ServerLogsState> {
ServerLogsBloc() : super(ServerLogsInitial()) {
on<ServerLogsFetch>((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<ServerLogEntry>.empty(growable: true),
meta,
false,
newEntries: List<ServerLogEntry>.empty(growable: true),
meta: meta,
loadingMore: false,
slice: slice,
),
);
if (_apiLogsSubscription != null) {
@ -49,17 +53,21 @@ class ServerLogsBloc extends Bloc<ServerLogsEvent, ServerLogsState> {
!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<ServerLogsEvent, ServerLogsState> {
on<ServerLogsGotNewEntry>((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<ServerLogsEvent, ServerLogsState> {
);
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<ServerLogsEvent, ServerLogsState> {
// 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<ApiConnectionRepository>().apiData.apiVersion.data;
@ -124,6 +138,7 @@ class ServerLogsBloc extends Bloc<ServerLogsEvent, ServerLogsState> {
limit: limit,
upCursor: upCursor,
downCursor: downCursor,
slice: slice,
);
}

View file

@ -5,6 +5,10 @@ sealed class ServerLogsEvent extends Equatable {
}
final class ServerLogsFetch extends ServerLogsEvent {
const ServerLogsFetch({this.serviceId});
final String? serviceId;
@override
List<Object> get props => [];
}

View file

@ -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<ServerLogEntry> oldEntries;
final List<ServerLogEntry> newEntries;
final ServerLogsPageMeta meta;
final bool loadingMore;
final String _lastCursor;
final String? slice;
List<String> get systemdUnits => oldEntries
.map((final entry) => entry.systemdUnit ?? 'kernel')
@ -56,7 +58,7 @@ final class ServerLogsLoaded extends ServerLogsState {
}
@override
List<Object> get props => [oldEntries, newEntries, meta, _lastCursor];
List<Object?> get props => [oldEntries, newEntries, meta, _lastCursor, slice];
}
final class ServerLogsError extends ServerLogsState {

View file

@ -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,
),
),
],
),
);

View file

@ -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<ServerLogsScreen> createState() => _ServerLogsScreenState();
@ -27,7 +29,7 @@ class _ServerLogsScreenState extends State<ServerLogsScreen> {
super.initState();
_serverLogsBloc = BlocProvider.of<ServerLogsBloc>(context);
_scrollController.addListener(_onScroll);
_serverLogsBloc.add(ServerLogsFetch());
_serverLogsBloc.add(ServerLogsFetch(serviceId: widget.serviceId));
}
@override
@ -82,7 +84,7 @@ class _ServerLogsScreenState extends State<ServerLogsScreen> {
const Key centerKey = ValueKey<String>('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<ServerLogsBloc, ServerLogsState>(
builder: (final context, final state) {
@ -107,6 +109,14 @@ class _ServerLogsScreenState extends State<ServerLogsScreen> {
_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,

View file

@ -83,7 +83,7 @@ class _ServerDetailsScreenState extends State<ServerDetailsScreen>
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(

View file

@ -172,6 +172,17 @@ class _ServicePageState extends State<ServicePage> {
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,
),
),
],
);
}

View file

@ -133,9 +133,14 @@ abstract class _$RootRouter extends RootStackRouter {
);
},
ServerLogsRoute.name: (routeData) {
final args = routeData.argsAs<ServerLogsRouteArgs>(
orElse: () => const ServerLogsRouteArgs());
return AutoRoutePage<dynamic>(
routeData: routeData,
child: const ServerLogsScreen(),
child: ServerLogsScreen(
serviceId: args.serviceId,
key: args.key,
),
);
},
ServerSettingsRoute.name: (routeData) {
@ -524,16 +529,40 @@ class ServerDetailsRoute extends PageRouteInfo<void> {
/// generated route for
/// [ServerLogsScreen]
class ServerLogsRoute extends PageRouteInfo<void> {
const ServerLogsRoute({List<PageRouteInfo>? children})
: super(
class ServerLogsRoute extends PageRouteInfo<ServerLogsRouteArgs> {
ServerLogsRoute({
String? serviceId,
Key? key,
List<PageRouteInfo>? children,
}) : super(
ServerLogsRoute.name,
args: ServerLogsRouteArgs(
serviceId: serviceId,
key: key,
),
initialChildren: children,
);
static const String name = 'ServerLogsRoute';
static const PageInfo<void> page = PageInfo<void>(name);
static const PageInfo<ServerLogsRouteArgs> page =
PageInfo<ServerLogsRouteArgs>(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