Some checks failed
Security / Security check (push) Failing after 2s
Track the platform rename: the hub spawn path is now ~/.chain/bin/chain (was ~/.fai/bin/fai.exe on Windows — both dir and binary were stale, so Studio could not launch the hub after the config-dir rename), the ~/.fai/* help strings become ~/.chain/*, FAI_REGISTRY_TOKEN -> CHAIN_REGISTRY_TOKEN, and the two in-app doc URLs point at the public fai/chain repo (fai/platform was renamed to the private fai/chain-private). The .fai module bundle extension is left unchanged (format phase). flutter analyze: no issues. 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 `~/.chain/logs/studio-errors.log`):
|
|
// fields `ts`, `level`, `source`, `error` get distinct colours;
|
|
// anything else renders neutral.
|
|
// - tracing text (the daemon's `~/.chain/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>[];
|
|
}
|
|
}
|