fix(studio): readable logs + in-app config.yaml viewer

- 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>
This commit is contained in:
flemming-it 2026-06-11 23:52:40 +02:00
parent f0a944dce7
commit b35bbc1b12
2 changed files with 115 additions and 19 deletions

View file

@ -402,7 +402,15 @@ class _PathRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final isLog = !isDirectory && path.toLowerCase().endsWith('.log'); final lower = path.toLowerCase();
final isLog = !isDirectory && lower.endsWith('.log');
// Text config files get an in-Studio viewer too (read top-down,
// no log colouring) so the operator can read config.yaml
// without leaving Studio or hunting for an external editor.
final isConfig = !isDirectory &&
(lower.endsWith('.yaml') ||
lower.endsWith('.yml') ||
lower.endsWith('.toml'));
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
@ -428,10 +436,11 @@ class _PathRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
if (isLog) ...[ if (isLog || isConfig) ...[
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => onPressed: () => isConfig
showFaiLogViewer(context, path: path, title: label), ? showFaiConfigViewer(context, path: path, title: label)
: showFaiLogViewer(context, path: path, title: label),
icon: const Icon(Icons.visibility_outlined, size: 14), icon: const Icon(Icons.visibility_outlined, size: 14),
label: Text(l.buttonView), label: Text(l.buttonView),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@ -482,6 +491,11 @@ class _DaemonActionsCard extends StatefulWidget {
class _DaemonActionsCardState extends State<_DaemonActionsCard> { class _DaemonActionsCardState extends State<_DaemonActionsCard> {
bool _busy = false; bool _busy = false;
String? _output; String? _output;
/// True when the last daemon action failed because no `fai`
/// binary could be located. Swaps the raw output line for the
/// actionable [FaiBinaryRecovery] block.
bool _binaryMissing = false;
ChannelStatusSnapshot? _channels; ChannelStatusSnapshot? _channels;
@override @override
@ -511,10 +525,16 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
final r = await action(); final r = await action();
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound;
setState(() { setState(() {
_busy = false; _busy = false;
_output = _binaryMissing = binaryMissing;
(r.ok // When the binary is missing we render the actionable
// recovery block instead of a raw output line the
// sentinel is not a user-facing string.
_output = binaryMissing
? null
: (r.ok
? '${l.daemonActionResultOk(label)}\n${r.stdout}' ? '${l.daemonActionResultOk(label)}\n${r.stdout}'
: '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}') : '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
.trim(); .trim();
@ -659,6 +679,15 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
], ],
), ),
], ],
if (_binaryMissing) ...[
const SizedBox(height: FaiSpace.md),
FaiBinaryRecovery(
onLocated: () => _run(
l.daemonActionStatus,
() => SystemActions.faiDaemon(['status']),
),
),
],
if (_output != null) ...[ if (_output != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Container( Container(

View file

@ -36,6 +36,8 @@ Future<void> showFaiLogViewer(
required String path, required String path,
String? title, String? title,
int tailLines = 500, int tailLines = 500,
bool fromTop = false,
bool plain = false,
}) { }) {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
@ -44,20 +46,51 @@ Future<void> showFaiLogViewer(
path: path, path: path,
title: title ?? path.split(Platform.pathSeparator).last, title: title ?? path.split(Platform.pathSeparator).last,
tailLines: tailLines, 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 { class FaiLogViewer extends StatefulWidget {
final String path; final String path;
final String title; final String title;
final int tailLines; 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({ const FaiLogViewer({
super.key, super.key,
required this.path, required this.path,
required this.title, required this.title,
this.tailLines = 500, this.tailLines = 500,
this.fromTop = false,
this.plain = false,
}); });
@override @override
@ -77,7 +110,11 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
Future<void> _reload() async { Future<void> _reload() async {
setState(() => _loading = true); setState(() => _loading = true);
final lines = await _readTail(widget.path, widget.tailLines); final lines = await _readLines(
widget.path,
widget.tailLines,
fromTop: widget.fromTop,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_lines = lines; _lines = lines;
@ -141,7 +178,7 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
), ),
), ),
) )
: _LogBody(lines: _lines), : _LogBody(lines: _lines, plain: widget.plain),
), ),
], ],
), ),
@ -249,8 +286,9 @@ class _Header extends StatelessWidget {
class _LogBody extends StatelessWidget { class _LogBody extends StatelessWidget {
final List<String> lines; final List<String> lines;
final bool plain;
const _LogBody({required this.lines}); const _LogBody({required this.lines, this.plain = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -265,6 +303,7 @@ class _LogBody extends StatelessWidget {
text: lines[i], text: lines[i],
gutterWidth: gutterWidth, gutterWidth: gutterWidth,
theme: theme, theme: theme,
plain: plain,
), ),
), ),
); );
@ -276,12 +315,14 @@ class _LogLine extends StatelessWidget {
final String text; final String text;
final double gutterWidth; final double gutterWidth;
final ThemeData theme; final ThemeData theme;
final bool plain;
const _LogLine({ const _LogLine({
required this.lineNumber, required this.lineNumber,
required this.text, required this.text,
required this.gutterWidth, required this.gutterWidth,
required this.theme, required this.theme,
this.plain = false,
}); });
@override @override
@ -307,7 +348,12 @@ class _LogLine extends StatelessWidget {
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Expanded( Expanded(
child: SelectableText.rich( child: SelectableText.rich(
_highlightLine(text, theme), plain
? TextSpan(
text: text,
style: TextStyle(color: theme.colorScheme.onSurface),
)
: _highlightLine(text, theme),
style: FaiTheme.mono( style: FaiTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
@ -420,22 +466,43 @@ TextSpan _highlightTraceLine(String line, ThemeData theme) {
); );
} }
/// Read the last [maxLines] of [path]. Returns an empty list on /// Matches ANSI / VT100 escape sequences (colour codes, cursor
/// any I/O failure the viewer renders an empty-state hint in /// moves) plus stray control characters. Daemon logs written to a
/// that case rather than throwing. /// file by older `fai` builds carried `\x1b[..m` colour codes that
Future<List<String>> _readTail(String path, int maxLines) async { /// 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 { try {
final f = File(path); final f = File(path);
if (!await f.exists()) return const <String>[]; if (!await f.exists()) return const <String>[];
final raw = await f.readAsString(); final raw = await f.readAsString();
final lines = raw.split('\n'); final lines = raw.split('\n').map(_sanitizeLine).toList();
// Drop the trailing blank line that `split` leaves behind // Drop the trailing blank line that `split` leaves behind
// when the file ends with `\n`. // when the file ends with `\n`.
if (lines.isNotEmpty && lines.last.isEmpty) { if (lines.isNotEmpty && lines.last.isEmpty) {
lines.removeLast(); lines.removeLast();
} }
if (lines.length <= maxLines) return lines; if (lines.length <= maxLines) return lines;
return lines.sublist(lines.length - maxLines); return fromTop
? lines.sublist(0, maxLines)
: lines.sublist(lines.length - maxLines);
} catch (_) { } catch (_) {
return const <String>[]; return const <String>[];
} }