Files
FlClash/lib/views/logs.dart
T
chen08209 da6fb0a8a1 feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 17:21:43 +08:00

268 lines
8.2 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class LogListController extends ValueNotifier<LogsState> {
LogListController() : super(const LogsState());
void search(String query) {
value = value.copyWith(query: query);
}
void updateKeywords(List<String> keywords) {
value = value.copyWith(keywords: keywords);
}
void setLogs(List<Log> logs) {
if (identical(logs, value.logs)) {
return;
}
value = value.copyWith(
logs: value.autoScrollToEnd
? logs
: retainTrimmedHead(value.logs, logs, pausedMaxLogsLength),
);
}
void setAutoScrollToEnd(bool autoScrollToEnd) {
value = value.copyWith(autoScrollToEnd: autoScrollToEnd);
}
void resumeAutoScrollToEnd(List<Log> logs) {
value = value.copyWith(autoScrollToEnd: true, logs: logs);
}
}
class LogsView extends ConsumerStatefulWidget {
const LogsView({super.key});
@override
ConsumerState<LogsView> createState() => _LogsViewState();
}
class _LogsViewState extends ConsumerState<LogsView>
with RouteMotionHoldMixin<LogsView> {
final _listController = LogListController();
late final ScrollController _scrollController;
late final _physics = FollowEndScrollPhysics(
isFollowing: () => _listController.value.autoScrollToEnd,
);
@override
void initState() {
super.initState();
_scrollController = sheetScrollController(
context,
initialScrollOffset: double.maxFinite,
);
_listController.setLogs(ref.read(logsProvider).list);
ref.listenManual(logsProvider.select((state) => state.revision), (_, _) {
updateLogsThrottler();
});
}
List<IconButtonData> _buildActions() {
return [
IconButtonData(
glyph: AppGlyphs.export,
tooltip: context.appLocalizations.exportLogs,
onPressed: _handleExport,
),
];
}
@override
void dispose() {
_listController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _handleExport() async {
final appLocalizations = context.appLocalizations;
final res = await globalState.safeRun<bool>(() async {
return ref.read(logsProvider.notifier).exportLogs();
}, title: appLocalizations.exportLogs);
if (res != true) return;
unawaited(
dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(text: appLocalizations.exportSuccess),
),
);
}
void updateLogsThrottler() {
throttler.call(FunctionTag.logs, () {
if (!mounted) {
return;
}
updateWhenRouteSettled(
() => _listController.setLogs(ref.read(logsProvider).list),
);
}, duration: renderThrottleDuration);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonScaffold(
iconActions: _buildActions(),
onKeywordsUpdate: _listController.updateKeywords,
searchState: AppBarSearchState(onSearch: _listController.search),
title: appLocalizations.logs,
body: ValueListenableBuilder<LogsState>(
valueListenable: _listController,
builder: (context, state, _) {
final logs = state.list;
return NullStatusSwitcher(
isEmpty: logs.isEmpty,
isSearching: state.isSearching,
nullStatus: NullStatus(
illustration: NullStatusIllustration.logs,
label: appLocalizations.nullTip(appLocalizations.logs),
),
child: Align(
alignment: Alignment.topCenter,
child: FloatingScrollbar(
controller: _scrollController,
hintBuilder: (fraction) {
final index = (fraction * (logs.length - 1)).round();
return logs[index].dateTime;
},
child: ScrollToEndBox(
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(logsProvider).list,
);
},
controller: _scrollController,
enable: state.autoScrollToEnd,
child: SuperListView.separated(
physics: _physics,
reverse: true,
shrinkWrap: true,
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
itemCount: logs.length,
extentEstimation: recordListExtentEstimation,
separatorBuilder: (_, _) =>
const SizedBox(height: recordListGap),
itemBuilder: (_, index) {
final log = logs[index];
return LogItem(
log: log,
onClick: (value) {
context.commonScaffoldState?.addKeyword(value);
},
);
},
),
),
),
),
);
},
),
);
}
}
class LogItem extends StatelessWidget {
final Log log;
final Function(String)? onClick;
const LogItem({super.key, required this.log, this.onClick});
@override
Widget build(BuildContext context) {
final tone = switch (log.logLevel) {
LogLevel.warning => RecordTone.warning,
LogLevel.error => RecordTone.error,
LogLevel.info || LogLevel.debug || LogLevel.silent => RecordTone.neutral,
};
final payload = LogPayload.parse(log.payload);
return RecordListItem(
onTap: () {},
header: RecordHeader(
children: [
RecordTimestamp(log.dateTime),
RecordLabel(
label: log.logLevel.name,
tone: tone,
onPressed: () => onClick?.call(log.logLevel.name),
),
],
),
body: _LogBody(payload: payload, errorColor: tone.accentColor(context)),
);
}
}
class _LogBody extends StatelessWidget {
final LogPayload payload;
final Color? errorColor;
const _LogBody({required this.payload, this.errorColor});
String get _sourceText => [
payload.tag,
if (payload.route case final route?) ...[route.source, route.sourceDetail],
].where((text) => text.isNotEmpty).join(' · ');
@override
Widget build(BuildContext context) {
final styles = RecordTextStyles.of(context);
final route = payload.route;
if (route == null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(payload.message, style: styles.primary),
if (payload.tag.isNotEmpty)
Text(payload.tag, style: styles.secondary),
],
);
}
return SelectableText.rich(
TextSpan(
children: [
TextSpan(text: route.destination, style: styles.title),
if (route.error.isNotEmpty)
TextSpan(
text: '\n${route.error}',
style: styles.secondary.copyWith(color: errorColor),
),
const TextSpan(text: '\n'),
if (route.rule.isNotEmpty) ...[
TextSpan(text: route.rule, style: styles.secondary),
TextSpan(text: ' \u2192 ', style: styles.secondary.toJetBrainsMono),
],
TextSpan(
text: route.proxy,
style: AppTextStyle.footnote
.of(context, emphasized: true)
.copyWith(color: context.colorScheme.primary),
),
TextSpan(text: '\n$_sourceText', style: styles.secondary),
],
),
);
}
}