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) {
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,13 +525,19 @@ 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
? '${l.daemonActionResultOk(label)}\n${r.stdout}'
: '${l.daemonActionResultFailed(label)}\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
.trim();
_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();
});
// Refresh the status pill restart / stop / start all
// change the running flag.
@ -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(