Files
kaptan/ground-suite/lib/features/diagnostics/presentation/diagnostics_page.dart
T

357 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/observations/observed_event.dart';
import '../../../shared/widgets/severity_badge.dart';
import '../../../shared/widgets/surfaces.dart';
import '../../observability/presentation/observability_controller.dart';
enum _DiagnosticFilter { all, logs, errors, shutdowns }
class DiagnosticsPage extends ConsumerStatefulWidget {
const DiagnosticsPage({super.key});
@override
ConsumerState<DiagnosticsPage> createState() => _DiagnosticsPageState();
}
class _DiagnosticsPageState extends ConsumerState<DiagnosticsPage> {
_DiagnosticFilter _filter = _DiagnosticFilter.all;
String _query = '';
@override
Widget build(BuildContext context) {
final state = ref.watch(observabilityControllerProvider);
final diagnostics = state.events
.where(_isDiagnostic)
.toList(growable: false);
final events = diagnostics.where(_matches).toList(growable: false);
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 16),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ConsoleLabel('Event stream'),
const SizedBox(height: 8),
Text(
'Event timeline',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Read-only decoded observations. Diagnostic delivery can be bounded and lossy.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Retained diagnostics ${state.events.length} · local records not retained ${state.localTimelineEvictionCount}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
const SizedBox(height: 20),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final filter in _DiagnosticFilter.values)
ChoiceChip(
label: Text(_filterLabel(filter, diagnostics)),
selected: _filter == filter,
onSelected: (_) => setState(() => _filter = filter),
),
],
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Search message, error path, or detail',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
setState(() => _query = value.trim().toLowerCase());
},
),
],
),
),
),
if (events.isEmpty)
const SliverPadding(
padding: EdgeInsets.fromLTRB(28, 0, 28, 28),
sliver: SliverToBoxAdapter(
child: InstrumentPanel(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No events match this view.'),
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
sliver: SliverList.builder(
itemCount: events.length,
itemBuilder: (context, index) =>
_DiagnosticRow(event: events[index]),
),
),
],
);
}
bool _matches(ObservedEvent event) {
final typeMatches = switch (_filter) {
_DiagnosticFilter.all => true,
_DiagnosticFilter.logs => event is LogEvent,
_DiagnosticFilter.errors => event is ErrorEvent,
_DiagnosticFilter.shutdowns => event is ShutdownEvent,
};
return typeMatches &&
(_query.isEmpty ||
_searchableText(event).toLowerCase().contains(_query));
}
}
class _DiagnosticRow extends StatelessWidget {
const _DiagnosticRow({required this.event});
final ObservedEvent event;
@override
Widget build(BuildContext context) {
final content = _contentFor(
event,
Theme.of(context).brightness == Brightness.dark,
);
return Semantics(
container: true,
label: content.semanticLabel,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InstrumentPanel(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 3, height: 56, color: content.tone),
const SizedBox(width: 12),
SizedBox(
width: 92,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConsoleLabel(content.kind, color: content.tone),
const SizedBox(height: 6),
Text(
_timelineTimestamp(event.receivedAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(width: 8),
content.icon,
const SizedBox(width: 10),
Expanded(
child: SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content.title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
content.detail,
style: content.monoDetail
? Theme.of(context).textTheme.bodyMedium
?.copyWith(fontFamily: 'monospace')
: null,
),
if (content.additionalDetail case final detail?) ...[
const SizedBox(height: 8),
Text(
detail,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
],
),
),
),
),
);
}
}
class _DiagnosticContent {
const _DiagnosticContent({
required this.kind,
required this.tone,
required this.icon,
required this.title,
required this.detail,
required this.semanticLabel,
this.additionalDetail,
this.monoDetail = false,
});
final String kind;
final Color tone;
final Widget icon;
final String title;
final String detail;
final String? additionalDetail;
final String semanticLabel;
final bool monoDetail;
}
_DiagnosticContent _contentFor(
ObservedEvent event,
bool dark,
) => switch (event) {
LogEvent(:final level, :final message, :final flags) => _DiagnosticContent(
kind: 'Log',
tone: _toneForSeverity(level, dark),
icon: SeverityBadge(level: level),
title: level.label,
detail: message,
additionalDetail: _formatLogFlags(flags),
semanticLabel: _semanticLogLabel(level, message, flags),
),
ErrorEvent(:final path, :final additionalInformation, :final flags) =>
_DiagnosticContent(
kind: 'Error',
tone: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
icon: Icon(
Icons.error_outline,
color: dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
),
title: 'Runtime error observed',
detail: path,
additionalDetail: _formatErrorDetail(additionalInformation, flags),
semanticLabel: _semanticErrorLabel(path, additionalInformation, flags),
monoDetail: true,
),
ShutdownEvent(:final reason) => _DiagnosticContent(
kind: 'Shutdown',
tone: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
icon: Icon(
Icons.power_settings_new_outlined,
color: dark ? const Color(0xfff7b955) : const Color(0xffb54708),
),
title: 'Orderly shutdown report',
detail: 'Reason: ${reason.label}',
additionalDetail:
'Completed orderly, output-inhibited report; not a command, panic, hard-fault, or reset.',
semanticLabel: 'Orderly shutdown report: ${reason.label}',
),
_ => throw StateError('only diagnostic events reach the timeline'),
};
bool _isDiagnostic(ObservedEvent event) =>
event is LogEvent || event is ErrorEvent || event is ShutdownEvent;
String _filterLabel(_DiagnosticFilter filter, List<ObservedEvent> events) {
final count = switch (filter) {
_DiagnosticFilter.all => events.length,
_DiagnosticFilter.logs => events.whereType<LogEvent>().length,
_DiagnosticFilter.errors => events.whereType<ErrorEvent>().length,
_DiagnosticFilter.shutdowns => events.whereType<ShutdownEvent>().length,
};
final label = switch (filter) {
_DiagnosticFilter.all => 'All events',
_DiagnosticFilter.logs => 'Logs',
_DiagnosticFilter.errors => 'Errors',
_DiagnosticFilter.shutdowns => 'Shutdown reports',
};
return '$label $count';
}
String _searchableText(ObservedEvent event) => switch (event) {
LogEvent(:final level, :final message) => '${level.label} $message',
ErrorEvent(:final path, :final additionalInformation) =>
'$path $additionalInformation',
ShutdownEvent(:final reason) => reason.label,
_ => '',
};
Color _toneForSeverity(MavSeverity level, bool dark) =>
switch (level.wireValue) {
0 ||
1 ||
2 ||
3 => dark ? const Color(0xffff6b63) : const Color(0xffd92d20),
4 => dark ? const Color(0xfff7b955) : const Color(0xffb54708),
5 || 6 => dark ? const Color(0xff70b8ff) : const Color(0xff175cd3),
7 => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
_ => dark ? const Color(0xffa8b5ac) : const Color(0xff546258),
};
String? _formatLogFlags(int flags) {
final labels = <String>[
if (flags & LogEvent.traceFlag != 0) 'Trace',
if (flags & LogEvent.truncatedFlag != 0) 'Truncated',
if (flags & LogEvent.coalescedFlag != 0) 'Coalesced',
if (flags & ~LogEvent.knownFlags != 0)
'Unknown bits 0x${(flags & ~LogEvent.knownFlags).toRadixString(16)}',
];
return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
}
String? _formatErrorDetail(String additionalInformation, int flags) {
final flagDetail = _formatErrorFlags(flags);
final details = <String>[
if (additionalInformation.isNotEmpty) additionalInformation,
?flagDetail,
];
return details.isEmpty ? null : details.join('\n');
}
String? _formatErrorFlags(int flags) {
final labels = <String>[
if (flags & ErrorEvent.truncatedFlag != 0) 'Truncated',
if (flags & ErrorEvent.coalescedFlag != 0) 'Coalesced',
if (flags & ~ErrorEvent.knownFlags != 0)
'Unknown bits 0x${(flags & ~ErrorEvent.knownFlags).toRadixString(16)}',
];
return labels.isEmpty ? null : 'Flags: ${labels.join(' · ')}';
}
String _semanticLogLabel(MavSeverity level, String message, int flags) {
final flagDetail = _formatLogFlags(flags);
return flagDetail == null
? '${level.label} log: $message'
: '${level.label} log: $message. $flagDetail';
}
String _semanticErrorLabel(
String path,
String additionalInformation,
int flags,
) {
final detail = _formatErrorDetail(additionalInformation, flags);
return detail == null ? 'Error: $path' : 'Error: $path. $detail';
}
String _timelineTimestamp(DateTime value) =>
'${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}Z';