450 lines
13 KiB
Dart
450 lines
13 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|