feat(studio): central error helpers + inline log viewer + Today CTAs
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Bundles the error-UX overhaul and the inline log viewer:
- New `FaiLog` (`~/.fai/logs/studio-errors.log`, 256 KiB rotation,
JSON-per-line). Every operator-visible failure is appended so
`fai admin doctor` and the new viewer can show the trail
without the operator having to reproduce the failure.
- New `showFaiErrorSnack` / `showFaiErrorDialog` helpers wrap
`FaiErrorBox` in copyable surfaces; 27 ad-hoc
`SnackBar(content: Text(e.toString()))` sites swept to use
them (settings, doctor, audit, store, module sheet, system-AI
editor, flow output).
- New `FaiLogViewer` modal (`showFaiLogViewer`) renders log
files inline with line numbers, JSON-key + `[level]` token
colouring, Copy-all, Refresh, Open-externally. Doctor's
daemon-paths panel grows a "View" button next to "Open" for
every `.log` row and now also lists the Studio errors log.
- Today carousel: CTAs now actually re-run search after a
`filterCategory` / `runQuery` story is tapped (was only
flipping the chip state). Fallback story list bumped to 8.
- Editor bumped to git ref carrying 0.15.0 (type-token
colouring + analyzer diagnostics).
Studio bumped to 0.62.0.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
2731062a13
commit
8f5cd2528b
22 changed files with 1087 additions and 134 deletions
442
lib/widgets/fai_log_viewer.dart
Normal file
442
lib/widgets/fai_log_viewer.dart
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
// 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,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => FaiLogViewer(
|
||||
path: path,
|
||||
title: title ?? path.split(Platform.pathSeparator).last,
|
||||
tailLines: tailLines,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class FaiLogViewer extends StatefulWidget {
|
||||
final String path;
|
||||
final String title;
|
||||
final int tailLines;
|
||||
|
||||
const FaiLogViewer({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.title,
|
||||
this.tailLines = 500,
|
||||
});
|
||||
|
||||
@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 _readTail(widget.path, widget.tailLines);
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const _LogBody({required this.lines});
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogLine extends StatelessWidget {
|
||||
final int lineNumber;
|
||||
final String text;
|
||||
final double gutterWidth;
|
||||
final ThemeData theme;
|
||||
|
||||
const _LogLine({
|
||||
required this.lineNumber,
|
||||
required this.text,
|
||||
required this.gutterWidth,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@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(
|
||||
_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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
try {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return const <String>[];
|
||||
final raw = await f.readAsString();
|
||||
final lines = raw.split('\n');
|
||||
// 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);
|
||||
} catch (_) {
|
||||
return const <String>[];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue