Files
PeachDeployments/lib/Peach/see/logs/LogsState.dart
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

450 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
),
),
),
);
}
}