diff --git a/lib/api/log.dart b/lib/api/log.dart index ca0d571..69c8eab 100644 --- a/lib/api/log.dart +++ b/lib/api/log.dart @@ -15,6 +15,33 @@ enum LogLevel { warn, @JsonValue(6) error; + + static LogLevel? tryParse(String level) { + int? levnum = int.tryParse(level); + if (levnum != null && levnum <= 6 && levnum >= 1) { + return LogLevel.values[levnum - 1]; + } + switch (level) { + case 'trace': + return LogLevel.trace; + case 'debug': + return LogLevel.debug; + case 'log': + return LogLevel.log; + case 'info': + return LogLevel.info; + case 'warn': + return LogLevel.warn; + case 'error': + return LogLevel.error; + default: + return null; + } + } + + int toInt() { + return index + 1; + } } @JsonSerializable() diff --git a/lib/auth.dart b/lib/auth.dart index f444576..1402458 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -41,7 +41,8 @@ class AuthInfo { : _user?.permissions.has(UserPermission.manageTasks); bool? get canShareGallery => _user?.permissions.has(UserPermission.shareGallery); - bool? get canQueryLog => _user?.permissions.has(UserPermission.queryLog); + bool? get canQueryLog => + noUser == true ? true : _user?.permissions.has(UserPermission.queryLog); MeilisearchInfo? get meilisearch => _status?.meilisearch; MeiliSearchClient? _meiliSearchClient; MeiliSearchClient? get meiliSearchClient => _meiliSearchClient; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ea3e0ce..5b89e97 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -359,5 +359,10 @@ "deleteShareConfirm": "Do you want to delete shared token?", "failedDeleteShare": "Failed to delete shared token: ", "queryLog": "Query log", - "loggingStack": "Enable logging stack for all log levels" + "loggingStack": "Enable logging stack for all log levels", + "serverLogs": "Server logs", + "time": "Time", + "message": "Message", + "type": "Type", + "level": "Level" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4245cf2..066e56c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -359,5 +359,10 @@ "deleteShareConfirm": "是否删除分享令牌?", "failedDeleteShare": "删除分享令牌失败:", "queryLog": "查询日志", - "loggingStack": "为所有日志级别启用堆栈记录" + "loggingStack": "为所有日志级别启用堆栈记录", + "serverLogs": "服务器日志", + "time": "时间", + "message": "消息", + "type": "类型", + "level": "级别" } diff --git a/lib/main.dart b/lib/main.dart index 2f6a782..a51a120 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:window_manager/window_manager.dart'; import 'api/client.dart'; +import 'api/log.dart'; import 'dialog/dialog_page.dart'; import 'dialog/download_zip_page.dart'; import 'dialog/edit_user_page.dart'; @@ -25,6 +26,7 @@ import 'pages/galleries.dart'; import 'pages/gallery.dart'; import 'pages/home.dart'; import 'pages/login.dart'; +import 'pages/logs.dart'; import 'pages/sessions.dart'; import 'pages/settings.dart'; import 'pages/settings/cache.dart'; @@ -361,6 +363,43 @@ final _router = GoRouter( path: SessionsPage.routeName, builder: (context, state) => SessionsPage(key: state.pageKey), ), + GoRoute( + name: LogsPage.routeName, + path: LogsPage.routeName, + builder: (context, state) { + int? page; + String? type; + LogLevel? minLevel; + List? allowedLevel; + int? size; + if (state.uri.queryParameters.containsKey("page")) { + page = int.tryParse(state.uri.queryParameters["page"]!); + } + if (state.uri.queryParameters.containsKey("type")) { + type = state.uri.queryParameters["type"]!; + } + if (state.uri.queryParameters.containsKey("min_level")) { + minLevel = + LogLevel.tryParse(state.uri.queryParameters["min_level"]!); + } + if (state.uri.queryParameters.containsKey("allowed_level")) { + allowedLevel = state.uri.queryParameters["allowed_level"]! + .split(",") + .map((e) => LogLevel.tryParse(e)) + .nonNulls + .toList(); + } + if (state.uri.queryParameters.containsKey("size")) { + size = int.tryParse(state.uri.queryParameters["size"]!); + } + return LogsPage( + key: state.pageKey, + page: page, + type: type, + minLevel: minLevel, + allowedLevel: allowedLevel, + size: size); + }), ], observers: [ _NavigatorObserver(), diff --git a/lib/pages/home.dart b/lib/pages/home.dart index b02a054..03c0b75 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -51,6 +51,15 @@ class HomeDrawer extends StatelessWidget { context.push("/users"); }) : Container(), + auth.canQueryLog == true + ? ListTile( + leading: const Icon(Icons.list), + title: Text(i18n.serverLogs), + onTap: () { + Scaffold.of(context).closeDrawer(); + context.push("/logs"); + }) + : Container(), ListTile( leading: const Icon(Icons.settings), title: Text(AppLocalizations.of(context)!.settings), diff --git a/lib/pages/logs.dart b/lib/pages/logs.dart new file mode 100644 index 0000000..50bf3a9 --- /dev/null +++ b/lib/pages/logs.dart @@ -0,0 +1,227 @@ +import 'package:advanced_datatable/advanced_datatable_source.dart'; +import 'package:advanced_datatable/datatable.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:intl/intl.dart'; +import '../api/log.dart'; +import '../globals.dart'; +import '../main.dart'; + +final _log = Logger('LogsPage'); + +class LogsPage extends StatefulWidget { + const LogsPage( + {super.key, + this.page, + this.type, + this.minLevel, + this.allowedLevel, + this.size}); + + static const String routeName = '/logs'; + + final int? page; + final String? type; + final LogLevel? minLevel; + final List? allowedLevel; + final int? size; + + @override + State createState() => _LogsPage(); +} + +class _LogDataSource extends AdvancedDataTableSource { + _LogDataSource(this.logs, + {this.type, + this.minLevel, + this.allowedLevel, + this.size = 10, + this.count, + this.page = 1, + this.locale, + this.start = 0}); + final String? type; + final LogLevel? minLevel; + final List? allowedLevel; + final int size; + final String? locale; + int? count; + int start; + int page; + List logs; + @override + bool get isRowCountApproximate => count == null; + @override + int get rowCount => count ?? 10; + @override + int get selectedRowCount => 0; + @override + Future> getNextPage( + NextPageRequest pageRequest) async { + int npage = pageRequest.offset ~/ pageRequest.pageSize + 1; + page = npage; + if ((logs.length >= pageRequest.offset + pageRequest.pageSize - start && + pageRequest.offset >= start) || + logs.length == count) { + return RemoteDataSourceDetails(count ?? logs.length, logs); + } + var data = (await api.queryLog( + page: page, + type: type, + minLevel: minLevel?.toInt(), + allowedLevel: allowedLevel?.map((e) => e.toInt()).join(","), + limit: size)) + .unwrap(); + count = data.count; + if (pageRequest.offset < start) { + logs.insertAll(0, data.datas); + start -= data.datas.length; + } else { + logs.addAll(data.datas); + } + return RemoteDataSourceDetails(count ?? logs.length, logs); + } + + @override + DataRow? getRow(int index) { + index += (page - 1) * size; + index -= start; + var log = logs.elementAtOrNull(index); + if (log == null) return null; + return DataRow(cells: [ + DataCell( + Text(DateFormat.yMd(locale).add_jms().format(log.time.toLocal()))), + DataCell(Text(log.message)), + DataCell(Text(log.level.name)), + DataCell(Text(log.type)), + ]); + } +} + +class _LogsPage extends State with ThemeModeWidget, IsTopWidget2 { + int? _page; + String? _type; + LogLevel? _minLevel; + List? _allowedLevel; + int _size = 50; + bool _pageMode = false; + _LogDataSource? _dataSource; + CancelToken? _cancel; + bool _isLoading = false; + LogEntries? _firstPage; + + Future _fetchFirstPage() async { + try { + _cancel = CancelToken(); + _isLoading = true; + _firstPage = (await api.queryLog( + page: _page ?? 1, + type: _type, + minLevel: _minLevel?.toInt(), + allowedLevel: _allowedLevel?.map((e) => e.toInt()).join(","), + limit: _size, + cancel: _cancel)) + .unwrap(); + if (!_cancel!.isCancelled) { + setState(() { + _isLoading = false; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.severe("Failed to load first page:", e); + setState(() { + _isLoading = false; + }); + } + } + } + + @override + void initState() { + _page = widget.page; + _type = widget.type; + _minLevel = widget.minLevel ?? LogLevel.log; + _allowedLevel = widget.allowedLevel; + _size = widget.size ?? 10; + _pageMode = + _page != null ? true : (prefs.getBool("serverLogsPageMode") ?? true); + super.initState(); + } + + @override + void dispose() { + _cancel?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + tryInitApi(context); + final i18n = AppLocalizations.of(context)!; + if (isTop(context)) { + setCurrentTitle(i18n.serverLogs); + } + final locale = MainApp.of(context).lang.toLocale().toString(); + bool isLoading = false; + if (_pageMode) { + isLoading = _firstPage == null; + if (isLoading && !_isLoading) _fetchFirstPage(); + if (_dataSource == null && _firstPage != null) { + _dataSource = _LogDataSource(_firstPage!.datas, + page: _page ?? 1, + type: _type, + minLevel: _minLevel, + allowedLevel: _allowedLevel, + size: _size, + count: _firstPage!.count, + locale: locale, + start: (_page ?? 1 - 1) * _size); + } + } + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.serverLogs), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ], + ), + body: _pageMode + ? _dataSource != null + ? AdvancedPaginatedDataTable( + columns: [ + DataColumn(label: Text(i18n.time)), + DataColumn(label: Text(i18n.message)), + DataColumn(label: Text(i18n.level)), + DataColumn(label: Text(i18n.type)), + ], + source: _dataSource!, + rowsPerPage: _size, + onPageChanged: (page) { + _page = page ~/ _size + 1; + var params = { + "page": _page?.toString(), + "type": _type, + "min_level": _minLevel?.name, + "allowed_level": + _allowedLevel?.map((e) => e.name).join(","), + "size": _size.toString(), + }; + params.removeWhere( + (key, value) => value == null || value!.isEmpty); + context.replaceNamed("/logs", queryParameters: params); + }) + : const Center(child: CircularProgressIndicator()) + : Container()); + } +} diff --git a/pubspec.lock b/pubspec.lock index 342aff7..e1c9643 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -14,6 +14,14 @@ packages: description: dart source: sdk version: "0.3.3" + advanced_datatable: + dependency: "direct main" + description: + name: advanced_datatable + sha256: "3537a9a811769c121e9c9d49006357d9e99af4bac030ce059825f5c904a0fb47" + url: "https://pub.dev" + source: hosted + version: "0.0.9" analyzer: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6b02443..b426074 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: '>=3.0.5 <4.0.0' dependencies: + advanced_datatable: ^0.0.9 cryptography: ^2.5.0 cryptography_flutter: ^2.3.0 dio: ^5.3.2