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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue