Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
449
lib/Peach/see/logs/LogsState.dart
Normal file
449
lib/Peach/see/logs/LogsState.dart
Normal file
@@ -0,0 +1,449 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../comms/Comms.dart' show LogLine;
|
||||
import '../../designs/LogConsole.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import 'ConnectLogs.dart';
|
||||
import 'Logs.dart';
|
||||
import 'ViewLogs.dart';
|
||||
|
||||
class LogsState extends State<Logs> implements ConnectLogs {
|
||||
ViewLogs? _model;
|
||||
|
||||
StreamSubscription<LogLine>? _logSub;
|
||||
final _lines = <LogLine>[];
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
bool _loading = true;
|
||||
bool _following = false;
|
||||
bool _busy = false;
|
||||
String _status = 'unknown';
|
||||
String _query = '';
|
||||
DateTimeRange? _range;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
ServiceConfig get _service => widget.service;
|
||||
|
||||
List<LogLine> get _filtered {
|
||||
if (_query.trim().isEmpty) return _lines;
|
||||
final q = _query.toLowerCase();
|
||||
return _lines.where((l) => l.text.toLowerCase().contains(q)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSub?.cancel();
|
||||
_model?.stopLogs();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewLogs>.reactive(
|
||||
viewModelBuilder: () => ViewLogs(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'${_service.name} · logs',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _range == null ? 'Filter by date' : 'Change date range',
|
||||
onPressed: _busy ? null : _onPickRange,
|
||||
icon: Icon(
|
||||
Icons.date_range,
|
||||
color: _range != null ? PeachTheme.seed : null,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download logs',
|
||||
onPressed: _onDownload,
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _loading || _busy ? null : _onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_header(),
|
||||
_controlBar(),
|
||||
_searchBar(),
|
||||
if (_loading || _busy) const LinearProgressIndicator(minHeight: 3),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
|
||||
child: LogConsole(
|
||||
logs: _filtered,
|
||||
height: null,
|
||||
live: _following,
|
||||
totalCount: _query.isEmpty ? null : _lines.length,
|
||||
emptyMessage: _loading
|
||||
? 'Loading…'
|
||||
: (_query.isNotEmpty ? 'No matches.' : 'No log output.'),
|
||||
onClear: _onClearBacklog,
|
||||
onCopy: _onCopy,
|
||||
),
|
||||
),
|
||||
),
|
||||
_bottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewLogs model) {
|
||||
_model = model;
|
||||
// onViewModelReady fires mid-build; a synchronous setState from the
|
||||
// first load would dirty this State while it is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _initiate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initiate() async {
|
||||
_logSub = _model?.logs.listen(_onLogLine);
|
||||
_model?.fetchLogs(_server, _service);
|
||||
_model?.loadStatus(_server, _service);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
// ── status helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
bool get _isActive =>
|
||||
_status.contains('active') && !_status.contains('inactive');
|
||||
|
||||
bool get _hasUnit => _status != 'no-unit';
|
||||
|
||||
(Color, IconData) get _statusStyle {
|
||||
if (_status.contains('failed')) return (PeachTheme.danger, Icons.error);
|
||||
if (_isActive) return (PeachTheme.seed, Icons.play_circle);
|
||||
if (_status.contains('activating')) {
|
||||
return (PeachTheme.warn, Icons.hourglass_top);
|
||||
}
|
||||
return (context.jp.textFaint, Icons.stop_circle);
|
||||
}
|
||||
|
||||
String _unitLabel() {
|
||||
if (_service.isWeb) {
|
||||
final unit = _service.nginxService.isEmpty
|
||||
? 'nginx'
|
||||
: _service.nginxService;
|
||||
return 'nginx: $unit';
|
||||
}
|
||||
return _service.serviceName.isEmpty
|
||||
? '(no unit)'
|
||||
: 'unit: ${_service.serviceName}';
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRefresh() {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_following = false;
|
||||
_lines.clear();
|
||||
});
|
||||
_model?.fetchLogs(
|
||||
_server,
|
||||
_service,
|
||||
since: _range?.start,
|
||||
until: _range?.end,
|
||||
);
|
||||
}
|
||||
|
||||
void _onStart() => _control('start');
|
||||
|
||||
void _onStop() => _control('stop');
|
||||
|
||||
void _onRestart() => _control('restart');
|
||||
|
||||
void _control(String action) {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_following = false;
|
||||
});
|
||||
_model?.control(_server, _service, action);
|
||||
}
|
||||
|
||||
Future<void> _onPickRange() async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 3),
|
||||
lastDate: now,
|
||||
initialDateRange: _range,
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(
|
||||
() => _range = DateTimeRange(
|
||||
start: DateTime(
|
||||
picked.start.year,
|
||||
picked.start.month,
|
||||
picked.start.day,
|
||||
),
|
||||
end: DateTime(
|
||||
picked.end.year,
|
||||
picked.end.month,
|
||||
picked.end.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
),
|
||||
),
|
||||
);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
void _onClearRange() {
|
||||
setState(() => _range = null);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
void _onDownload() {
|
||||
final lines = _filtered.map((l) => l.text).toList();
|
||||
if (lines.isEmpty) {
|
||||
_notify('Nothing to download.');
|
||||
return;
|
||||
}
|
||||
_model?.saveLogs(_service.name, lines);
|
||||
}
|
||||
|
||||
void _onToggleFollow() => _model?.toggleFollow(_server, _service, _following);
|
||||
|
||||
void _onClearBacklog() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopy() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _filtered.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _onSearchChanged(String v) => setState(() => _query = v);
|
||||
|
||||
void _onClearSearch() {
|
||||
_searchController.clear();
|
||||
setState(() => _query = '');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectLogs ────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onLogsFetched() {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void onStatusLoaded(String status) {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = status);
|
||||
}
|
||||
|
||||
@override
|
||||
void onControlFinished(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
_notify(message);
|
||||
}
|
||||
|
||||
@override
|
||||
void onFollowChanged(bool following) {
|
||||
if (!mounted) return;
|
||||
setState(() => _following = following);
|
||||
}
|
||||
|
||||
@override
|
||||
void onLogsSaved(String? path) {
|
||||
_notify(path == null ? 'Download cancelled' : 'Saved to $path');
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _header() {
|
||||
final jp = context.jp;
|
||||
final (color, icon) = _statusStyle;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.dns, size: 15, color: PeachTheme.seed),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: text(
|
||||
'${_server.username}@${_server.host} · ${_unitLabel()}',
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: color),
|
||||
const SizedBox(width: 5),
|
||||
text(
|
||||
_status.toUpperCase(),
|
||||
10.5,
|
||||
TextType.Bold,
|
||||
color: color,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _controlBar() {
|
||||
if (!_hasUnit) return const SizedBox.shrink();
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Contextual: Start only when stopped, Stop only when running.
|
||||
if (!_isActive)
|
||||
_ctlButton(Icons.play_arrow, 'Start', PeachTheme.seed, _onStart),
|
||||
if (_isActive)
|
||||
_ctlButton(Icons.stop, 'Stop', PeachTheme.danger, _onStop),
|
||||
_ctlButton(
|
||||
Icons.restart_alt,
|
||||
_service.isWeb ? 'Reload' : 'Restart',
|
||||
PeachTheme.accent,
|
||||
_onRestart,
|
||||
),
|
||||
if (_range != null) _rangeChip(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ctlButton(
|
||||
IconData icon,
|
||||
String label,
|
||||
Color color,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: _busy ? null : onTap,
|
||||
icon: Icon(icon, size: 18),
|
||||
label: text(label, 13, TextType.Bold, color: color),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: color,
|
||||
side: BorderSide(color: color.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rangeChip() {
|
||||
String d(DateTime t) => '${t.month}/${t.day}';
|
||||
return InputChip(
|
||||
avatar: const Icon(Icons.date_range, size: 16),
|
||||
label: Text('${d(_range!.start)} – ${d(_range!.end)}'),
|
||||
onDeleted: _onClearRange,
|
||||
onPressed: _onPickRange,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _searchBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search logs…',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: _query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: _onClearSearch,
|
||||
),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _onToggleFollow,
|
||||
icon: Icon(_following ? Icons.stop_circle : Icons.stream),
|
||||
label: text(
|
||||
_following ? 'Stop live tail' : 'Live tail',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
style: _following
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: PeachTheme.danger,
|
||||
foregroundColor: colorWhite,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user