Refactor logs
This commit is contained in:
23
lib/features/log/data/log_data_providers.dart
Normal file
23
lib/features/log/data/log_data_providers.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:hiddify/features/log/data/log_path_resolver.dart';
|
||||
import 'package:hiddify/features/log/data/log_repository.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'log_data_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<LogRepository> logRepository(LogRepositoryRef ref) async {
|
||||
final repo = LogRepositoryImpl(
|
||||
singbox: ref.watch(singboxServiceProvider),
|
||||
logPathResolver: ref.watch(logPathResolverProvider),
|
||||
);
|
||||
await repo.init().getOrElse((l) => throw l).run();
|
||||
return repo;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
LogPathResolver logPathResolver(LogPathResolverRef ref) {
|
||||
return LogPathResolver(
|
||||
ref.watch(filesEditorServiceProvider).dirs.workingDir,
|
||||
);
|
||||
}
|
||||
33
lib/features/log/data/log_parser.dart
Normal file
33
lib/features/log/data/log_parser.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
// ignore_for_file: parameter_assignments
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/features/log/model/log_entity.dart';
|
||||
import 'package:hiddify/features/log/model/log_level.dart';
|
||||
import 'package:tint/tint.dart';
|
||||
|
||||
abstract class LogParser {
|
||||
static LogEntity parseSingbox(String log) {
|
||||
log = log.strip();
|
||||
DateTime? time;
|
||||
if (log.length > 25) {
|
||||
time = DateTime.tryParse(log.substring(6, 25));
|
||||
}
|
||||
if (time != null) {
|
||||
log = log.substring(26);
|
||||
}
|
||||
final level = LogLevel.values.firstOrNullWhere(
|
||||
(e) {
|
||||
if (log.startsWith(e.name.toUpperCase())) {
|
||||
log = log.removePrefix(e.name.toUpperCase());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
return LogEntity(
|
||||
level: level,
|
||||
time: time,
|
||||
message: log.trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/features/log/data/log_path_resolver.dart
Normal file
19
lib/features/log/data/log_path_resolver.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class LogPathResolver {
|
||||
const LogPathResolver(this._workingDir);
|
||||
|
||||
final Directory _workingDir;
|
||||
|
||||
Directory get directory => _workingDir;
|
||||
|
||||
File coreFile() {
|
||||
return File(p.join(directory.path, "box.log"));
|
||||
}
|
||||
|
||||
File appFile() {
|
||||
return File(p.join(directory.path, "app.log"));
|
||||
}
|
||||
}
|
||||
70
lib/features/log/data/log_repository.dart
Normal file
70
lib/features/log/data/log_repository.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/repository/exception_handlers.dart';
|
||||
import 'package:hiddify/features/log/data/log_parser.dart';
|
||||
import 'package:hiddify/features/log/data/log_path_resolver.dart';
|
||||
import 'package:hiddify/features/log/model/log_entity.dart';
|
||||
import 'package:hiddify/features/log/model/log_failure.dart';
|
||||
import 'package:hiddify/services/singbox/singbox_service.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
|
||||
abstract interface class LogRepository {
|
||||
TaskEither<LogFailure, Unit> init();
|
||||
Stream<Either<LogFailure, List<LogEntity>>> watchLogs();
|
||||
TaskEither<LogFailure, Unit> clearLogs();
|
||||
}
|
||||
|
||||
class LogRepositoryImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements LogRepository {
|
||||
LogRepositoryImpl({
|
||||
required this.singbox,
|
||||
required this.logPathResolver,
|
||||
});
|
||||
|
||||
final SingboxService singbox;
|
||||
final LogPathResolver logPathResolver;
|
||||
|
||||
@override
|
||||
TaskEither<LogFailure, Unit> init() {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
if (!await logPathResolver.directory.exists()) {
|
||||
await logPathResolver.directory.create(recursive: true);
|
||||
}
|
||||
if (await logPathResolver.coreFile().exists()) {
|
||||
await logPathResolver.coreFile().writeAsString("");
|
||||
} else {
|
||||
await logPathResolver.coreFile().create(recursive: true);
|
||||
}
|
||||
if (await logPathResolver.appFile().exists()) {
|
||||
await logPathResolver.appFile().writeAsString("");
|
||||
} else {
|
||||
await logPathResolver.appFile().create(recursive: true);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
LogUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<LogFailure, List<LogEntity>>> watchLogs() {
|
||||
return singbox
|
||||
.watchLogs(logPathResolver.coreFile().path)
|
||||
.map((event) => event.map(LogParser.parseSingbox).toList())
|
||||
.handleExceptions(
|
||||
(error, stackTrace) {
|
||||
loggy.warning("error watching logs", error, stackTrace);
|
||||
return LogFailure.unexpected(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<LogFailure, Unit> clearLogs() {
|
||||
return exceptionHandler(
|
||||
() => singbox.clearLogs().mapLeft(LogFailure.unexpected).run(),
|
||||
LogFailure.unexpected,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
lib/features/log/model/log_entity.dart
Normal file
13
lib/features/log/model/log_entity.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/features/log/model/log_level.dart';
|
||||
|
||||
part 'log_entity.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class LogEntity with _$LogEntity {
|
||||
const factory LogEntity({
|
||||
LogLevel? level,
|
||||
DateTime? time,
|
||||
required String message,
|
||||
}) = _LogEntity;
|
||||
}
|
||||
25
lib/features/log/model/log_failure.dart
Normal file
25
lib/features/log/model/log_failure.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/prefs/locale_prefs.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
|
||||
part 'log_failure.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class LogFailure with _$LogFailure, Failure {
|
||||
const LogFailure._();
|
||||
|
||||
const factory LogFailure.unexpected([
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
]) = LogUnexpectedFailure;
|
||||
|
||||
@override
|
||||
({String type, String? message}) present(TranslationsEn t) {
|
||||
return switch (this) {
|
||||
LogUnexpectedFailure() => (
|
||||
type: "unexpected",
|
||||
message: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
lib/features/log/model/log_level.dart
Normal file
25
lib/features/log/model/log_level.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum LogLevel {
|
||||
trace,
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
fatal,
|
||||
panic;
|
||||
|
||||
/// [LogLevel] selectable by user as preference
|
||||
static List<LogLevel> get choices => values.takeFirst(4);
|
||||
|
||||
Color? get color => switch (this) {
|
||||
trace => Colors.lightBlueAccent,
|
||||
debug => Colors.grey,
|
||||
info => Colors.lightGreen,
|
||||
warn => Colors.orange,
|
||||
error => Colors.redAccent,
|
||||
fatal => Colors.red,
|
||||
panic => Colors.red,
|
||||
};
|
||||
}
|
||||
146
lib/features/log/overview/logs_overview_notifier.dart
Normal file
146
lib/features/log/overview/logs_overview_notifier.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hiddify/features/log/data/log_data_providers.dart';
|
||||
import 'package:hiddify/features/log/model/log_entity.dart';
|
||||
import 'package:hiddify/features/log/model/log_level.dart';
|
||||
import 'package:hiddify/features/log/overview/logs_overview_state.dart';
|
||||
import 'package:hiddify/utils/riverpod_utils.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'logs_overview_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class LogsOverviewNotifier extends _$LogsOverviewNotifier with AppLogger {
|
||||
@override
|
||||
LogsOverviewState build() {
|
||||
ref.disposeDelay(const Duration(seconds: 20));
|
||||
state = const LogsOverviewState();
|
||||
ref.onDispose(
|
||||
() {
|
||||
loggy.debug("disposing");
|
||||
_listener?.cancel();
|
||||
_listener = null;
|
||||
},
|
||||
);
|
||||
ref.onCancel(
|
||||
() {
|
||||
if (_listener?.isPaused != true) {
|
||||
loggy.debug("pausing");
|
||||
_listener?.pause();
|
||||
}
|
||||
},
|
||||
);
|
||||
ref.onResume(
|
||||
() {
|
||||
if (!state.paused && (_listener?.isPaused ?? false)) {
|
||||
loggy.debug("resuming");
|
||||
_listener?.resume();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_addListeners();
|
||||
return const LogsOverviewState();
|
||||
}
|
||||
|
||||
StreamSubscription? _listener;
|
||||
|
||||
Future<void> _addListeners() async {
|
||||
loggy.debug("adding listeners");
|
||||
await _listener?.cancel();
|
||||
_listener = ref
|
||||
.read(logRepositoryProvider)
|
||||
.requireValue
|
||||
.watchLogs()
|
||||
.throttle(
|
||||
(_) => Stream.value(_listener?.isPaused ?? false),
|
||||
leading: false,
|
||||
trailing: true,
|
||||
)
|
||||
.throttleTime(
|
||||
const Duration(milliseconds: 250),
|
||||
leading: false,
|
||||
trailing: true,
|
||||
)
|
||||
.asyncMap(
|
||||
(event) async {
|
||||
await event.fold(
|
||||
(f) {
|
||||
_logs = [];
|
||||
state = state.copyWith(logs: AsyncError(f, StackTrace.current));
|
||||
},
|
||||
(a) async {
|
||||
_logs = a.reversed;
|
||||
state = state.copyWith(logs: AsyncData(await _computeLogs()));
|
||||
},
|
||||
);
|
||||
},
|
||||
).listen((event) {});
|
||||
}
|
||||
|
||||
Iterable<LogEntity> _logs = [];
|
||||
final _debouncer = CallbackDebouncer(const Duration(milliseconds: 200));
|
||||
LogLevel? _levelFilter;
|
||||
String _filter = "";
|
||||
|
||||
Future<List<LogEntity>> _computeLogs() async {
|
||||
if (_levelFilter == null && _filter.isEmpty) return _logs.toList();
|
||||
return _logs.where((e) {
|
||||
return (_filter.isEmpty || e.message.contains(_filter)) &&
|
||||
(_levelFilter == null ||
|
||||
e.level == null ||
|
||||
e.level!.index >= _levelFilter!.index);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
loggy.debug("pausing");
|
||||
_listener?.pause();
|
||||
state = state.copyWith(paused: true);
|
||||
}
|
||||
|
||||
void resume() {
|
||||
loggy.debug("resuming");
|
||||
_listener?.resume();
|
||||
state = state.copyWith(paused: false);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
loggy.debug("clearing");
|
||||
await ref.read(logRepositoryProvider).requireValue.clearLogs().match(
|
||||
(l) {
|
||||
loggy.warning("error clearing logs", l);
|
||||
},
|
||||
(_) {
|
||||
_logs = [];
|
||||
state = state.copyWith(logs: const AsyncData([]));
|
||||
},
|
||||
).run();
|
||||
}
|
||||
|
||||
void filterMessage(String? filter) {
|
||||
_filter = filter ?? '';
|
||||
_debouncer(
|
||||
() async {
|
||||
if (state.logs case AsyncData()) {
|
||||
state = state.copyWith(
|
||||
filter: _filter,
|
||||
logs: AsyncData(await _computeLogs()),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> filterLevel(LogLevel? level) async {
|
||||
_levelFilter = level;
|
||||
if (state.logs case AsyncData()) {
|
||||
state = state.copyWith(
|
||||
levelFilter: _levelFilter,
|
||||
logs: AsyncData(await _computeLogs()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
226
lib/features/log/overview/logs_overview_page.dart
Normal file
226
lib/features/log/overview/logs_overview_page.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hiddify/core/core_providers.dart';
|
||||
import 'package:hiddify/core/prefs/prefs.dart';
|
||||
import 'package:hiddify/domain/failures.dart';
|
||||
import 'package:hiddify/features/common/nested_app_bar.dart';
|
||||
import 'package:hiddify/features/log/data/log_data_providers.dart';
|
||||
import 'package:hiddify/features/log/model/log_level.dart';
|
||||
import 'package:hiddify/features/log/overview/logs_overview_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class LogsOverviewPage extends HookConsumerWidget with PresLogger {
|
||||
const LogsOverviewPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final t = ref.watch(translationsProvider);
|
||||
final state = ref.watch(logsOverviewNotifierProvider);
|
||||
final notifier = ref.watch(logsOverviewNotifierProvider.notifier);
|
||||
|
||||
final debug = ref.watch(debugModeNotifierProvider);
|
||||
final pathResolver = ref.watch(logPathResolverProvider);
|
||||
|
||||
final filterController = useTextEditingController(text: state.filter);
|
||||
|
||||
final List<PopupMenuEntry> popupButtons = debug || PlatformUtils.isDesktop
|
||||
? [
|
||||
PopupMenuItem(
|
||||
child: Text(t.logs.shareCoreLogs),
|
||||
onTap: () async {
|
||||
await UriUtils.tryShareOrLaunchFile(
|
||||
Uri.parse(pathResolver.coreFile().path),
|
||||
fileOrDir: pathResolver.directory.uri,
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text(t.logs.shareAppLogs),
|
||||
onTap: () async {
|
||||
await UriUtils.tryShareOrLaunchFile(
|
||||
Uri.parse(pathResolver.appFile().path),
|
||||
fileOrDir: pathResolver.directory.uri,
|
||||
);
|
||||
},
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
return Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return <Widget>[
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: MultiSliver(
|
||||
children: [
|
||||
NestedAppBar(
|
||||
forceElevated: innerBoxIsScrolled,
|
||||
title: Text(t.logs.pageTitle),
|
||||
actions: [
|
||||
if (state.paused)
|
||||
IconButton(
|
||||
onPressed: notifier.resume,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
tooltip: t.logs.resumeTooltip,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: notifier.pause,
|
||||
icon: const Icon(Icons.pause),
|
||||
tooltip: t.logs.pauseTooltip,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: notifier.clear,
|
||||
icon: const Icon(Icons.clear_all),
|
||||
tooltip: t.logs.clearTooltip,
|
||||
),
|
||||
if (popupButtons.isNotEmpty)
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) {
|
||||
return popupButtons;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverPinnedHeader(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
controller: filterController,
|
||||
onChanged: notifier.filterMessage,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: t.logs.filterHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
DropdownButton<Option<LogLevel>>(
|
||||
value: optionOf(state.levelFilter),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
notifier.filterLevel(v.toNullable());
|
||||
},
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: none(),
|
||||
child: Text(t.logs.allLevelsFilter),
|
||||
),
|
||||
...LogLevel.choices.map(
|
||||
(e) => DropdownMenuItem(
|
||||
value: some(e),
|
||||
child: Text(e.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
return CustomScrollView(
|
||||
primary: false,
|
||||
reverse: true,
|
||||
slivers: <Widget>[
|
||||
switch (state.logs) {
|
||||
AsyncData(value: final logs) => SliverList.builder(
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = logs[index];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (log.level != null)
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
log.level!.name.toUpperCase(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(
|
||||
color: log.level!.color,
|
||||
),
|
||||
),
|
||||
if (log.time != null)
|
||||
Text(
|
||||
log.time!.toString(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
log.message,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (index != 0)
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
height: 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
AsyncError(:final error) => SliverErrorBodyPlaceholder(
|
||||
t.presentShortError(error),
|
||||
),
|
||||
_ => const SliverLoadingBodyPlaceholder(),
|
||||
},
|
||||
SliverOverlapInjector(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/features/log/overview/logs_overview_state.dart
Normal file
18
lib/features/log/overview/logs_overview_state.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/features/log/model/log_entity.dart';
|
||||
import 'package:hiddify/features/log/model/log_level.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'logs_overview_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class LogsOverviewState with _$LogsOverviewState {
|
||||
const LogsOverviewState._();
|
||||
|
||||
const factory LogsOverviewState({
|
||||
@Default(AsyncLoading()) AsyncValue<List<LogEntity>> logs,
|
||||
@Default(false) bool paused,
|
||||
@Default("") String filter,
|
||||
LogLevel? levelFilter,
|
||||
}) = _LogsOverviewState;
|
||||
}
|
||||
Reference in New Issue
Block a user