- Strip ANSI / control characters when reading log files so daemon logs that carry colour escape codes render clean instead of as garbled special characters (the source side is fixed in the hub too; this is the defensive belt for existing files). - Generalise the file viewer (read top-down + plain mode) and add an in-Studio "View" affordance for config.yaml / .yml / .toml on the Doctor paths panel, so config is readable without leaving Studio. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
509 lines
15 KiB
Dart
509 lines
15 KiB
Dart
// FaiLogViewer — modal sheet that renders a log file inline with
|
|
// line numbers, basic syntax colouring and a Copy-all button.
|
|
//
|
|
// Use [showFaiLogViewer] to open it. Two log shapes are supported
|
|
// out of the box:
|
|
//
|
|
// - JSON-per-line (Studio's `~/.fai/logs/studio-errors.log`):
|
|
// fields `ts`, `level`, `source`, `error` get distinct colours;
|
|
// anything else renders neutral.
|
|
// - tracing text (the daemon's `~/.fai/run/<channel>.log`):
|
|
// `[INFO]`, `[WARN]`, `[ERROR]`, `[DEBUG]` substrings get the
|
|
// matching tone from the FaiTheme palette.
|
|
//
|
|
// The viewer reads the *last* N lines (default 500) to keep
|
|
// rendering snappy on huge log files. A Refresh button re-reads
|
|
// the file in place. An "Open externally" button still hands the
|
|
// path to the OS so power users can pipe it through their tool
|
|
// of choice.
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../data/system_actions.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/theme.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
/// Open [path] inside a modal log viewer.
|
|
///
|
|
/// [title] is shown in the AppBar (e.g. `"local.log"` or
|
|
/// `"studio-errors.log"`); falls back to the basename of [path].
|
|
Future<void> showFaiLogViewer(
|
|
BuildContext context, {
|
|
required String path,
|
|
String? title,
|
|
int tailLines = 500,
|
|
bool fromTop = false,
|
|
bool plain = false,
|
|
}) {
|
|
return showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
builder: (_) => FaiLogViewer(
|
|
path: path,
|
|
title: title ?? path.split(Platform.pathSeparator).last,
|
|
tailLines: tailLines,
|
|
fromTop: fromTop,
|
|
plain: plain,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Open a config / plain-text file (e.g. `config.yaml`) inside the
|
|
/// same modal viewer, read top-down with no log-level colouring.
|
|
/// A thin wrapper over [showFaiLogViewer] so call sites read by
|
|
/// intent.
|
|
Future<void> showFaiConfigViewer(
|
|
BuildContext context, {
|
|
required String path,
|
|
String? title,
|
|
int maxLines = 2000,
|
|
}) {
|
|
return showFaiLogViewer(
|
|
context,
|
|
path: path,
|
|
title: title,
|
|
tailLines: maxLines,
|
|
fromTop: true,
|
|
plain: true,
|
|
);
|
|
}
|
|
|
|
class FaiLogViewer extends StatefulWidget {
|
|
final String path;
|
|
final String title;
|
|
final int tailLines;
|
|
|
|
/// Read from the top of the file (config) vs the tail (logs).
|
|
final bool fromTop;
|
|
|
|
/// Skip log-level colouring and render every line neutral
|
|
/// (for config / plain-text files).
|
|
final bool plain;
|
|
|
|
const FaiLogViewer({
|
|
super.key,
|
|
required this.path,
|
|
required this.title,
|
|
this.tailLines = 500,
|
|
this.fromTop = false,
|
|
this.plain = false,
|
|
});
|
|
|
|
@override
|
|
State<FaiLogViewer> createState() => _FaiLogViewerState();
|
|
}
|
|
|
|
class _FaiLogViewerState extends State<FaiLogViewer> {
|
|
List<String> _lines = const <String>[];
|
|
bool _loading = true;
|
|
bool _justCopied = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_reload();
|
|
}
|
|
|
|
Future<void> _reload() async {
|
|
setState(() => _loading = true);
|
|
final lines = await _readLines(
|
|
widget.path,
|
|
widget.tailLines,
|
|
fromTop: widget.fromTop,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_lines = lines;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _copyAll() async {
|
|
await Clipboard.setData(ClipboardData(text: _lines.join('\n')));
|
|
if (!mounted) return;
|
|
setState(() => _justCopied = true);
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (mounted) setState(() => _justCopied = false);
|
|
});
|
|
}
|
|
|
|
Future<void> _openExternal() async {
|
|
await SystemActions.openOrReveal(widget.path, isDirectory: false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Dialog(
|
|
insetPadding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.xl,
|
|
vertical: FaiSpace.xl,
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 980, maxHeight: 720),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_Header(
|
|
title: widget.title,
|
|
path: widget.path,
|
|
lineCount: _lines.length,
|
|
tailLimit: widget.tailLines,
|
|
loading: _loading,
|
|
justCopied: _justCopied,
|
|
onCopy: _copyAll,
|
|
onReload: _reload,
|
|
onOpenExternal: _openExternal,
|
|
onClose: () => Navigator.of(context).pop(),
|
|
),
|
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
Expanded(
|
|
child: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _lines.isEmpty
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.xl),
|
|
child: Text(
|
|
l.logViewerEmpty,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: _LogBody(lines: _lines, plain: widget.plain),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Header extends StatelessWidget {
|
|
final String title;
|
|
final String path;
|
|
final int lineCount;
|
|
final int tailLimit;
|
|
final bool loading;
|
|
final bool justCopied;
|
|
final VoidCallback onCopy;
|
|
final VoidCallback onReload;
|
|
final VoidCallback onOpenExternal;
|
|
final VoidCallback onClose;
|
|
|
|
const _Header({
|
|
required this.title,
|
|
required this.path,
|
|
required this.lineCount,
|
|
required this.tailLimit,
|
|
required this.loading,
|
|
required this.justCopied,
|
|
required this.onCopy,
|
|
required this.onReload,
|
|
required this.onOpenExternal,
|
|
required this.onClose,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
FaiSpace.lg,
|
|
FaiSpace.md,
|
|
FaiSpace.sm,
|
|
FaiSpace.md,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.description_outlined,
|
|
size: 18,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l.logViewerTitle(title, tailLimit),
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'$path · ${l.logViewerLineCount(lineCount)}',
|
|
style: FaiTheme.mono(
|
|
size: 10,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: justCopied ? l.buttonCopied : l.logViewerCopyAll,
|
|
icon: Icon(
|
|
justCopied ? Icons.check : Icons.content_copy,
|
|
size: 16,
|
|
),
|
|
onPressed: loading || lineCount == 0 ? null : onCopy,
|
|
),
|
|
IconButton(
|
|
tooltip: l.buttonRefresh,
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
onPressed: loading ? null : onReload,
|
|
),
|
|
IconButton(
|
|
tooltip: l.logViewerOpenExternal,
|
|
icon: const Icon(Icons.open_in_new, size: 16),
|
|
onPressed: onOpenExternal,
|
|
),
|
|
IconButton(
|
|
tooltip: l.buttonClose,
|
|
icon: const Icon(Icons.close, size: 16),
|
|
onPressed: onClose,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LogBody extends StatelessWidget {
|
|
final List<String> lines;
|
|
final bool plain;
|
|
|
|
const _LogBody({required this.lines, this.plain = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final gutterWidth = 12.0 + 8.0 * lines.length.toString().length;
|
|
return Scrollbar(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
itemCount: lines.length,
|
|
itemBuilder: (context, i) => _LogLine(
|
|
lineNumber: i + 1,
|
|
text: lines[i],
|
|
gutterWidth: gutterWidth,
|
|
theme: theme,
|
|
plain: plain,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LogLine extends StatelessWidget {
|
|
final int lineNumber;
|
|
final String text;
|
|
final double gutterWidth;
|
|
final ThemeData theme;
|
|
final bool plain;
|
|
|
|
const _LogLine({
|
|
required this.lineNumber,
|
|
required this.text,
|
|
required this.gutterWidth,
|
|
required this.theme,
|
|
this.plain = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 1),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: gutterWidth,
|
|
child: Text(
|
|
'$lineNumber',
|
|
textAlign: TextAlign.right,
|
|
style: FaiTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurfaceVariant.withValues(
|
|
alpha: 0.6,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: SelectableText.rich(
|
|
plain
|
|
? TextSpan(
|
|
text: text,
|
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
|
)
|
|
: _highlightLine(text, theme),
|
|
style: FaiTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Lightweight tokeniser shared by both supported log shapes.
|
|
///
|
|
/// We don't reach for a full grammar here on purpose: log files
|
|
/// are short-lived, the operator just needs the eye to land on
|
|
/// the level/source quickly. Real syntax highlighting belongs in
|
|
/// the flow editor; logs only need three or four accent colours.
|
|
TextSpan _highlightLine(String line, ThemeData theme) {
|
|
// JSON-per-line path (the Studio error log). Looks like:
|
|
// {"ts":"…","level":"error","source":"flows.run","error":"…"}
|
|
if (line.startsWith('{') && line.endsWith('}')) {
|
|
return _highlightJsonLine(line, theme);
|
|
}
|
|
return _highlightTraceLine(line, theme);
|
|
}
|
|
|
|
TextSpan _highlightJsonLine(String line, ThemeData theme) {
|
|
final spans = <TextSpan>[];
|
|
final base = TextStyle(color: theme.colorScheme.onSurface);
|
|
final keyStyle = TextStyle(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
);
|
|
final stringStyle = TextStyle(color: FaiColors.success);
|
|
final errorStringStyle = TextStyle(color: theme.colorScheme.error);
|
|
|
|
final keyRe = RegExp(r'"(ts|level|source|error|context)"\s*:');
|
|
final stringRe = RegExp(r'"((?:\\.|[^"\\])*)"');
|
|
|
|
int cursor = 0;
|
|
bool nextStringIsError = false;
|
|
while (cursor < line.length) {
|
|
final keyMatch = keyRe.matchAsPrefix(line, cursor);
|
|
if (keyMatch != null) {
|
|
spans.add(TextSpan(text: keyMatch.group(0), style: keyStyle));
|
|
nextStringIsError = keyMatch.group(1) == 'error';
|
|
cursor = keyMatch.end;
|
|
continue;
|
|
}
|
|
final stringMatch = stringRe.matchAsPrefix(line, cursor);
|
|
if (stringMatch != null) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: stringMatch.group(0),
|
|
style: nextStringIsError ? errorStringStyle : stringStyle,
|
|
),
|
|
);
|
|
cursor = stringMatch.end;
|
|
nextStringIsError = false;
|
|
continue;
|
|
}
|
|
spans.add(TextSpan(text: line[cursor], style: base));
|
|
cursor++;
|
|
}
|
|
return TextSpan(children: spans);
|
|
}
|
|
|
|
TextSpan _highlightTraceLine(String line, ThemeData theme) {
|
|
// Tracing line: prefix tag like [INFO] / [WARN] / [ERROR] /
|
|
// [DEBUG] gets the tone colour, everything else stays neutral.
|
|
final tagRe = RegExp(r'\[(INFO|WARN|WARNING|ERROR|DEBUG|TRACE)\]');
|
|
final match = tagRe.firstMatch(line);
|
|
if (match == null) {
|
|
return TextSpan(
|
|
text: line,
|
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
|
);
|
|
}
|
|
Color tone;
|
|
switch (match.group(1)) {
|
|
case 'ERROR':
|
|
tone = theme.colorScheme.error;
|
|
break;
|
|
case 'WARN':
|
|
case 'WARNING':
|
|
tone = FaiColors.warning;
|
|
break;
|
|
case 'INFO':
|
|
tone = theme.colorScheme.primary;
|
|
break;
|
|
default:
|
|
tone = theme.colorScheme.onSurfaceVariant;
|
|
}
|
|
return TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: line.substring(0, match.start),
|
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
|
),
|
|
TextSpan(
|
|
text: match.group(0),
|
|
style: TextStyle(color: tone, fontWeight: FontWeight.w600),
|
|
),
|
|
TextSpan(
|
|
text: line.substring(match.end),
|
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Matches ANSI / VT100 escape sequences (colour codes, cursor
|
|
/// moves) plus stray control characters. Daemon logs written to a
|
|
/// file by older `fai` builds carried `\x1b[..m` colour codes that
|
|
/// rendered as garbled "special characters" in the viewer; we
|
|
/// strip them defensively so existing log files render clean even
|
|
/// though current builds no longer write ANSI to files.
|
|
final RegExp _ansiAndControl = RegExp(
|
|
r'\x1B\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]',
|
|
);
|
|
|
|
String _sanitizeLine(String line) => line.replaceAll(_ansiAndControl, '');
|
|
|
|
/// Read up to [maxLines] of [path], sanitising each line of ANSI /
|
|
/// control characters. When [fromTop] is true the *first*
|
|
/// [maxLines] are returned (config files, read top-down);
|
|
/// otherwise the *last* [maxLines] are returned (logs, tail-first).
|
|
/// Returns an empty list on any I/O failure — the viewer renders
|
|
/// an empty-state hint rather than throwing.
|
|
Future<List<String>> _readLines(
|
|
String path,
|
|
int maxLines, {
|
|
bool fromTop = false,
|
|
}) async {
|
|
try {
|
|
final f = File(path);
|
|
if (!await f.exists()) return const <String>[];
|
|
final raw = await f.readAsString();
|
|
final lines = raw.split('\n').map(_sanitizeLine).toList();
|
|
// Drop the trailing blank line that `split` leaves behind
|
|
// when the file ends with `\n`.
|
|
if (lines.isNotEmpty && lines.last.isEmpty) {
|
|
lines.removeLast();
|
|
}
|
|
if (lines.length <= maxLines) return lines;
|
|
return fromTop
|
|
? lines.sublist(0, maxLines)
|
|
: lines.sublist(lines.length - maxLines);
|
|
} catch (_) {
|
|
return const <String>[];
|
|
}
|
|
}
|