157 lines
4.5 KiB
Dart
157 lines
4.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
/// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions.
|
|
class DiagnosticLog {
|
|
DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now;
|
|
static final instance = DiagnosticLog();
|
|
static const maxBytes = 256 * 1024;
|
|
static const _keys = {
|
|
'school',
|
|
'phase',
|
|
'page',
|
|
'count',
|
|
'term',
|
|
'type',
|
|
'status',
|
|
'code',
|
|
'version',
|
|
'result',
|
|
};
|
|
final DateTime Function() clock;
|
|
final _entries = <Map<String, dynamic>>[];
|
|
File? _file;
|
|
Future<void> _writes = Future.value();
|
|
Timer? _timer;
|
|
|
|
Map<String, Object> _safe(Map fields) => {
|
|
for (final e in fields.entries.take(6))
|
|
if (_keys.contains(e.key) &&
|
|
(e.value is bool ||
|
|
e.value is int ||
|
|
e.value is String &&
|
|
RegExp(r'^[A-Za-z0-9_.-]{1,40}$').hasMatch(e.value)))
|
|
e.key as String: e.value as Object,
|
|
};
|
|
void _trim() {
|
|
final now = clock().toUtc();
|
|
_entries.removeWhere((e) {
|
|
final at = DateTime.tryParse(e['at'] as String? ?? '');
|
|
return at == null ||
|
|
at.isAfter(now) ||
|
|
now.difference(at) > const Duration(minutes: 10);
|
|
});
|
|
if (_entries.length > 500) _entries.removeRange(0, _entries.length - 500);
|
|
}
|
|
|
|
void add(String event, {Map<String, Object?> fields = const {}}) {
|
|
if (!RegExp(r'^[a-z_]{1,64}$').hasMatch(event)) return;
|
|
_trim();
|
|
final safe = _safe(fields);
|
|
if (_entries.isNotEmpty) {
|
|
final last = _entries.last;
|
|
final at = DateTime.tryParse(last['at'] as String? ?? '');
|
|
final previous = Map<String, dynamic>.from(last)
|
|
..remove('at')
|
|
..remove('event');
|
|
if (last['event'] == event &&
|
|
jsonEncode(previous) == jsonEncode(safe) &&
|
|
at != null &&
|
|
clock().toUtc().difference(at) < const Duration(seconds: 3)) {
|
|
return;
|
|
}
|
|
}
|
|
_entries.add({
|
|
'at': clock().toUtc().toIso8601String(),
|
|
'event': event,
|
|
...safe,
|
|
});
|
|
_trim();
|
|
unawaited(flush());
|
|
}
|
|
|
|
void navigation(String school, String phase, String url) {
|
|
final uri = Uri.tryParse(url);
|
|
final page = uri == null
|
|
? 'unknown'
|
|
: uri.host == 'idas.uestc.edu.cn'
|
|
? 'identity'
|
|
: uri.host == 'yjsjy.uestc.edu.cn'
|
|
? uri.path.contains('xskbcx')
|
|
? 'timetable'
|
|
: uri.path.contains('kskcxx')
|
|
? 'exams'
|
|
: 'graduate_portal'
|
|
: uri.host == 'eams.uestc.edu.cn'
|
|
? uri.path.contains('stdExamTable')
|
|
? 'exams'
|
|
: uri.path.contains('courseTable')
|
|
? 'timetable'
|
|
: 'undergraduate_portal'
|
|
: 'external';
|
|
add('navigation_$phase', fields: {'school': school, 'page': page});
|
|
}
|
|
|
|
Future<void> attach(Directory directory) async {
|
|
final file = File('${directory.path}/diagnostics-recent.json');
|
|
try {
|
|
if (await file.exists() && await file.length() <= maxBytes) {
|
|
final saved = jsonDecode(await file.readAsString());
|
|
if (saved is List) {
|
|
final restored = <Map<String, dynamic>>[];
|
|
for (final row in saved.take(500)) {
|
|
if (row is Map &&
|
|
row['at'] is String &&
|
|
row['event'] is String &&
|
|
RegExp(r'^[a-z_]{1,64}$').hasMatch(row['event'])) {
|
|
restored.add({
|
|
'at': row['at'],
|
|
'event': row['event'],
|
|
..._safe(row),
|
|
});
|
|
}
|
|
}
|
|
_entries.insertAll(0, restored);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
/* Diagnostics must never block startup or feedback. */
|
|
}
|
|
_file = file;
|
|
_timer?.cancel();
|
|
_timer = Timer.periodic(
|
|
const Duration(minutes: 1),
|
|
(_) => unawaited(flush()),
|
|
);
|
|
await flush();
|
|
}
|
|
|
|
Future<void> flush() {
|
|
_trim();
|
|
final file = _file;
|
|
if (file == null) return Future.value();
|
|
final text = jsonEncode(_entries);
|
|
_writes = _writes.then((_) async {
|
|
try {
|
|
await file.writeAsString(text, flush: true);
|
|
} catch (_) {}
|
|
});
|
|
return _writes;
|
|
}
|
|
|
|
Future<Uint8List> export() async {
|
|
_trim();
|
|
await flush();
|
|
final header =
|
|
'TPSchedule diagnostic log\nWindow: last 10 minutes (UTC timestamps)\n'
|
|
'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n';
|
|
return Uint8List.fromList(
|
|
utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'),
|
|
);
|
|
}
|
|
|
|
void dispose() => _timer?.cancel();
|
|
}
|