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:
parent
f0a944dce7
commit
b35bbc1b12
2 changed files with 115 additions and 19 deletions
|
|
@ -402,7 +402,15 @@ class _PathRow extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
|
|
@ -428,10 +436,11 @@ class _PathRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
if (isLog) ...[
|
||||
if (isLog || isConfig) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
showFaiLogViewer(context, path: path, title: label),
|
||||
onPressed: () => isConfig
|
||||
? showFaiConfigViewer(context, path: path, title: label)
|
||||
: showFaiLogViewer(context, path: path, title: label),
|
||||
icon: const Icon(Icons.visibility_outlined, size: 14),
|
||||
label: Text(l.buttonView),
|
||||
style: OutlinedButton.styleFrom(
|
||||
|
|
@ -482,6 +491,11 @@ class _DaemonActionsCard extends StatefulWidget {
|
|||
class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
||||
bool _busy = false;
|
||||
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;
|
||||
|
||||
@override
|
||||
|
|
@ -511,10 +525,16 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
final r = await action();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_output =
|
||||
(r.ok
|
||||
_binaryMissing = binaryMissing;
|
||||
// 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.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
|
||||
.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) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Container(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ Future<void> showFaiLogViewer(
|
|||
required String path,
|
||||
String? title,
|
||||
int tailLines = 500,
|
||||
bool fromTop = false,
|
||||
bool plain = false,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
|
|
@ -44,20 +46,51 @@ Future<void> showFaiLogViewer(
|
|||
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
|
||||
|
|
@ -77,7 +110,11 @@ class _FaiLogViewerState extends State<FaiLogViewer> {
|
|||
|
||||
Future<void> _reload() async {
|
||||
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;
|
||||
setState(() {
|
||||
_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 {
|
||||
final List<String> lines;
|
||||
final bool plain;
|
||||
|
||||
const _LogBody({required this.lines});
|
||||
const _LogBody({required this.lines, this.plain = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -265,6 +303,7 @@ class _LogBody extends StatelessWidget {
|
|||
text: lines[i],
|
||||
gutterWidth: gutterWidth,
|
||||
theme: theme,
|
||||
plain: plain,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -276,12 +315,14 @@ class _LogLine extends StatelessWidget {
|
|||
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
|
||||
|
|
@ -307,7 +348,12 @@ class _LogLine extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: SelectableText.rich(
|
||||
_highlightLine(text, theme),
|
||||
plain
|
||||
? TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
)
|
||||
: _highlightLine(text, theme),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
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
|
||||
/// any I/O failure — the viewer renders an empty-state hint in
|
||||
/// that case rather than throwing.
|
||||
Future<List<String>> _readTail(String path, int maxLines) async {
|
||||
/// 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');
|
||||
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 lines.sublist(lines.length - maxLines);
|
||||
return fromTop
|
||||
? lines.sublist(0, maxLines)
|
||||
: lines.sublist(lines.length - maxLines);
|
||||
} catch (_) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue