feat: Server logs screen

This commit is contained in:
Inex Code 2024-07-24 17:22:12 +03:00
parent 39babdeeaa
commit 515b0e2c67
15 changed files with 2309 additions and 4 deletions

View file

@ -170,6 +170,18 @@
"few": "{} cores",
"many": "{} 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": {

View file

@ -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/recovery_key/recovery_key_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/users/users_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 ServerDetailsCubit serverDetailsCubit;
late final VolumesBloc volumesBloc;
late final ServerLogsBloc serverLogsBloc;
@override
void initState() {
@ -52,6 +54,7 @@ class BlocAndProviderConfigState extends State<BlocAndProviderConfig> {
connectionStatusBloc = ConnectionStatusBloc();
serverDetailsCubit = ServerDetailsCubit();
volumesBloc = VolumesBloc();
serverLogsBloc = ServerLogsBloc();
}
@override
@ -94,6 +97,9 @@ class BlocAndProviderConfigState extends State<BlocAndProviderConfig> {
BlocProvider(
create: (final _) => JobsCubit(),
),
BlocProvider(
create: (final _) => serverLogsBloc,
),
],
child: widget.child,
);

View 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
}
}

File diff suppressed because it is too large Load diff

View 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;
}
}
}

View file

@ -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/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/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';
@ -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/server_disk_volume.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/ssh_settings.dart';
import 'package:selfprivacy/logic/models/system_settings.dart';
@ -34,6 +36,7 @@ part 'server_actions_api.dart';
part 'services_api.dart';
part 'users_api.dart';
part 'volume_api.dart';
part 'logs_api.dart';
class ServerApi extends GraphQLApiMap
with
@ -42,7 +45,8 @@ class ServerApi extends GraphQLApiMap
ServerActionsApi,
ServicesApi,
UsersApi,
BackupsApi {
BackupsApi,
LogsApi {
ServerApi({
this.hasLogger = false,
this.isWithToken = true,

View 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;
}

View 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 => [];
}

View 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];
}

View 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,
];
}

View file

@ -54,9 +54,7 @@ class _ConsolePageState extends State<ConsolePage> {
actions: [
IconButton(
icon: Icon(
console.paused
? Icons.play_arrow_outlined
: Icons.pause_outlined,
console.paused ? Icons.play_arrow_outlined : Icons.pause_outlined,
),
onPressed: console.paused ? console.play : console.pause,
),

View 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),
),
);
}

View file

@ -80,6 +80,11 @@ class _ServerDetailsScreenState extends State<ServerDetailsScreen>
leading: const Icon(BrandIcons.settings),
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),
Text(
'server.resource_usage'.tr(),

View file

@ -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/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/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';
@ -105,6 +106,7 @@ class RootRouter extends _$RootRouter {
AutoRoute(page: ServerStorageRoute.page),
AutoRoute(page: ExtendingVolumeRoute.page),
AutoRoute(page: ServerSettingsRoute.page),
AutoRoute(page: ServerLogsRoute.page),
],
),
AutoRoute(page: ServicesMigrationRoute.page),
@ -150,6 +152,8 @@ String getRouteTitle(final String routeName) {
return 'server.card_title';
case 'ServerSettingsRoute':
return 'server.settings';
case 'ServerLogsRoute':
return 'server.logs';
case 'BackupDetailsRoute':
return 'backup.card_title';
case 'BackupsListRoute':

View file

@ -132,6 +132,12 @@ abstract class _$RootRouter extends RootStackRouter {
child: const ServerDetailsScreen(),
);
},
ServerLogsRoute.name: (routeData) {
return AutoRoutePage<dynamic>(
routeData: routeData,
child: const ServerLogsScreen(),
);
},
ServerSettingsRoute.name: (routeData) {
return AutoRoutePage<dynamic>(
routeData: routeData,
@ -510,6 +516,20 @@ class ServerDetailsRoute extends PageRouteInfo<void> {
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
/// [ServerSettingsScreen]
class ServerSettingsRoute extends PageRouteInfo<void> {