mirror of
https://git.selfprivacy.org/kherel/selfprivacy.org.app.git
synced 2024-11-10 19:03:12 +00:00
feat: Server logs screen
This commit is contained in:
parent
39babdeeaa
commit
515b0e2c67
|
@ -170,6 +170,18 @@
|
||||||
"few": "{} cores",
|
"few": "{} cores",
|
||||||
"many": "{} cores",
|
"many": "{} cores",
|
||||||
"other": "{} cores"
|
"other": "{} cores"
|
||||||
|
},
|
||||||
|
"logs": "Server logs",
|
||||||
|
"logs_empty": "No logs yet",
|
||||||
|
"filter_by_systemd_unit": "Filter by systemd unit",
|
||||||
|
"all_units": "All units",
|
||||||
|
"log_dialog": {
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"cursor": "Cursor",
|
||||||
|
"priority": "Priority",
|
||||||
|
"systemd_unit": "Systemd unit",
|
||||||
|
"systemd_slice": "Systemd slice",
|
||||||
|
"message": "Message"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domain": {
|
"domain": {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:selfprivacy/logic/bloc/connection_status_bloc.dart';
|
||||||
import 'package:selfprivacy/logic/bloc/devices/devices_bloc.dart';
|
import 'package:selfprivacy/logic/bloc/devices/devices_bloc.dart';
|
||||||
import 'package:selfprivacy/logic/bloc/recovery_key/recovery_key_bloc.dart';
|
import 'package:selfprivacy/logic/bloc/recovery_key/recovery_key_bloc.dart';
|
||||||
import 'package:selfprivacy/logic/bloc/server_jobs/server_jobs_bloc.dart';
|
import 'package:selfprivacy/logic/bloc/server_jobs/server_jobs_bloc.dart';
|
||||||
|
import 'package:selfprivacy/logic/bloc/server_logs/server_logs_bloc.dart';
|
||||||
import 'package:selfprivacy/logic/bloc/services/services_bloc.dart';
|
import 'package:selfprivacy/logic/bloc/services/services_bloc.dart';
|
||||||
import 'package:selfprivacy/logic/bloc/users/users_bloc.dart';
|
import 'package:selfprivacy/logic/bloc/users/users_bloc.dart';
|
||||||
import 'package:selfprivacy/logic/bloc/volumes/volumes_bloc.dart';
|
import 'package:selfprivacy/logic/bloc/volumes/volumes_bloc.dart';
|
||||||
|
@ -36,6 +37,7 @@ class BlocAndProviderConfigState extends State<BlocAndProviderConfig> {
|
||||||
late final ConnectionStatusBloc connectionStatusBloc;
|
late final ConnectionStatusBloc connectionStatusBloc;
|
||||||
late final ServerDetailsCubit serverDetailsCubit;
|
late final ServerDetailsCubit serverDetailsCubit;
|
||||||
late final VolumesBloc volumesBloc;
|
late final VolumesBloc volumesBloc;
|
||||||
|
late final ServerLogsBloc serverLogsBloc;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -52,6 +54,7 @@ class BlocAndProviderConfigState extends State<BlocAndProviderConfig> {
|
||||||
connectionStatusBloc = ConnectionStatusBloc();
|
connectionStatusBloc = ConnectionStatusBloc();
|
||||||
serverDetailsCubit = ServerDetailsCubit();
|
serverDetailsCubit = ServerDetailsCubit();
|
||||||
volumesBloc = VolumesBloc();
|
volumesBloc = VolumesBloc();
|
||||||
|
serverLogsBloc = ServerLogsBloc();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -94,6 +97,9 @@ class BlocAndProviderConfigState extends State<BlocAndProviderConfig> {
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (final _) => JobsCubit(),
|
create: (final _) => JobsCubit(),
|
||||||
),
|
),
|
||||||
|
BlocProvider(
|
||||||
|
create: (final _) => serverLogsBloc,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
);
|
);
|
||||||
|
|
28
lib/logic/api_maps/graphql_maps/schema/logs.graphql
Normal file
28
lib/logic/api_maps/graphql_maps/schema/logs.graphql
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
fragment LogEntry on LogEntry {
|
||||||
|
message
|
||||||
|
timestamp
|
||||||
|
priority
|
||||||
|
systemdUnit
|
||||||
|
systemdSlice
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
query Logs($limit: Int!, $upCursor: String, $downCursor: String) {
|
||||||
|
logs {
|
||||||
|
paginated(limit: $limit, upCursor: $upCursor, downCursor: $downCursor) {
|
||||||
|
pageMeta {
|
||||||
|
upCursor
|
||||||
|
downCursor
|
||||||
|
}
|
||||||
|
entries {
|
||||||
|
...LogEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription LogEntries {
|
||||||
|
logEntries {
|
||||||
|
...LogEntry
|
||||||
|
}
|
||||||
|
}
|
1588
lib/logic/api_maps/graphql_maps/schema/logs.graphql.dart
Normal file
1588
lib/logic/api_maps/graphql_maps/schema/logs.graphql.dart
Normal file
File diff suppressed because it is too large
Load diff
53
lib/logic/api_maps/graphql_maps/server_api/logs_api.dart
Normal file
53
lib/logic/api_maps/graphql_maps/server_api/logs_api.dart
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
part of 'server_api.dart';
|
||||||
|
|
||||||
|
mixin LogsApi on GraphQLApiMap {
|
||||||
|
Future<(List<ServerLogEntry>, ServerLogsPageMeta)> getServerLogs({
|
||||||
|
required final int limit,
|
||||||
|
final String? upCursor,
|
||||||
|
final String? downCursor,
|
||||||
|
}) async {
|
||||||
|
QueryResult<Query$Logs> response;
|
||||||
|
List<ServerLogEntry> logsList = [];
|
||||||
|
ServerLogsPageMeta pageMeta =
|
||||||
|
const ServerLogsPageMeta(downCursor: null, upCursor: null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final GraphQLClient client = await getClient();
|
||||||
|
final variables = Variables$Query$Logs(
|
||||||
|
upCursor: upCursor,
|
||||||
|
downCursor: downCursor,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
final query = Options$Query$Logs(variables: variables);
|
||||||
|
response = await client.query$Logs(query);
|
||||||
|
if (response.hasException) {
|
||||||
|
print(response.exception.toString());
|
||||||
|
}
|
||||||
|
if (response.parsedData == null) {
|
||||||
|
return (logsList, pageMeta);
|
||||||
|
}
|
||||||
|
logsList = response.parsedData?.logs.paginated.entries
|
||||||
|
.map<ServerLogEntry>(
|
||||||
|
(final log) => ServerLogEntry.fromGraphQL(log),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
pageMeta = ServerLogsPageMeta.fromGraphQL(
|
||||||
|
response.parsedData!.logs.paginated.pageMeta,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (logsList, pageMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ServerLogEntry> getServerLogsStream() async* {
|
||||||
|
final GraphQLClient client = await getSubscriptionClient();
|
||||||
|
final subscription = client.subscribe$LogEntries();
|
||||||
|
await for (final response in subscription) {
|
||||||
|
final log = ServerLogEntry.fromGraphQL(response.parsedData!.logEntries);
|
||||||
|
yield log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import 'package:selfprivacy/logic/api_maps/generic_result.dart';
|
||||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/graphql_api_map.dart';
|
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/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/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/schema.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_api.graphql.dart';
|
||||||
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/server_settings.graphql.dart';
|
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/server_settings.graphql.dart';
|
||||||
|
@ -22,6 +23,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/recovery_token_status.dart';
|
||||||
import 'package:selfprivacy/logic/models/json/server_disk_volume.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/json/server_job.dart';
|
||||||
|
import 'package:selfprivacy/logic/models/server_logs.dart';
|
||||||
import 'package:selfprivacy/logic/models/service.dart';
|
import 'package:selfprivacy/logic/models/service.dart';
|
||||||
import 'package:selfprivacy/logic/models/ssh_settings.dart';
|
import 'package:selfprivacy/logic/models/ssh_settings.dart';
|
||||||
import 'package:selfprivacy/logic/models/system_settings.dart';
|
import 'package:selfprivacy/logic/models/system_settings.dart';
|
||||||
|
@ -34,6 +36,7 @@ part 'server_actions_api.dart';
|
||||||
part 'services_api.dart';
|
part 'services_api.dart';
|
||||||
part 'users_api.dart';
|
part 'users_api.dart';
|
||||||
part 'volume_api.dart';
|
part 'volume_api.dart';
|
||||||
|
part 'logs_api.dart';
|
||||||
|
|
||||||
class ServerApi extends GraphQLApiMap
|
class ServerApi extends GraphQLApiMap
|
||||||
with
|
with
|
||||||
|
@ -42,7 +45,8 @@ class ServerApi extends GraphQLApiMap
|
||||||
ServerActionsApi,
|
ServerActionsApi,
|
||||||
ServicesApi,
|
ServicesApi,
|
||||||
UsersApi,
|
UsersApi,
|
||||||
BackupsApi {
|
BackupsApi,
|
||||||
|
LogsApi {
|
||||||
ServerApi({
|
ServerApi({
|
||||||
this.hasLogger = false,
|
this.hasLogger = false,
|
||||||
this.isWithToken = true,
|
this.isWithToken = true,
|
||||||
|
|
104
lib/logic/bloc/server_logs/server_logs_bloc.dart
Normal file
104
lib/logic/bloc/server_logs/server_logs_bloc.dart
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:selfprivacy/config/get_it_config.dart';
|
||||||
|
import 'package:selfprivacy/logic/models/server_logs.dart';
|
||||||
|
|
||||||
|
part 'server_logs_event.dart';
|
||||||
|
part 'server_logs_state.dart';
|
||||||
|
|
||||||
|
class ServerLogsBloc extends Bloc<ServerLogsEvent, ServerLogsState> {
|
||||||
|
ServerLogsBloc() : super(ServerLogsInitial()) {
|
||||||
|
on<ServerLogsFetch>((final event, final emit) async {
|
||||||
|
emit(ServerLogsLoading());
|
||||||
|
try {
|
||||||
|
final (logsData, meta) = await _getLogs(limit: 50);
|
||||||
|
emit(ServerLogsLoaded(logsData, meta, false));
|
||||||
|
if (_apiLogsSubscription != null) {
|
||||||
|
await _apiLogsSubscription?.cancel();
|
||||||
|
}
|
||||||
|
_apiLogsSubscription =
|
||||||
|
getIt<ApiConnectionRepository>().api.getServerLogsStream().listen(
|
||||||
|
(final ServerLogEntry logEntry) {
|
||||||
|
print('Got new log entry');
|
||||||
|
print(logEntry);
|
||||||
|
add(ServerLogsGotNewEntry(logEntry));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(ServerLogsError(e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
on<ServerLogsFetchMore>((final event, final emit) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is ServerLogsLoaded &&
|
||||||
|
!currentState.loadingMore &&
|
||||||
|
currentState.meta.upCursor != null) {
|
||||||
|
try {
|
||||||
|
final (logsData, meta) =
|
||||||
|
await _getLogs(limit: 50, downCursor: currentState.meta.upCursor);
|
||||||
|
final allEntries = currentState.entries
|
||||||
|
..addAll(logsData)
|
||||||
|
..sort((final a, final b) => b.timestamp.compareTo(a.timestamp));
|
||||||
|
emit(ServerLogsLoaded(allEntries.toSet().toList(), meta, false));
|
||||||
|
} catch (e) {
|
||||||
|
emit(ServerLogsError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
on<ServerLogsGotNewEntry>((final event, final emit) {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is ServerLogsLoaded) {
|
||||||
|
final entries = currentState.entries;
|
||||||
|
if (!entries.any((final entry) => entry.cursor == event.entry.cursor)) {
|
||||||
|
entries.add(event.entry);
|
||||||
|
entries
|
||||||
|
.sort((final a, final b) => b.timestamp.compareTo(a.timestamp));
|
||||||
|
emit(
|
||||||
|
ServerLogsLoaded(
|
||||||
|
entries,
|
||||||
|
currentState.meta,
|
||||||
|
currentState.loadingMore,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
on<ServerLogsDisconnect>((final event, final emit) {
|
||||||
|
_apiLogsSubscription?.cancel();
|
||||||
|
emit(ServerLogsInitial());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(List<ServerLogEntry>, ServerLogsPageMeta)> _getLogs({
|
||||||
|
// No more than 50
|
||||||
|
required final int limit,
|
||||||
|
// All entries returned will be lesser than this cursor. Sets upper bound on results.
|
||||||
|
final String? upCursor,
|
||||||
|
// 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.
|
||||||
|
}) =>
|
||||||
|
getIt<ApiConnectionRepository>().api.getServerLogs(
|
||||||
|
limit: limit,
|
||||||
|
upCursor: upCursor,
|
||||||
|
downCursor: downCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_apiLogsSubscription?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onChange(final Change<ServerLogsState> change) {
|
||||||
|
super.onChange(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamSubscription? _apiLogsSubscription;
|
||||||
|
}
|
29
lib/logic/bloc/server_logs/server_logs_event.dart
Normal file
29
lib/logic/bloc/server_logs/server_logs_event.dart
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
part of 'server_logs_bloc.dart';
|
||||||
|
|
||||||
|
sealed class ServerLogsEvent extends Equatable {
|
||||||
|
const ServerLogsEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsFetch extends ServerLogsEvent {
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsFetchMore extends ServerLogsEvent {
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsGotNewEntry extends ServerLogsEvent {
|
||||||
|
const ServerLogsGotNewEntry(this.entry);
|
||||||
|
|
||||||
|
final ServerLogEntry entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [entry];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsDisconnect extends ServerLogsEvent {
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
51
lib/logic/bloc/server_logs/server_logs_state.dart
Normal file
51
lib/logic/bloc/server_logs/server_logs_state.dart
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
part of 'server_logs_bloc.dart';
|
||||||
|
|
||||||
|
sealed class ServerLogsState extends Equatable {
|
||||||
|
const ServerLogsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsInitial extends ServerLogsState {
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsLoading extends ServerLogsState {
|
||||||
|
@override
|
||||||
|
List<Object> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsLoaded extends ServerLogsState {
|
||||||
|
const ServerLogsLoaded(this.entries, this.meta, this.loadingMore);
|
||||||
|
|
||||||
|
final List<ServerLogEntry> entries;
|
||||||
|
final ServerLogsPageMeta meta;
|
||||||
|
final bool loadingMore;
|
||||||
|
|
||||||
|
List<String> get systemdUnits => entries
|
||||||
|
.map((final entry) => entry.systemdUnit ?? 'kernel')
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
(List<ServerLogEntry>, int) entriesForUnit(final String unit) {
|
||||||
|
if (unit == 'kernel') {
|
||||||
|
final filteredEntries =
|
||||||
|
entries.where((final entry) => entry.systemdUnit == null).toList();
|
||||||
|
return (filteredEntries, filteredEntries.length);
|
||||||
|
}
|
||||||
|
final filteredEntries =
|
||||||
|
entries.where((final entry) => entry.systemdUnit == unit).toList();
|
||||||
|
return (filteredEntries, filteredEntries.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [entries, meta];
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ServerLogsError extends ServerLogsState {
|
||||||
|
const ServerLogsError(this.error);
|
||||||
|
|
||||||
|
final Object error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [error];
|
||||||
|
}
|
67
lib/logic/models/server_logs.dart
Normal file
67
lib/logic/models/server_logs.dart
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:selfprivacy/logic/api_maps/graphql_maps/schema/logs.graphql.dart';
|
||||||
|
|
||||||
|
class ServerLogEntry extends Equatable {
|
||||||
|
ServerLogEntry.fromGraphQL(final Fragment$LogEntry log)
|
||||||
|
: this(
|
||||||
|
message: log.message,
|
||||||
|
cursor: log.cursor,
|
||||||
|
priority: log.priority,
|
||||||
|
systemdSlice: log.systemdSlice,
|
||||||
|
systemdUnit: log.systemdUnit,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ServerLogEntry({
|
||||||
|
required this.message,
|
||||||
|
required this.cursor,
|
||||||
|
required this.priority,
|
||||||
|
required this.systemdSlice,
|
||||||
|
required this.systemdUnit,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final String cursor;
|
||||||
|
final int? priority;
|
||||||
|
final String? systemdSlice;
|
||||||
|
final String? systemdUnit;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
static final DateFormat _formatter = DateFormat('hh:mm:ss');
|
||||||
|
String get timeString => _formatter.format(timestamp);
|
||||||
|
String get fullUTCString => timestamp.toUtc().toIso8601String();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
message,
|
||||||
|
cursor,
|
||||||
|
priority,
|
||||||
|
systemdSlice,
|
||||||
|
systemdUnit,
|
||||||
|
timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerLogsPageMeta extends Equatable {
|
||||||
|
ServerLogsPageMeta.fromGraphQL(final Query$Logs$logs$paginated$pageMeta meta)
|
||||||
|
: this(
|
||||||
|
downCursor: meta.downCursor,
|
||||||
|
upCursor: meta.upCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ServerLogsPageMeta({
|
||||||
|
required this.downCursor,
|
||||||
|
required this.upCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? downCursor;
|
||||||
|
final String? upCursor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
downCursor,
|
||||||
|
upCursor,
|
||||||
|
];
|
||||||
|
}
|
|
@ -54,9 +54,7 @@ class _ConsolePageState extends State<ConsolePage> {
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
console.paused
|
console.paused ? Icons.play_arrow_outlined : Icons.pause_outlined,
|
||||||
? Icons.play_arrow_outlined
|
|
||||||
: Icons.pause_outlined,
|
|
||||||
),
|
),
|
||||||
onPressed: console.paused ? console.play : console.pause,
|
onPressed: console.paused ? console.play : console.pause,
|
||||||
),
|
),
|
||||||
|
|
336
lib/ui/pages/server_details/logs/logs_screen.dart
Normal file
336
lib/ui/pages/server_details/logs/logs_screen.dart
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
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:selfprivacy/logic/bloc/server_logs/server_logs_bloc.dart';
|
||||||
|
import 'package:selfprivacy/logic/models/server_logs.dart';
|
||||||
|
import 'package:selfprivacy/utils/platform_adapter.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class ServerLogsScreen extends StatefulWidget {
|
||||||
|
const ServerLogsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ServerLogsScreen> createState() => _ServerLogsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerLogsScreenState extends State<ServerLogsScreen> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
late ServerLogsBloc _serverLogsBloc;
|
||||||
|
|
||||||
|
String? _selectedSystemdUnit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_serverLogsBloc = BlocProvider.of<ServerLogsBloc>(context);
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_serverLogsBloc.add(ServerLogsFetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
_serverLogsBloc.add(ServerLogsDisconnect());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent) {
|
||||||
|
_serverLogsBloc.add(ServerLogsFetchMore());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDrawer(final List<String> systemdUnits) => Drawer(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
DrawerHeader(
|
||||||
|
child: Text('server.filter_by_systemd_unit'.tr()),
|
||||||
|
),
|
||||||
|
// a tile to reset filter
|
||||||
|
RadioListTile(
|
||||||
|
title: Text('server.all_units'.tr()),
|
||||||
|
value: null,
|
||||||
|
groupValue: _selectedSystemdUnit,
|
||||||
|
onChanged: (final value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSystemdUnit = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
for (final unit in systemdUnits.sorted())
|
||||||
|
RadioListTile(
|
||||||
|
title: Text(unit),
|
||||||
|
value: unit,
|
||||||
|
groupValue: _selectedSystemdUnit,
|
||||||
|
onChanged: (final value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSystemdUnit = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(final BuildContext context) => Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('server.logs'.tr()),
|
||||||
|
),
|
||||||
|
endDrawer: BlocBuilder<ServerLogsBloc, ServerLogsState>(
|
||||||
|
builder: (final context, final state) {
|
||||||
|
if (state is ServerLogsLoaded) {
|
||||||
|
return _buildDrawer(state.systemdUnits);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
body: BlocBuilder<ServerLogsBloc, ServerLogsState>(
|
||||||
|
builder: (final context, final state) {
|
||||||
|
final isLoadingMore =
|
||||||
|
state is ServerLogsLoaded && state.loadingMore;
|
||||||
|
if (state is ServerLogsLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
} else if (state is ServerLogsLoaded) {
|
||||||
|
if (_selectedSystemdUnit == null) {
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: state.entries.length + (isLoadingMore ? 1 : 0),
|
||||||
|
itemBuilder: (final context, final index) {
|
||||||
|
if (isLoadingMore && index == state.entries.length) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final logEntry = state.entries[index];
|
||||||
|
return LogEntryWidget(
|
||||||
|
logEntry: logEntry,
|
||||||
|
key: ValueKey(logEntry.cursor),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final (filteredLogs, filteredLength) =
|
||||||
|
state.entriesForUnit(_selectedSystemdUnit!);
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: filteredLength + (isLoadingMore ? 1 : 0),
|
||||||
|
itemBuilder: (final context, final index) {
|
||||||
|
if (isLoadingMore && index == filteredLength) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final logEntry = filteredLogs[index];
|
||||||
|
return LogEntryWidget(
|
||||||
|
logEntry: logEntry,
|
||||||
|
key: ValueKey(logEntry.cursor),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (state is ServerLogsError) {
|
||||||
|
return Center(child: Text('Error: ${state.error}'));
|
||||||
|
}
|
||||||
|
return Center(child: Text('server.no_logs'.tr()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogEntryWidget extends StatelessWidget {
|
||||||
|
const LogEntryWidget({
|
||||||
|
required this.logEntry,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ServerLogEntry logEntry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(final BuildContext context) {
|
||||||
|
final Color color = logEntry.priority == 4
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: logEntry.priority != null && logEntry.priority! <= 3
|
||||||
|
? Theme.of(context).colorScheme.error
|
||||||
|
: Theme.of(context).colorScheme.onSurface;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: DefaultTextStyle.of(context).style,
|
||||||
|
children: <TextSpan>[
|
||||||
|
TextSpan(
|
||||||
|
text: '${logEntry.timeString}: ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: logEntry.systemdUnit,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
logEntry.message,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 6,
|
||||||
|
),
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (final BuildContext context) =>
|
||||||
|
ServerLogEntryDialog(log: logEntry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// dialog with detailed log content
|
||||||
|
class ServerLogEntryDialog extends StatelessWidget {
|
||||||
|
const ServerLogEntryDialog({
|
||||||
|
required this.log,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ServerLogEntry log;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(final BuildContext context) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(log.timeString),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 16,
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: DefaultTextStyle.of(context).style,
|
||||||
|
children: <TextSpan>[
|
||||||
|
TextSpan(
|
||||||
|
text: '${'console_page.logged_at'.tr()}: ',
|
||||||
|
style: const TextStyle(),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '${log.timeString} (${log.fullUTCString})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_SectionRow('server.log_dialog.metadata'.tr()),
|
||||||
|
_KeyValueRow('server.log_dialog.cursor'.tr(), log.cursor),
|
||||||
|
if (log.priority != null)
|
||||||
|
_KeyValueRow(
|
||||||
|
'server.log_dialog.priority'.tr(), log.priority?.toString()),
|
||||||
|
if (log.systemdSlice != null)
|
||||||
|
_KeyValueRow(
|
||||||
|
'server.log_dialog.systemd_slice'.tr(), log.systemdSlice),
|
||||||
|
if (log.systemdUnit != null)
|
||||||
|
_KeyValueRow(
|
||||||
|
'server.log_dialog.systemd_unit'.tr(), log.systemdUnit),
|
||||||
|
const Divider(),
|
||||||
|
_SectionRow('server.log_dialog.message'.tr()),
|
||||||
|
_DataRow(log.message),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
// A button to copy the request to the clipboard
|
||||||
|
if (log.message.isNotEmpty)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => PlatformAdapter.setClipboard(log.message),
|
||||||
|
child: Text('console_page.copy'.tr()),
|
||||||
|
),
|
||||||
|
// close dialog
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text('basis.close'.tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionRow extends StatelessWidget {
|
||||||
|
const _SectionRow(this.title);
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(final BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
width: 2.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
title.tr(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KeyValueRow extends StatelessWidget {
|
||||||
|
const _KeyValueRow(this.title, this.value);
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String? value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(final BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: DefaultTextStyle.of(context).style,
|
||||||
|
children: <TextSpan>[
|
||||||
|
TextSpan(
|
||||||
|
text: '$title: ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
TextSpan(text: value ?? ''),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DataRow extends StatelessWidget {
|
||||||
|
const _DataRow(this.data);
|
||||||
|
|
||||||
|
final String? data;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(final BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: SelectableText(
|
||||||
|
data ?? 'null',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
|
@ -80,6 +80,11 @@ class _ServerDetailsScreenState extends State<ServerDetailsScreen>
|
||||||
leading: const Icon(BrandIcons.settings),
|
leading: const Icon(BrandIcons.settings),
|
||||||
onTap: () => context.pushRoute(const ServerSettingsRoute()),
|
onTap: () => context.pushRoute(const ServerSettingsRoute()),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('server.logs'.tr()),
|
||||||
|
leading: const Icon(Icons.manage_search_outlined),
|
||||||
|
onTap: () => context.pushRoute(const ServerLogsRoute()),
|
||||||
|
),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
Text(
|
Text(
|
||||||
'server.resource_usage'.tr(),
|
'server.resource_usage'.tr(),
|
||||||
|
|
|
@ -16,6 +16,7 @@ import 'package:selfprivacy/ui/pages/onboarding/onboarding.dart';
|
||||||
import 'package:selfprivacy/ui/pages/providers/providers.dart';
|
import 'package:selfprivacy/ui/pages/providers/providers.dart';
|
||||||
import 'package:selfprivacy/ui/pages/recovery_key/recovery_key.dart';
|
import 'package:selfprivacy/ui/pages/recovery_key/recovery_key.dart';
|
||||||
import 'package:selfprivacy/ui/pages/root_route.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/server_details_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_details/server_settings_screen.dart';
|
||||||
import 'package:selfprivacy/ui/pages/server_storage/binds_migration/services_migration.dart';
|
import 'package:selfprivacy/ui/pages/server_storage/binds_migration/services_migration.dart';
|
||||||
|
@ -105,6 +106,7 @@ class RootRouter extends _$RootRouter {
|
||||||
AutoRoute(page: ServerStorageRoute.page),
|
AutoRoute(page: ServerStorageRoute.page),
|
||||||
AutoRoute(page: ExtendingVolumeRoute.page),
|
AutoRoute(page: ExtendingVolumeRoute.page),
|
||||||
AutoRoute(page: ServerSettingsRoute.page),
|
AutoRoute(page: ServerSettingsRoute.page),
|
||||||
|
AutoRoute(page: ServerLogsRoute.page),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AutoRoute(page: ServicesMigrationRoute.page),
|
AutoRoute(page: ServicesMigrationRoute.page),
|
||||||
|
@ -150,6 +152,8 @@ String getRouteTitle(final String routeName) {
|
||||||
return 'server.card_title';
|
return 'server.card_title';
|
||||||
case 'ServerSettingsRoute':
|
case 'ServerSettingsRoute':
|
||||||
return 'server.settings';
|
return 'server.settings';
|
||||||
|
case 'ServerLogsRoute':
|
||||||
|
return 'server.logs';
|
||||||
case 'BackupDetailsRoute':
|
case 'BackupDetailsRoute':
|
||||||
return 'backup.card_title';
|
return 'backup.card_title';
|
||||||
case 'BackupsListRoute':
|
case 'BackupsListRoute':
|
||||||
|
|
|
@ -132,6 +132,12 @@ abstract class _$RootRouter extends RootStackRouter {
|
||||||
child: const ServerDetailsScreen(),
|
child: const ServerDetailsScreen(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
ServerLogsRoute.name: (routeData) {
|
||||||
|
return AutoRoutePage<dynamic>(
|
||||||
|
routeData: routeData,
|
||||||
|
child: const ServerLogsScreen(),
|
||||||
|
);
|
||||||
|
},
|
||||||
ServerSettingsRoute.name: (routeData) {
|
ServerSettingsRoute.name: (routeData) {
|
||||||
return AutoRoutePage<dynamic>(
|
return AutoRoutePage<dynamic>(
|
||||||
routeData: routeData,
|
routeData: routeData,
|
||||||
|
@ -510,6 +516,20 @@ class ServerDetailsRoute extends PageRouteInfo<void> {
|
||||||
static const PageInfo<void> page = PageInfo<void>(name);
|
static const PageInfo<void> page = PageInfo<void>(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [ServerLogsScreen]
|
||||||
|
class ServerLogsRoute extends PageRouteInfo<void> {
|
||||||
|
const ServerLogsRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(
|
||||||
|
ServerLogsRoute.name,
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'ServerLogsRoute';
|
||||||
|
|
||||||
|
static const PageInfo<void> page = PageInfo<void>(name);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [ServerSettingsScreen]
|
/// [ServerSettingsScreen]
|
||||||
class ServerSettingsRoute extends PageRouteInfo<void> {
|
class ServerSettingsRoute extends PageRouteInfo<void> {
|
||||||
|
|
Loading…
Reference in a new issue