feat(debug): диплинк komet.pw/export-logs и zip-экспорт логов за 24 часа

This commit is contained in:
klockky
2026-07-14 23:05:15 +03:00
parent 1dce377500
commit 1e2fc42b35
8 changed files with 188 additions and 95 deletions
+44 -4
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:app_links/app_links.dart';
import '../../backend/api.dart';
import '../../frontend/debug/log_export.dart';
import '../../frontend/widgets/max_link_handler.dart';
import '../../main.dart';
import 'desktop_url_scheme.dart';
@@ -16,6 +17,8 @@ class DeepLinkService {
StreamSubscription<Uri>? _sub;
StreamSubscription<SessionState>? _stateSub;
String? _pending;
bool _pendingLogExport = false;
Timer? _logExportRetry;
bool _ready = false;
bool _started = false;
@@ -42,6 +45,11 @@ class DeepLinkService {
}
void _onUri(Uri uri) {
if (_isLogExportLink(uri)) {
_pendingLogExport = true;
_flushPending();
return;
}
final url = _normalize(uri);
if (url == null) return;
_pending = url;
@@ -49,16 +57,46 @@ class DeepLinkService {
}
void _flushPending() {
final pending = _pending;
if (pending == null || !_ready) return;
if (api.state != SessionState.online) return;
final context = KometApp.navigatorKey.currentContext;
if (context == null) return;
if (_pendingLogExport) {
if (context == null) {
_logExportRetry ??= Timer(const Duration(milliseconds: 300), () {
_logExportRetry = null;
_flushPending();
});
} else {
_pendingLogExport = false;
exportDebugLog(context);
}
}
if (!_ready || context == null) return;
final pending = _pending;
if (pending == null || api.state != SessionState.online) return;
_pending = null;
tryHandleMaxLink(context, pending);
}
bool _isLogExportLink(Uri uri) {
final scheme = uri.scheme.toLowerCase();
final host = uri.host.toLowerCase();
final segments = <String>[
if (scheme == 'komet' && host.isNotEmpty) host,
...uri.pathSegments,
].where((s) => s.isNotEmpty).toList();
if (scheme == 'komet') {
return segments.length == 1 && segments.first == 'export-logs';
}
if (scheme == 'https' || scheme == 'http') {
return (host == 'komet.pw' || host == 'www.komet.pw') &&
segments.length == 1 &&
segments.first == 'export-logs';
}
return false;
}
String? _normalize(Uri uri) {
final scheme = uri.scheme.toLowerCase();
@@ -82,6 +120,8 @@ class DeepLinkService {
}
void dispose() {
_logExportRetry?.cancel();
_logExportRetry = null;
_sub?.cancel();
_sub = null;
_stateSub?.cancel();
+80 -49
View File
@@ -5,8 +5,16 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../protocol/opcode_map.dart';
import 'format.dart';
import 'log_redact.dart';
class DebugExportFile {
final String name;
final String content;
DebugExportFile(this.name, this.content);
}
class _LogEntry {
final int opcode;
final int seq;
@@ -99,7 +107,8 @@ class DebugSessionLog {
DebugSessionLog._();
static final DebugSessionLog instance = DebugSessionLog._();
static const int _maxSessions = 3;
static const Duration _retention = Duration(hours: 24);
static const int _maxStoredSessions = 30;
static const int _maxEntriesPerSession = 2000;
static const int _maxLogLinesPerSession = 5000;
static const Duration _flushDebounce = Duration(seconds: 3);
@@ -225,9 +234,17 @@ class DebugSessionLog {
Future<void> _rotate() async {
final files = await _sessionFiles();
const keep = _maxSessions - 1;
if (files.length <= keep) return;
for (final file in files.take(files.length - keep)) {
final cutoff = DateTime.now().subtract(_retention).millisecondsSinceEpoch;
final stale = <File>[];
final fresh = <File>[];
for (final file in files) {
(_startMillis(file) < cutoff ? stale : fresh).add(file);
}
const keep = _maxStoredSessions - 1;
if (fresh.length > keep) {
stale.addAll(fresh.take(fresh.length - keep));
}
for (final file in stale) {
try {
await file.delete();
} catch (_) {}
@@ -255,7 +272,8 @@ class DebugSessionLog {
return int.tryParse(digits) ?? 0;
}
Future<String?> buildExport({String? endpoint}) async {
Future<List<DebugExportFile>?> buildExportFiles({String? endpoint}) async {
final cutoff = DateTime.now().subtract(_retention);
final sessions = <_SessionData>[];
final dir = _dir;
if (dir != null) {
@@ -263,7 +281,10 @@ class DebugSessionLog {
if (_currentFile != null && file.path == _currentFile!.path) continue;
try {
final decoded = jsonDecode(await file.readAsString());
if (decoded is Map) sessions.add(_SessionData.fromJson(decoded));
if (decoded is Map) {
final session = _SessionData.fromJson(decoded);
if (!session.startedAt.isBefore(cutoff)) sessions.add(session);
}
} catch (_) {}
}
}
@@ -277,56 +298,66 @@ class DebugSessionLog {
),
);
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
final lastN = sessions.length > _maxSessions
? sessions.sublist(sessions.length - _maxSessions)
: sessions;
final totalEntries = lastN.fold<int>(0, (sum, s) => sum + s.entries.length);
final totalLogs = lastN.fold<int>(0, (sum, s) => sum + s.logLines.length);
final totalEntries = sessions.fold<int>(
0,
(sum, s) => sum + s.entries.length,
);
final totalLogs = sessions.fold<int>(
0,
(sum, s) => sum + s.logLines.length,
);
if (totalEntries == 0 && totalLogs == 0) return null;
final info = StringBuffer();
info.writeln('Komet — отладочный лог');
if (endpoint != null) info.writeln('Сервер: $endpoint');
info.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
info.writeln('Период: последние ${_retention.inHours} часа');
info.writeln('Заходов в приложение: ${sessions.length}');
info.writeln('Всего запросов: $totalEntries');
info.writeln('Всего строк лога: $totalLogs');
info.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
final files = <DebugExportFile>[DebugExportFile('info.txt', '$info')];
for (var s = 0; s < sessions.length; s++) {
final session = sessions[s];
final name =
'session_${(s + 1).toString().padLeft(2, '0')}_'
'${formatFileStamp(session.startedAt)}.txt';
files.add(DebugExportFile(name, _buildSessionText(s + 1, session)));
}
return files;
}
String _buildSessionText(int index, _SessionData session) {
final buffer = StringBuffer();
buffer.writeln('Komet — отладочный лог');
if (endpoint != null) buffer.writeln('Сервер: $endpoint');
buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
buffer.writeln('Заходов в приложение: ${lastN.length}');
buffer.writeln('Всего запросов: $totalEntries');
buffer.writeln('Всего строк лога: $totalLogs');
buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
buffer.writeln('==================================================');
buffer.writeln('ЗАХОД #$index${session.startedAt.toIso8601String()}');
buffer.writeln(
'запросов: ${session.entries.length}'
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
' · строк лога: ${session.logLines.length}'
'${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
);
buffer.writeln('==================================================');
buffer.writeln();
for (var s = 0; s < lastN.length; s++) {
final session = lastN[s];
buffer.writeln('==================================================');
buffer.writeln(
'ЗАХОД #${s + 1}${session.startedAt.toIso8601String()}',
);
buffer.writeln(
'запросов: ${session.entries.length}'
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
' · строк лога: ${session.logLines.length}'
'${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
);
buffer.writeln('==================================================');
buffer.writeln();
buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
if (session.logLines.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (final line in session.logLines) {
buffer.writeln(line);
}
buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
if (session.logLines.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (final line in session.logLines) {
buffer.writeln(line);
}
buffer.writeln();
}
buffer.writeln();
buffer.writeln('----- ЗАПРОСЫ -----');
if (session.entries.isEmpty) {
buffer.writeln('(пусто)');
buffer.writeln();
} else {
for (var i = 0; i < session.entries.length; i++) {
_writeEntry(buffer, i + 1, session.entries[i]);
}
buffer.writeln('----- ЗАПРОСЫ -----');
if (session.entries.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (var i = 0; i < session.entries.length; i++) {
_writeEntry(buffer, i + 1, session.entries[i]);
}
}
return buffer.toString();