feat: some more work on console_page

* console_log's copy data is now a valid json object for all log types
* graphQLResponse now provides raw response object for copy
* console_model now handles pause in itself, so UI pipeline doesn't disturb pause (like when revisiting page / hot reloading)
* some minor console_page UI tweaks
pull/482/head
Aliaksei Tratseuski 2024-05-20 03:09:23 +04:00
parent 0ee46e1c1e
commit 4e0779f5e7
9 changed files with 345 additions and 214 deletions

View File

@ -48,6 +48,7 @@
"title": "Console",
"waiting": "Waiting for initialization…",
"copy": "Copy",
"copy_raw": "Raw response",
"historyEmpty": "No data yet",
"error":"Error",
"log":"Log",
@ -55,14 +56,19 @@
"rest_api_response":"Rest API Response",
"graphql_request":"GraphQL Request",
"graphql_response":"GraphQL Response",
"logged_at": "logged at: ",
"logged_at": "Logged at",
"data": "Data",
"erros":"Errors",
"errors":"Errors",
"error_path": "Path",
"error_locations": "Locations",
"error_extensions": "Extensions",
"request_data": "Request data",
"headers": "Headers",
"response_data": "Response data",
"context": "Context",
"operation": "Operation",
"operation_type": "Operation type",
"operation_name": "Operation name",
"variables": "Variables"
},
"about_application_page": {

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:graphql_flutter/graphql_flutter.dart';
@ -17,9 +18,10 @@ class RequestLoggingLink extends Link {
]) async* {
_addConsoleLog(
GraphQlRequestConsoleLog(
// context: request.context,
operationType: request.type.name,
operation: request.operation,
variables: request.variables,
context: request.context,
),
);
yield* forward!(request);
@ -32,9 +34,10 @@ class ResponseLoggingParser extends ResponseParser {
final response = super.parseResponse(body);
_addConsoleLog(
GraphQlResponseConsoleLog(
// context: response.context,
data: response.data,
errors: response.errors,
context: response.context,
rawResponse: jsonEncode(response.response),
),
);
return response;

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
@ -68,10 +69,10 @@ class ConsoleInterceptor extends InterceptorsWrapper {
) async {
addConsoleLog(
RestApiRequestConsoleLog(
method: options.method,
data: options.data.toString(),
headers: options.headers,
uri: options.uri,
method: options.method,
headers: options.headers,
data: jsonEncode(options.data),
),
);
return super.onRequest(options, handler);
@ -84,10 +85,10 @@ class ConsoleInterceptor extends InterceptorsWrapper {
) async {
addConsoleLog(
RestApiResponseConsoleLog(
uri: response.realUri,
method: response.requestOptions.method,
statusCode: response.statusCode,
data: response.data.toString(),
uri: response.realUri,
data: jsonEncode(response.data),
),
);
return super.onResponse(
@ -103,12 +104,13 @@ class ConsoleInterceptor extends InterceptorsWrapper {
) async {
final Response? response = err.response;
log(err.toString());
addConsoleLog(
ManualConsoleLog.warning(
customTitle: 'RestAPI error',
content: 'response-uri: ${response?.realUri}\n'
'code: ${response?.statusCode}\n'
'data: ${response?.toString()}\n',
content: '"uri": "${response?.realUri}",\n'
'"status_code": ${response?.statusCode},\n'
'"response": ${jsonEncode(response)}',
),
);
return super.onError(err, handler);

View File

@ -530,7 +530,7 @@ class ServerInstallationRepository {
Future<void> deleteDomain() async {
await box.delete(BNames.serverDomain);
getIt<ApiConfigModel>().init();
await getIt<ApiConfigModel>().init();
}
Future<void> saveIsServerStarted(final bool value) async {
@ -604,6 +604,6 @@ class ServerInstallationRepository {
BNames.hasFinalChecked,
BNames.isLoading,
]);
getIt<ApiConfigModel>().init();
await getIt<ApiConfigModel>().init();
}
}

View File

@ -2,15 +2,50 @@ import 'package:flutter/material.dart';
import 'package:selfprivacy/logic/models/console_log.dart';
class ConsoleModel extends ChangeNotifier {
/// limit for history, so logs won't affect memory and overflow
static const logBufferLimit = 500;
/// differs from log buffer limit so as to not rearrange memory each time
/// we add incoming log
static const incomingBufferBreakpoint = 750;
final List<ConsoleLog> _logs = [];
final List<ConsoleLog> _incomingQueue = [];
bool _paused = false;
bool get paused => _paused;
List<ConsoleLog> get logs => _logs;
void log(final ConsoleLog newLog) {
logs.add(newLog);
notifyListeners();
// Make sure we don't have too many
if (logs.length > 500) {
logs.removeAt(0);
if (paused) {
_incomingQueue.add(newLog);
if (_incomingQueue.length > incomingBufferBreakpoint) {
logs.removeRange(0, _incomingQueue.length - logBufferLimit);
}
} else {
logs.add(newLog);
_updateQueue();
}
}
void play() {
_logs.addAll(_incomingQueue);
_paused = false;
_updateQueue();
_incomingQueue.clear();
}
void pause() {
_paused = true;
notifyListeners();
}
/// drop logs over the limit and
void _updateQueue() {
// Make sure we don't have too many
if (logs.length > logBufferLimit) {
logs.removeRange(0, logs.length - logBufferLimit);
}
notifyListeners();
}
}

View File

@ -1,5 +1,7 @@
import 'package:gql/language.dart';
import 'package:graphql/client.dart';
import 'dart:convert';
import 'package:gql/language.dart' as gql;
import 'package:graphql/client.dart' as gql_client;
import 'package:intl/intl.dart';
enum ConsoleLogSeverity {
@ -12,7 +14,6 @@ enum ConsoleLogSeverity {
/// TODO(misterfourtytwo): should we add?
///
/// * equality override
/// * translations of theese strings
sealed class ConsoleLog {
ConsoleLog({
final String? customTitle,
@ -32,13 +33,23 @@ sealed class ConsoleLog {
String get content;
/// data available for copy in dialog
String? get shareableData => '$title\n'
'{\n$content\n}';
String? get shareableData => '{"title":"$title",\n'
'"timestamp": "$fullUTCString",\n'
'"data":{\n$content\n}'
'\n}';
static final DateFormat _formatter = DateFormat('hh:mm:ss');
String get timeString => _formatter.format(time);
String get fullUTCString => time.toUtc().toIso8601String();
}
abstract class LogWithRawResponse {
String get rawResponse;
}
/// entity for manually created logs, as opposed to automated ones coming
/// from requests / responses
class ManualConsoleLog extends ConsoleLog {
ManualConsoleLog({
required this.content,
@ -72,8 +83,10 @@ class RestApiRequestConsoleLog extends ConsoleLog {
@override
String get title => 'Rest API Request';
@override
String get content => 'method: $method\n'
'uri: $uri';
String get content => '"method": "$method",\n'
'"uri": "$uri",\n'
'"headers": ${jsonEncode(headers)},\n'
'"data": $data';
}
class RestApiResponseConsoleLog extends ConsoleLog {
@ -93,49 +106,70 @@ class RestApiResponseConsoleLog extends ConsoleLog {
@override
String get title => 'Rest API Response';
@override
String get content => 'method: $method | status code: $statusCode\n'
'uri: $uri';
String get content => '"method": "$method",\n'
'"status_code": $statusCode,\n'
'"uri": "$uri",\n'
'"data": $data';
}
/// there is no actual getter for context fields outside of its class
/// one can extract unique entries by their type, which implements
/// `ContextEntry` class, I'll leave the code here if in the future
/// some entries will actually be needed.
// extension ContextEncoder on gql_client.Context {
// String get encode {
// return '""';
// }
// }
class GraphQlRequestConsoleLog extends ConsoleLog {
GraphQlRequestConsoleLog({
this.operation,
this.variables,
this.context,
required this.operationType,
required this.operation,
required this.variables,
// this.context,
super.severity,
});
final Context? context;
final Operation? operation;
// final gql_client.Context? context;
final String operationType;
final gql_client.Operation? operation;
String get operationDocument =>
operation != null ? gql.printNode(operation!.document) : 'null';
final Map<String, dynamic>? variables;
@override
String get title => 'GraphQL Request';
@override
String get content => 'name: ${operation?.operationName}\n'
'document: ${operation?.document != null ? printNode(operation!.document) : null}';
String get stringifiedOperation => operation == null
? 'null'
: 'Operation{\n'
'\tname: ${operation?.operationName},\n'
'\tdocument: ${operation?.document != null ? printNode(operation!.document) : null}\n'
'}';
String get content =>
// '"context": ${context?.encode},\n'
'"variables": ${jsonEncode(variables)},\n'
'"type": "$operationType",\n'
'"name": "${operation?.operationName}",\n'
'"document": ${jsonEncode(operationDocument)}';
}
class GraphQlResponseConsoleLog extends ConsoleLog {
class GraphQlResponseConsoleLog extends ConsoleLog
implements LogWithRawResponse {
GraphQlResponseConsoleLog({
required this.rawResponse,
// this.context,
this.data,
this.errors,
this.context,
super.severity,
});
final Context? context;
@override
final String rawResponse;
// final gql_client.Context? context;
final Map<String, dynamic>? data;
final List<GraphQLError>? errors;
final List<gql_client.GraphQLError>? errors;
@override
String get title => 'GraphQL Response';
@override
String get content => 'data: $data';
String get content =>
// '"context": ${context?.encode},\n'
'"data": ${jsonEncode(data)},\n'
'"errors": $errors';
}

View File

@ -7,66 +7,92 @@ extension on ConsoleLog {
List<Widget> unwrapContent(final BuildContext context) => switch (this) {
(final RestApiRequestConsoleLog log) => [
if (log.method != null) _KeyValueRow('method', log.method),
if (log.uri != null) _KeyValueRow('uri', log.uri.toString()),
if (log.uri != null) _KeyValueRow('uri', '${log.uri}'),
// headers bloc
if (log.headers?.isNotEmpty ?? false)
if (log.headers?.isNotEmpty ?? false) ...[
const _SectionRow('console_page.headers'),
...?log.headers?.entries
.map((final entry) => _KeyValueRow(entry.key, entry.value)),
for (final entry in log.headers!.entries)
_KeyValueRow(entry.key, '${entry.value}'),
],
// data bloc
// data
const _SectionRow('console_page.data'),
_DataRow(log.data?.toString()),
_DataRow('${log.data}'),
],
(final RestApiResponseConsoleLog log) => [
if (log.method != null) _KeyValueRow('method', log.method),
if (log.uri != null) _KeyValueRow('uri', log.uri.toString()),
if (log.method != null) _KeyValueRow('method', '${log.method}'),
if (log.uri != null) _KeyValueRow('uri', '${log.uri}'),
if (log.statusCode != null)
_KeyValueRow('statusCode', log.statusCode.toString()),
_KeyValueRow('statusCode', '${log.statusCode}'),
// data bloc
// data
const _SectionRow('console_page.response_data'),
_DataRow(log.data?.toString()),
_DataRow('${log.data}'),
],
(final GraphQlRequestConsoleLog log) => [
// context
const _SectionRow('console_page.context'),
_DataRow(log.context?.toString()),
// data
if (log.operation != null)
const _SectionRow('console_page.operation'),
_DataRow(log.stringifiedOperation), // errors
if (log.variables?.isNotEmpty ?? false)
const _SectionRow('console_page.variables'),
...?log.variables?.entries.map(
(final entry) => _KeyValueRow(
entry.key,
'${entry.value}',
// // context
// if (log.context != null) ...[
// const _SectionRow('console_page.context'),
// _DataRow('${log.context}'),
// ],
const _SectionRow('console_page.operation'),
if (log.operation != null) ...[
_KeyValueRow(
'console_page.operation_type'.tr(),
log.operationType,
),
),
_KeyValueRow(
'console_page.operation_name'.tr(),
log.operation?.operationName,
),
const Divider(),
// data
_DataRow(log.operationDocument),
],
// preset variables
if (log.variables?.isNotEmpty ?? false) ...[
const _SectionRow('console_page.variables'),
for (final entry in log.variables!.entries)
_KeyValueRow(entry.key, '${entry.value}'),
],
],
(final GraphQlResponseConsoleLog log) => [
// context
const _SectionRow('console_page.context'),
_DataRow(log.context?.toString()),
// // context
// const _SectionRow('console_page.context'),
// _DataRow('${log.context}'),
// data
if (log.data != null) const _SectionRow('console_page.data'),
...?log.data?.entries.map(
(final entry) => _KeyValueRow(
entry.key,
'${entry.value}',
),
),
if (log.data != null) ...[
const _SectionRow('console_page.data'),
for (final entry in log.data!.entries)
_KeyValueRow(entry.key, '${entry.value}'),
],
// errors
if (log.errors?.isNotEmpty ?? false)
if (log.errors?.isNotEmpty ?? false) ...[
const _SectionRow('console_page.errors'),
...?log.errors?.map(
(final entry) => _KeyValueRow(
entry.message,
'${entry.locations}',
),
),
for (final entry in log.errors!) ...[
_KeyValueRow(
'${'console_page.error_message'.tr()}: ',
entry.message,
),
_KeyValueRow(
'${'console_page.error_path'.tr()}: ',
'${entry.path}',
),
if (entry.locations?.isNotEmpty ?? false)
_KeyValueRow(
'${'console_page.error_locations'.tr()}: ',
'${entry.locations}',
),
if (entry.extensions?.isNotEmpty ?? false)
_KeyValueRow(
'${'console_page.error_extensions'.tr()}: ',
'${entry.extensions}',
),
const Divider(),
],
],
],
(final ManualConsoleLog log) => [
_DataRow(log.content),
@ -74,6 +100,7 @@ extension on ConsoleLog {
};
}
/// dialog with detailed log content
class ConsoleItemDialog extends StatelessWidget {
const ConsoleItemDialog({
required this.log,
@ -83,80 +110,66 @@ class ConsoleItemDialog extends StatelessWidget {
final ConsoleLog log;
@override
Widget build(final BuildContext context) {
final content = log.unwrapContent(context);
return AlertDialog(
scrollable: true,
title: Text(log.title),
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('logged_at'.tr()),
SelectableText(
log.timeString,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontFeatures: [FontFeature.tabularFigures()],
Widget build(final BuildContext context) => AlertDialog(
scrollable: true,
title: Text(log.title),
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(),
...content,
],
),
actions: [
// A button to copy the request to the clipboard
if (log.shareableData?.isNotEmpty ?? false)
TextButton(
onPressed: () => PlatformAdapter.setClipboard(log.shareableData!),
child: Text('console_page.copy'.tr()),
),
// close dialog
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('basis.close'.tr()),
),
],
);
}
}
class _KeyValueRow extends StatelessWidget {
const _KeyValueRow(this.title, this.value);
final String title;
final String? value;
@override
Widget build(final BuildContext context) => SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: '$title: ',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: value ?? ''),
const Divider(),
...log.unwrapContent(context),
],
),
actions: [
if (log is LogWithRawResponse)
TextButton(
onPressed: () => PlatformAdapter.setClipboard(
(log as LogWithRawResponse).rawResponse,
),
child: Text('console_page.copy_raw'.tr()),
),
// A button to copy the request to the clipboard
if (log.shareableData?.isNotEmpty ?? false)
TextButton(
onPressed: () => PlatformAdapter.setClipboard(log.shareableData!),
child: Text('console_page.copy'.tr()),
),
// close dialog
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('basis.close'.tr()),
),
],
);
}
class _DataRow extends StatelessWidget {
const _DataRow(this.data);
final String? data;
@override
Widget build(final BuildContext context) => SelectableText(
data ?? 'null',
style: const TextStyle(fontWeight: FontWeight.w400),
);
}
/// different sections delimiter with `title`
class _SectionRow extends StatelessWidget {
const _SectionRow(this.title);
@ -184,3 +197,44 @@ class _SectionRow extends StatelessWidget {
),
);
}
/// data row with a {key: value} pair
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 ?? ''),
],
),
),
);
}
/// data row with only text
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

@ -35,37 +35,41 @@ class ConsoleLogItemWidget extends StatelessWidget {
final ConsoleLog log;
@override
Widget build(final BuildContext context) => ListTile(
dense: true,
title: SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: '${log.timeString}: ',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
Widget build(final BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ListTile(
dense: true,
title: Text.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: '${log.timeString}: ',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
TextSpan(
text: log.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
TextSpan(
text: log.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
subtitle: Text(
log.content,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
leading: Icon(log.resolveIcon()),
iconColor: log.resolveColor(context),
onTap: () => showDialog(
context: context,
builder: (final BuildContext context) =>
ConsoleItemDialog(log: log),
),
),
subtitle: Text(
log.content,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
leading: Icon(log.resolveIcon()),
iconColor: log.resolveColor(context),
onTap: () => showDialog(
context: context,
builder: (final BuildContext context) => ConsoleItemDialog(log: log),
),
);
}

View File

@ -16,8 +16,9 @@ class ConsolePage extends StatefulWidget {
}
class _ConsolePageState extends State<ConsolePage> {
ConsoleModel get console => getIt<ConsoleModel>();
/// should freeze logs state to properly read logs
bool paused = false;
late final Future<void> future;
@override
@ -25,12 +26,12 @@ class _ConsolePageState extends State<ConsolePage> {
super.initState();
future = getIt.allReady();
getIt<ConsoleModel>().addListener(update);
console.addListener(update);
}
@override
void dispose() {
getIt<ConsoleModel>().removeListener(update);
console.removeListener(update);
super.dispose();
}
@ -40,17 +41,12 @@ class _ConsolePageState extends State<ConsolePage> {
/// unmounted or during frame build, adding as postframe callback ensures
/// that element is marked for rebuild
WidgetsBinding.instance.addPostFrameCallback((final _) {
if (!paused && mounted) {
if (mounted) {
setState(() => {});
}
});
}
void togglePause() {
paused ^= true;
setState(() {});
}
@override
Widget build(final BuildContext context) => SafeArea(
child: Scaffold(
@ -63,34 +59,31 @@ class _ConsolePageState extends State<ConsolePage> {
actions: [
IconButton(
icon: Icon(
paused ? Icons.play_arrow_outlined : Icons.pause_outlined,
console.paused
? Icons.play_arrow_outlined
: Icons.pause_outlined,
),
onPressed: togglePause,
onPressed: console.paused ? console.play : console.pause,
),
],
),
body: SelectionArea(
child: Scrollbar(
child: FutureBuilder(
future: future,
builder: (
final BuildContext context,
final AsyncSnapshot<void> snapshot,
) {
if (snapshot.hasData) {
final List<ConsoleLog> logs =
getIt.get<ConsoleModel>().logs;
body: Scrollbar(
child: FutureBuilder(
future: future,
builder: (
final BuildContext context,
final AsyncSnapshot<void> snapshot,
) {
if (snapshot.hasData) {
final List<ConsoleLog> logs = console.logs;
return logs.isEmpty
? const _ConsoleViewEmpty()
: _ConsoleViewLoaded(
logs: logs,
);
}
return logs.isEmpty
? const _ConsoleViewEmpty()
: _ConsoleViewLoaded(logs: logs);
}
return const _ConsoleViewLoading();
},
),
return const _ConsoleViewLoading();
},
),
),
),
@ -135,7 +128,7 @@ class _ConsoleViewLoaded extends StatelessWidget {
@override
Widget build(final BuildContext context) => ListView.separated(
primary: true,
padding: const EdgeInsets.symmetric(vertical: 10),
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: logs.length,
itemBuilder: (final BuildContext context, final int index) {
final log = logs[logs.length - 1 - index];