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
114
lib/data/error_presentation.dart
Normal file
114
lib/data/error_presentation.dart
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Single entry point for surfacing an error to the operator.
|
||||
// Every error pathway Studio shows (SnackBar, dialog, inline
|
||||
// strip) should go through one of these helpers so the
|
||||
// operator gets:
|
||||
//
|
||||
// • a copyable, selectable rendition of the message
|
||||
// • a friendly headline (gRPC codes mapped via
|
||||
// friendlyError) instead of a wall-of-stack-trace
|
||||
// • an automatic append to ~/.fai/logs/studio-errors.log so
|
||||
// the trail survives the dismiss action
|
||||
//
|
||||
// Call sites used to scatter `ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
// SnackBar(content: Text(e.toString())))` — that violated the
|
||||
// "errors must be copyable" rule everywhere. These helpers
|
||||
// replace those open-coded sites.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/fai_error_box.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'fai_log.dart';
|
||||
|
||||
/// Show [error] as a SnackBar with a copy affordance.
|
||||
///
|
||||
/// [source] is a short tag (`flows.run`, `audit.clear`, …)
|
||||
/// used to group entries in the on-disk log so an operator or
|
||||
/// `fai doctor` can scan recent failures by area without
|
||||
/// reading the full free-text. Keep it stable; treat it like
|
||||
/// a metric label, not free-form text.
|
||||
///
|
||||
/// [context] is the build-context the snack lands on. Falls
|
||||
/// back to a Material default duration of 6 seconds — twice
|
||||
/// the framework default so the operator has time to click
|
||||
/// copy on an unfamiliar message.
|
||||
void showFaiErrorSnack(
|
||||
BuildContext context,
|
||||
String source,
|
||||
Object error, {
|
||||
String? title,
|
||||
Duration duration = const Duration(seconds: 6),
|
||||
}) {
|
||||
// Fire-and-forget log write. If the disk's full or the dir
|
||||
// is read-only the UI still surfaces the error.
|
||||
FaiLog.instance.error(source, error, context: title);
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) {
|
||||
// No Scaffold above — the caller is likely showing the
|
||||
// error from inside a dialog. We've already logged; bail
|
||||
// rather than throw.
|
||||
return;
|
||||
}
|
||||
messenger.hideCurrentSnackBar();
|
||||
final theme = Theme.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
// Default SnackBar padding crushes our error box; reset
|
||||
// to zero and let the box's own padding take over.
|
||||
padding: EdgeInsets.zero,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: FaiSpace.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
],
|
||||
FaiErrorBox(error: error, isError: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show [error] as a centered modal dialog. Use this when the
|
||||
/// failure blocks meaningful interaction (auth missing, hub
|
||||
/// unreachable) — a SnackBar would be too easy to dismiss.
|
||||
Future<void> showFaiErrorDialog(
|
||||
BuildContext context,
|
||||
String source,
|
||||
Object error, {
|
||||
String? title,
|
||||
}) async {
|
||||
FaiLog.instance.error(source, error);
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: title == null ? null : Text(title),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: FaiErrorBox(error: error, isError: true, maxHeight: 240),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).maybePop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
119
lib/data/fai_log.dart
Normal file
119
lib/data/fai_log.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Central error log for Studio. Every error surfaced to the
|
||||
// operator (SnackBar, error sheet, recoverable dialog) is also
|
||||
// appended here so `fai doctor` and "Open log" can show what
|
||||
// went wrong without the operator having to reproduce the
|
||||
// failure.
|
||||
//
|
||||
// Path: `~/.fai/logs/studio-errors.log` (cross-platform via
|
||||
// HOME / USERPROFILE). One line per event, JSON-shaped so
|
||||
// `fai doctor` can parse it; humans can still read it because
|
||||
// the JSON is single-line and short.
|
||||
//
|
||||
// Best-effort by design: a failed write to the log file must
|
||||
// never break the UI layer that asked us to log. We swallow
|
||||
// IO errors silently — the worst case is the operator loses
|
||||
// the trail of one failure, not the failure itself.
|
||||
//
|
||||
// Rotation: when the file passes 256 KiB we move it to
|
||||
// `studio-errors.log.1` and start fresh. One rotation level is
|
||||
// enough — anything older is noise, and a Studio session
|
||||
// rarely produces more than a few KiB of error text.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class FaiLog {
|
||||
FaiLog._();
|
||||
|
||||
static final FaiLog instance = FaiLog._();
|
||||
|
||||
// Serialise concurrent writes — Future-based mutex so a burst
|
||||
// of errors (rare but happens during failed install retries)
|
||||
// doesn't interleave half-lines on disk.
|
||||
Future<void> _tail = Future.value();
|
||||
|
||||
static const int _rotateBytes = 256 * 1024;
|
||||
|
||||
String get _path {
|
||||
final home =
|
||||
Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
'.';
|
||||
return p.join(home, '.fai', 'logs', 'studio-errors.log');
|
||||
}
|
||||
|
||||
/// Absolute path of the studio-errors log. Public so the
|
||||
/// Doctor page and the in-app log viewer can read it without
|
||||
/// hard-coding the layout twice.
|
||||
String get path => _path;
|
||||
|
||||
/// Append a single error event. The [source] is a short tag
|
||||
/// (`flows.run`, `daemon.logs`, …) that lets a reader group
|
||||
/// related failures. [error] can be any thrown object — gRPC
|
||||
/// codes, plain strings, exceptions; we serialise it via
|
||||
/// [error.toString()] so the on-disk record matches what the
|
||||
/// operator saw on screen.
|
||||
Future<void> error(String source, Object error, {Object? context}) {
|
||||
final entry = <String, Object?>{
|
||||
'ts': DateTime.now().toUtc().toIso8601String(),
|
||||
'level': 'error',
|
||||
'source': source,
|
||||
'error': error.toString(),
|
||||
if (context != null) 'context': context.toString(),
|
||||
};
|
||||
return _append(entry);
|
||||
}
|
||||
|
||||
/// Read the last [maxLines] lines back. Used by `fai doctor`
|
||||
/// + Settings → "Recent errors" view. Returns oldest first so
|
||||
/// the caller can render in chronological order.
|
||||
Future<List<String>> tail({int maxLines = 50}) async {
|
||||
try {
|
||||
final f = File(_path);
|
||||
if (!await f.exists()) return const <String>[];
|
||||
final raw = await f.readAsString();
|
||||
final lines = raw.split('\n').where((l) => l.isNotEmpty).toList();
|
||||
if (lines.length <= maxLines) return lines;
|
||||
return lines.sublist(lines.length - maxLines);
|
||||
} catch (_) {
|
||||
return const <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _append(Map<String, Object?> entry) {
|
||||
final next = _tail.then((_) async {
|
||||
try {
|
||||
final f = File(_path);
|
||||
final dir = Directory(p.dirname(_path));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
// Rotate before write so a hot-loop never balloons past
|
||||
// the cap. Single-level rotation: keep the previous file
|
||||
// as `.1`, anything older drops on the floor.
|
||||
if (await f.exists()) {
|
||||
final size = await f.length();
|
||||
if (size >= _rotateBytes) {
|
||||
final rotated = File('$_path.1');
|
||||
if (await rotated.exists()) {
|
||||
await rotated.delete();
|
||||
}
|
||||
await f.rename(rotated.path);
|
||||
}
|
||||
}
|
||||
await f.writeAsString(
|
||||
'${jsonEncode(entry)}\n',
|
||||
mode: FileMode.append,
|
||||
flush: false,
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort. A failed log write must not crash the UI.
|
||||
}
|
||||
});
|
||||
_tail = next.catchError((_) {});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,19 @@ import 'package:yaml/yaml.dart';
|
|||
/// closed and ABI-stable — adding a new variant without bumping
|
||||
/// the schema would let an old Studio render an unknown action
|
||||
/// as a no-op button.
|
||||
enum TodayCta { none, openSettings }
|
||||
///
|
||||
/// `filterCategory` and `runQuery` both consume the optional
|
||||
/// [TodayStoryData.ctaPayload] — a wire-string the Store page
|
||||
/// dispatches into its filter / search box. Keeping the action
|
||||
/// surface inside the Store means a hero story can teach about a
|
||||
/// section without forcing a page-route change.
|
||||
enum TodayCta {
|
||||
none,
|
||||
openSettings,
|
||||
filterCategory,
|
||||
runQuery,
|
||||
showSuggestedSources,
|
||||
}
|
||||
|
||||
/// Bilingual story rendered by the Today-Hero card. Either
|
||||
/// loaded from disk or supplied as a const fallback.
|
||||
|
|
@ -37,6 +49,11 @@ class TodayStoryData {
|
|||
final IconData icon;
|
||||
final TodayCta cta;
|
||||
|
||||
/// Wire payload used by [TodayCta.filterCategory] (category id
|
||||
/// to apply to the Store grid) and [TodayCta.runQuery] (free-text
|
||||
/// to drop into the search box). Empty for other variants.
|
||||
final String ctaPayload;
|
||||
|
||||
const TodayStoryData({
|
||||
required this.badgeEn,
|
||||
required this.badgeDe,
|
||||
|
|
@ -48,6 +65,7 @@ class TodayStoryData {
|
|||
required this.ctaLabelDe,
|
||||
required this.icon,
|
||||
required this.cta,
|
||||
this.ctaPayload = '',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +148,7 @@ class TodayStoryLoader {
|
|||
ctaLabelDe: s('cta_label_de'),
|
||||
icon: icon,
|
||||
cta: cta,
|
||||
ctaPayload: s('cta_payload'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +169,12 @@ class TodayStoryLoader {
|
|||
return TodayCta.none;
|
||||
case 'openSettings':
|
||||
return TodayCta.openSettings;
|
||||
case 'filterCategory':
|
||||
return TodayCta.filterCategory;
|
||||
case 'runQuery':
|
||||
return TodayCta.runQuery;
|
||||
case 'showSuggestedSources':
|
||||
return TodayCta.showSuggestedSources;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1423,5 +1423,23 @@
|
|||
"placeholders": {
|
||||
"name": {"type": "String"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttonView": "Ansehen",
|
||||
"logViewerTitle": "{title} — letzte {count} Zeilen",
|
||||
"@logViewerTitle": {
|
||||
"placeholders": {
|
||||
"title": {"type": "String"},
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"logViewerEmpty": "Log-Datei ist leer oder existiert noch nicht.",
|
||||
"logViewerCopyAll": "Alles kopieren",
|
||||
"logViewerOpenExternal": "Im Standardeditor öffnen",
|
||||
"logViewerLineCount": "{count, plural, =1{1 Zeile} other{{count} Zeilen}}",
|
||||
"@logViewerLineCount": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"doctorPathStudioErrors": "Studio-Fehler"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1426,5 +1426,23 @@
|
|||
"placeholders": {
|
||||
"name": {"type": "String"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttonView": "View",
|
||||
"logViewerTitle": "{title} — last {count} lines",
|
||||
"@logViewerTitle": {
|
||||
"placeholders": {
|
||||
"title": {"type": "String"},
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"logViewerEmpty": "Log file is empty or does not exist yet.",
|
||||
"logViewerCopyAll": "Copy all",
|
||||
"logViewerOpenExternal": "Open in default editor",
|
||||
"logViewerLineCount": "{count, plural, =1{1 line} other{{count} lines}}",
|
||||
"@logViewerLineCount": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"doctorPathStudioErrors": "Studio errors"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3913,6 +3913,48 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'A flow named \"{name}\" already exists.'**
|
||||
String flowEditorAlreadyExists(String name);
|
||||
|
||||
/// No description provided for @buttonView.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'View'**
|
||||
String get buttonView;
|
||||
|
||||
/// No description provided for @logViewerTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{title} — last {count} lines'**
|
||||
String logViewerTitle(String title, int count);
|
||||
|
||||
/// No description provided for @logViewerEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Log file is empty or does not exist yet.'**
|
||||
String get logViewerEmpty;
|
||||
|
||||
/// No description provided for @logViewerCopyAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copy all'**
|
||||
String get logViewerCopyAll;
|
||||
|
||||
/// No description provided for @logViewerOpenExternal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open in default editor'**
|
||||
String get logViewerOpenExternal;
|
||||
|
||||
/// No description provided for @logViewerLineCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 line} other{{count} lines}}'**
|
||||
String logViewerLineCount(int count);
|
||||
|
||||
/// No description provided for @doctorPathStudioErrors.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studio errors'**
|
||||
String get doctorPathStudioErrors;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -2282,4 +2282,35 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String flowEditorAlreadyExists(String name) {
|
||||
return 'Ein Flow namens \"$name\" existiert bereits.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get buttonView => 'Ansehen';
|
||||
|
||||
@override
|
||||
String logViewerTitle(String title, int count) {
|
||||
return '$title — letzte $count Zeilen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get logViewerEmpty => 'Log-Datei ist leer oder existiert noch nicht.';
|
||||
|
||||
@override
|
||||
String get logViewerCopyAll => 'Alles kopieren';
|
||||
|
||||
@override
|
||||
String get logViewerOpenExternal => 'Im Standardeditor öffnen';
|
||||
|
||||
@override
|
||||
String logViewerLineCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Zeilen',
|
||||
one: '1 Zeile',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get doctorPathStudioErrors => 'Studio-Fehler';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2287,4 +2287,35 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String flowEditorAlreadyExists(String name) {
|
||||
return 'A flow named \"$name\" already exists.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get buttonView => 'View';
|
||||
|
||||
@override
|
||||
String logViewerTitle(String title, int count) {
|
||||
return '$title — last $count lines';
|
||||
}
|
||||
|
||||
@override
|
||||
String get logViewerEmpty => 'Log file is empty or does not exist yet.';
|
||||
|
||||
@override
|
||||
String get logViewerCopyAll => 'Copy all';
|
||||
|
||||
@override
|
||||
String get logViewerOpenExternal => 'Open in default editor';
|
||||
|
||||
@override
|
||||
String logViewerLineCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count lines',
|
||||
one: '1 line',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get doctorPathStudioErrors => 'Studio errors';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// and quick-glance proof that you're seeing the current build.
|
||||
const String kStudioVersion = '0.54.0';
|
||||
const String kStudioVersion = '0.62.0';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -53,21 +54,16 @@ class _AuditPageState extends State<AuditPage> {
|
|||
_refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
|
||||
showFaiErrorSnack(context, 'audit.clear', e);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendly(Object e) {
|
||||
final s = e.toString();
|
||||
// The hub returns FailedPrecondition for compliance-locked
|
||||
// channels; tonic stringifies that as "failed_precondition:
|
||||
// …". Strip the noise so the operator sees the real reason.
|
||||
final marker = 'failed_precondition: ';
|
||||
final idx = s.toLowerCase().indexOf(marker);
|
||||
return idx >= 0 ? s.substring(idx + marker.length) : s;
|
||||
}
|
||||
// _friendly was an inline shadow of friendlyError() in
|
||||
// data/friendly_error.dart — kept only because the original
|
||||
// call site predated the central mapper. Removed in favour
|
||||
// of showFaiErrorSnack, which uses friendlyError under the
|
||||
// hood (with proper gRPC-code mapping + selectable
|
||||
// copy-affordance).
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/fai_log.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
|
@ -343,6 +345,12 @@ class _DaemonPathsPanel extends StatelessWidget {
|
|||
// Explorer instead of failing the OS "open" handler.
|
||||
final entries = <(String, String, IconData, bool)>[
|
||||
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
|
||||
(
|
||||
l.doctorPathStudioErrors,
|
||||
FaiLog.instance.path,
|
||||
Icons.report_problem_outlined,
|
||||
false,
|
||||
),
|
||||
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
|
||||
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
|
||||
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
|
||||
|
|
@ -387,6 +395,8 @@ class _PathRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final isLog = !isDirectory && path.toLowerCase().endsWith('.log');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
|
|
@ -412,6 +422,18 @@ class _PathRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
if (isLog) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
showFaiLogViewer(context, path: path, title: label),
|
||||
icon: const Icon(Icons.visibility_outlined, size: 14),
|
||||
label: Text(l.buttonView),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final r = await SystemActions.openOrReveal(
|
||||
|
|
@ -420,9 +442,10 @@ class _PathRow extends StatelessWidget {
|
|||
);
|
||||
if (!context.mounted) return;
|
||||
if (!r.ok) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.doctorOpenError(r.stderr))),
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
'doctor.open-path',
|
||||
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -430,7 +453,7 @@ class _PathRow extends StatelessWidget {
|
|||
isDirectory ? Icons.folder_open : Icons.open_in_new,
|
||||
size: 14,
|
||||
),
|
||||
label: Text(AppLocalizations.of(context)!.buttonOpen),
|
||||
label: Text(l.buttonOpen),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../data/today_story_loader.dart';
|
||||
|
|
@ -310,19 +311,16 @@ class _StorePageState extends State<StorePage> {
|
|||
_source.isEmpty &&
|
||||
!_installedOnly;
|
||||
final hasNoFederation = !raw.any((e) => e.isFederated);
|
||||
// Past-onboarding signal: the operator has
|
||||
// 3+ modules installed (wasm + federated
|
||||
// combined). At that point the editorial
|
||||
// hero stops earning its scroll cost — they
|
||||
// know what F∆I does. They can still bring
|
||||
// it back via the "Tour" button (TODO) or
|
||||
// by clearing _todayDismissed in prefs.
|
||||
final installedCount = raw
|
||||
.where((e) => e.installed)
|
||||
.length;
|
||||
final pastOnboarding = installedCount >= 3;
|
||||
final showToday =
|
||||
!_todayDismissed && isBrowsing && !pastOnboarding;
|
||||
// Today carousel stays visible until the
|
||||
// operator explicitly dismisses it. The earlier
|
||||
// installedCount≥3 auto-hide killed the strip
|
||||
// for anyone past first-launch — meaning the
|
||||
// rotating editorial stories (themes, audit,
|
||||
// SDK, sandbox…) were never seen by experienced
|
||||
// operators, which is exactly the audience the
|
||||
// wider story set is for. One-click dismiss is
|
||||
// enough opt-out.
|
||||
final showToday = !_todayDismissed && isBrowsing;
|
||||
// Recommended-source quick-adds are inlined in
|
||||
// the Today footer when both apply, so the
|
||||
// operator never sees two competing editorial
|
||||
|
|
@ -385,22 +383,24 @@ class _StorePageState extends State<StorePage> {
|
|||
: null,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta:
|
||||
onCta: _ctaCallbackFor(
|
||||
_todayStories[_todayIndex % _todayStories.length],
|
||||
),
|
||||
// Per-story brand chips: only the MCP
|
||||
// story shows DeepWiki / Semgrep
|
||||
// quick-adds, so the chips don't read
|
||||
// as ads on stories that aren't about
|
||||
// sources. Standalone "Suggested
|
||||
// sources" strip below still catches
|
||||
// the dismissed-Today / no-federation
|
||||
// edge case.
|
||||
recommendedSources:
|
||||
_todayStories[_todayIndex %
|
||||
_todayStories.length]
|
||||
.cta ==
|
||||
TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
: null,
|
||||
// Quick-add chips removed from the
|
||||
// editorial hero — they showed the
|
||||
// same DeepWiki / Semgrep names every
|
||||
// session, which felt like ads. Public
|
||||
// sources still live one click away
|
||||
// under Settings → MCP Clients, and
|
||||
// the standalone "Suggested sources"
|
||||
// strip below picks them up.
|
||||
recommendedSources: const <_RecommendedSource>[],
|
||||
TodayCta.showSuggestedSources
|
||||
? _kRecommendedSources
|
||||
: const <_RecommendedSource>[],
|
||||
recommendedAdding: _addingSources,
|
||||
onAddRecommended: _addRecommendedSource,
|
||||
),
|
||||
|
|
@ -554,6 +554,47 @@ class _StorePageState extends State<StorePage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve a Today story's CTA descriptor into a real callback
|
||||
/// the hero card can wire to its button. `null` collapses the
|
||||
/// button out of the layout — used for narrative-only stories
|
||||
/// (audit / sandbox explainer) where there's nowhere obvious
|
||||
/// to send the operator next.
|
||||
VoidCallback? _ctaCallbackFor(TodayStoryData story) {
|
||||
switch (story.cta) {
|
||||
case TodayCta.none:
|
||||
return null;
|
||||
case TodayCta.openSettings:
|
||||
return () => FaiSettingsDialog.show(context);
|
||||
case TodayCta.filterCategory:
|
||||
if (story.ctaPayload.isEmpty) return null;
|
||||
return () {
|
||||
// Two-step refresh: setState makes the dropdown
|
||||
// header re-render with the new selection, then
|
||||
// _runSearch re-issues the backend query with
|
||||
// `category:` in its filter — without the second
|
||||
// call the grid keeps showing the unfiltered list
|
||||
// and the carousel button looked broken.
|
||||
setState(() => _category = story.ctaPayload);
|
||||
_runSearch();
|
||||
};
|
||||
case TodayCta.runQuery:
|
||||
if (story.ctaPayload.isEmpty) return null;
|
||||
return () {
|
||||
_queryCtrl.text = story.ctaPayload;
|
||||
setState(() {});
|
||||
_runSearch();
|
||||
};
|
||||
case TodayCta.showSuggestedSources:
|
||||
// Stays on this slide — the chips render in the hero
|
||||
// footer for stories with this CTA. The button just
|
||||
// scrolls focus to them; for now the chips are
|
||||
// immediately visible above the fold, so the click is
|
||||
// effectively a no-op acknowledgement. Keeping it
|
||||
// wired avoids a dead button.
|
||||
return () {};
|
||||
}
|
||||
}
|
||||
|
||||
void _clearQuery() {
|
||||
setState(() {
|
||||
_queryCtrl.clear();
|
||||
|
|
@ -669,10 +710,11 @@ class _StorePageState extends State<StorePage> {
|
|||
_runSearch();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l.storeProviderAddFailed(s.label, e.toString())),
|
||||
),
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
'store.provider.add',
|
||||
e,
|
||||
title: l.storeProviderAddFailed(s.label, ''),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _addingSources.remove(s.name));
|
||||
|
|
@ -2143,9 +2185,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
final r = await SystemActions.openInOs(widget.item.repository);
|
||||
if (!mounted) return;
|
||||
if (!r.ok) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.storeOpenBrowserFailed(r.stderr))),
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
'store.repository.open',
|
||||
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2926,18 +2969,18 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
|||
/// on the dedicated Welcome page, not in the store carousel.
|
||||
const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
||||
TodayStoryData(
|
||||
badgeEn: 'MODULES',
|
||||
badgeDe: 'MODULE',
|
||||
titleEn: 'Modules are the building blocks',
|
||||
titleDe: 'Module sind die Bausteine',
|
||||
badgeEn: 'MCP',
|
||||
badgeDe: 'MCP',
|
||||
titleEn: 'Plug in any MCP server',
|
||||
titleDe: 'Jeden MCP-Server einbinden',
|
||||
bodyEn:
|
||||
'Each tile in the grid below is a sandboxed WASM module. Pick one, hit Install, and the hub adds it to your local registry. Modules declare their permissions up front; the hub denies anything else.',
|
||||
'F∆I speaks the Model Context Protocol out of the box. Add a public server (DeepWiki, Semgrep) or a private one — its tools show up in this grid next to the built-in modules. Same install button, same audit trail.',
|
||||
bodyDe:
|
||||
'Jede Kachel unten ist ein sandboxes WASM-Modul. Auswählen, Installieren klicken — der Hub fügt es deiner lokalen Registry hinzu. Module deklarieren ihre Berechtigungen explizit; der Hub verweigert alles andere.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.extension_outlined,
|
||||
cta: TodayCta.none,
|
||||
'F∆I spricht das Model Context Protocol von Haus aus. Einen öffentlichen Server (DeepWiki, Semgrep) oder einen eigenen hinzufügen — seine Tools tauchen in diesem Grid neben den eingebauten Modulen auf. Gleicher Install-Button, gleiche Audit-Spur.',
|
||||
ctaLabelEn: 'Browse suggested sources',
|
||||
ctaLabelDe: 'Empfohlene Quellen ansehen',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: TodayCta.showSuggestedSources,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'FLOWS',
|
||||
|
|
@ -2948,24 +2991,97 @@ const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
|
|||
'A flow is a YAML file with three keys — inputs, steps, outputs. Each step calls one module. Every run lands in the audit log with a verifiable hash chain. Build flows graphically or write the YAML directly — either way, the run is reproducible.',
|
||||
bodyDe:
|
||||
'Ein Flow ist eine YAML-Datei mit drei Schlüsseln — inputs, steps, outputs. Jeder Schritt ruft ein Modul auf. Jeder Lauf landet im Audit-Log mit verifizierbarer Hash-Kette. Flows grafisch bauen oder direkt YAML schreiben — der Lauf bleibt reproduzierbar.',
|
||||
ctaLabelEn: 'Show featured flows',
|
||||
ctaLabelDe: 'Featured-Flows zeigen',
|
||||
icon: Icons.account_tree_outlined,
|
||||
cta: TodayCta.runQuery,
|
||||
ctaPayload: 'hello',
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'THEMES',
|
||||
badgeDe: 'THEMES',
|
||||
titleEn: 'Make Studio yours — pick a theme',
|
||||
titleDe: 'Studio nach deinem Geschmack — Theme wählen',
|
||||
bodyEn:
|
||||
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio plugins re-skin the whole app at runtime. Or pick a single accent colour and let Material 3 derive the rest. Browse the studio-plugin category for the full set.',
|
||||
bodyDe:
|
||||
'Solarized, Sunflower, Space, Sunset, Glass-Apple, Accessibility — Studio-Plugins themen die App zur Laufzeit komplett um. Oder eine einzelne Akzentfarbe wählen und Material 3 leitet den Rest ab. Die Kategorie Studio-Plugin zeigt das volle Set.',
|
||||
ctaLabelEn: 'Show themes',
|
||||
ctaLabelDe: 'Themes anzeigen',
|
||||
icon: Icons.palette_outlined,
|
||||
cta: TodayCta.filterCategory,
|
||||
ctaPayload: 'studio-plugin',
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'TEXT TOOLS',
|
||||
badgeDe: 'TEXT-TOOLS',
|
||||
titleEn: 'Extract, anonymise, translate, summarise',
|
||||
titleDe: 'Extrahieren, anonymisieren, übersetzen, zusammenfassen',
|
||||
bodyEn:
|
||||
'The text-processing bundle is F∆I\'s document-analysis backbone. PDF and DOCX in, plain text out. Then anonymise PII, translate, or summarise — every step audit-grade, every Ollama call recorded with model digest.',
|
||||
bodyDe:
|
||||
'Das Text-Processing-Bündel ist F∆Is Dokumentenanalyse-Rückgrat. PDF und DOCX rein, Klartext raus. Dann PII anonymisieren, übersetzen oder zusammenfassen — jeder Schritt audit-tauglich, jeder Ollama-Aufruf mit Modell-Digest protokolliert.',
|
||||
ctaLabelEn: 'Show text modules',
|
||||
ctaLabelDe: 'Text-Module zeigen',
|
||||
icon: Icons.article_outlined,
|
||||
cta: TodayCta.filterCategory,
|
||||
ctaPayload: 'text-processing',
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'SANDBOX',
|
||||
badgeDe: 'SANDBOX',
|
||||
titleEn: 'Every module declares its permissions',
|
||||
titleDe: 'Jedes Modul deklariert seine Berechtigungen',
|
||||
bodyEn:
|
||||
'F∆I modules are WASM components. They run in a sandbox with no ambient authority — every filesystem path, every network host, every env var has to be listed in module.yaml. The hub enforces them. No declaration, no access.',
|
||||
bodyDe:
|
||||
'F∆I-Module sind WASM-Komponenten. Sie laufen in einer Sandbox ohne Ambient-Authority — jeder Dateipfad, jeder Netzwerk-Host, jede Env-Var muss in module.yaml stehen. Der Hub setzt es durch. Keine Deklaration, kein Zugriff.',
|
||||
ctaLabelEn: 'Review permissions',
|
||||
ctaLabelDe: 'Berechtigungen prüfen',
|
||||
icon: Icons.shield_outlined,
|
||||
cta: TodayCta.openSettings,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'AUDIT',
|
||||
badgeDe: 'AUDIT',
|
||||
titleEn: 'Tamper-evident hash chain — built in',
|
||||
titleDe: 'Manipulationssicher per Hash-Kette — eingebaut',
|
||||
bodyEn:
|
||||
'Every flow run, every install, every approval lands in ~/.fai/audit/ as a hash-chained event log. Any later edit invalidates the chain. CRA-ready out of the box — no compliance product to buy on top.',
|
||||
bodyDe:
|
||||
'Jeder Flow-Lauf, jede Installation, jede Freigabe landet in ~/.fai/audit/ als hash-verkettetes Event-Log. Jede spätere Änderung bricht die Kette. CRA-tauglich von Haus aus — kein Compliance-Produkt zum Aufkaufen nötig.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.account_tree_outlined,
|
||||
icon: Icons.timeline_outlined,
|
||||
cta: TodayCta.none,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'EXTENSIBILITY',
|
||||
badgeDe: 'ERWEITERBAR',
|
||||
titleEn: 'Bring your own sources',
|
||||
titleDe: 'Eigene Quellen anbinden',
|
||||
badgeEn: 'TEST FIRST',
|
||||
badgeDe: 'ZUERST TESTEN',
|
||||
titleEn: 'fai doctor — diagnose before you ship',
|
||||
titleDe: 'fai doctor — diagnostizieren bevor ausgerollt wird',
|
||||
bodyEn:
|
||||
'The hub federates external module sources — public MCP servers, n8n workflows, your own internal registry. Configure a source under Settings → MCP Clients and its tools appear in this store alongside the built-in modules. Same install button, same audit trail.',
|
||||
'Before installing a module on a production hub, run `fai doctor` in the CLI. It validates manifest, signatures, declared permissions against the operator policy ceiling, and reports anything the hub would reject — no surprises at install time.',
|
||||
bodyDe:
|
||||
'Der Hub bindet externe Modul-Quellen ein — öffentliche MCP-Server, n8n-Workflows, eigene interne Registries. Eine Quelle unter Einstellungen → MCP-Clients konfigurieren, und ihre Tools erscheinen hier im Store neben den eingebauten Modulen. Gleicher Install-Button, gleiche Audit-Spur.',
|
||||
ctaLabelEn: 'Manage sources',
|
||||
ctaLabelDe: 'Quellen verwalten',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: TodayCta.openSettings,
|
||||
'Bevor ein Modul auf einem Produktions-Hub installiert wird, in der CLI `fai doctor` laufen lassen. Validiert Manifest, Signaturen und deklarierte Berechtigungen gegen die Operator-Policy-Decke — keine Überraschungen beim Install.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.health_and_safety_outlined,
|
||||
cta: TodayCta.none,
|
||||
),
|
||||
TodayStoryData(
|
||||
badgeEn: 'BUILD ONE',
|
||||
badgeDe: 'EIGENES MODUL',
|
||||
titleEn: 'Build a module in a single Rust file',
|
||||
titleDe: 'Ein Modul in einer einzigen Rust-Datei bauen',
|
||||
bodyEn:
|
||||
'fai-module-sdk gives you a #[fai::module] macro and a Cargo target. Declare inputs / outputs in module.yaml, write the function, cargo build, drop the wasm in ~/.fai/modules. The hub picks it up at next list. Polyglot SDKs follow in Phase 1.',
|
||||
bodyDe:
|
||||
'fai-module-sdk liefert ein #[fai::module]-Makro und ein Cargo-Target. Inputs / Outputs in module.yaml deklarieren, Funktion schreiben, cargo build, das wasm in ~/.fai/modules legen. Der Hub erkennt es beim nächsten Listing. Polyglott-SDKs folgen in Phase 1.',
|
||||
ctaLabelEn: '',
|
||||
ctaLabelDe: '',
|
||||
icon: Icons.code_outlined,
|
||||
cta: TodayCta.none,
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -720,6 +720,7 @@ class _ChecklistRow extends StatelessWidget {
|
|||
final bool done;
|
||||
final String title;
|
||||
final String hint;
|
||||
|
||||
/// Tap handler — takes the operator to the page where
|
||||
/// they can actually complete the item. null = no-op
|
||||
/// (e.g. AI item when System AI was already configured).
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'package:file_picker/file_picker.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/flow_output.dart';
|
||||
import '../data/format.dart';
|
||||
import '../data/system_actions.dart';
|
||||
|
|
@ -172,9 +173,7 @@ class _BytesView extends StatelessWidget {
|
|||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
|
||||
showFaiErrorSnack(context, 'flows.output.save', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,10 +234,11 @@ class _FileView extends StatelessWidget {
|
|||
final target = uri.startsWith('file://') ? uri.substring(7) : uri;
|
||||
final r = await SystemActions.openInOs(target);
|
||||
if (!context.mounted || r.ok) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
showFaiErrorSnack(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
|
||||
'flows.output.open',
|
||||
r.stderr.isEmpty ? 'open failed' : r.stderr,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
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>[];
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -104,9 +105,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _uninstalling = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.modulesUninstallFailed(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'modules.uninstall', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import 'package:fai_client_sdk/fai_client_sdk.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/hub_auth_token.dart';
|
||||
import '../data/registry_token.dart';
|
||||
|
|
@ -92,10 +93,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'auth.hub-token.save', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,10 +109,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'auth.hub-token.clear', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,10 +134,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'auth.registry-token.save', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,10 +149,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'auth.registry-token.clear', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,10 +179,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
||||
showFaiErrorSnack(context, 'settings.n8n.add', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,10 +190,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _n8nEndpoints = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'settings.n8n.remove', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,10 +201,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _n8nEndpoints = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'settings.n8n.refresh', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -251,10 +231,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
||||
showFaiErrorSnack(context, 'settings.mcp.add', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,10 +242,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _mcpClients = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'settings.mcp.remove', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,10 +253,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _mcpClients = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'settings.mcp.refresh', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -249,10 +250,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.systemAiCacheClearFailed(e.toString()))),
|
||||
);
|
||||
showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ class _Tile extends StatelessWidget {
|
|||
final List<Color> swatches;
|
||||
final bool selected;
|
||||
final bool loading;
|
||||
|
||||
/// Adds a small tune icon at the top-right, telegraphing
|
||||
/// "this tile opens an editor instead of applying
|
||||
/// immediately". Used for the Custom tile so the operator
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export 'fai_empty_state.dart';
|
|||
export 'fai_en_badge.dart';
|
||||
export 'fai_error_box.dart';
|
||||
export 'fai_flow_output.dart';
|
||||
export 'fai_log_viewer.dart';
|
||||
export 'fai_module_sheet.dart';
|
||||
export 'fai_pill.dart';
|
||||
export 'fai_settings_dialog.dart';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue