Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

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