Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
lib/Peach/see/configuration/Configuration.dart
Normal file
16
lib/Peach/see/configuration/Configuration.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'ConfigurationState.dart';
|
||||
|
||||
/// Probes the server's environment (Java, MariaDB, Postgres, nginx, Docker,
|
||||
/// Node, Git), shows what's present, and guides installing what's missing
|
||||
/// using the auto-detected package manager — preview, then confirm.
|
||||
class Configuration extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const Configuration({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<Configuration> createState() => ConfigurationState();
|
||||
}
|
||||
406
lib/Peach/see/configuration/ConfigurationState.dart
Normal file
406
lib/Peach/see/configuration/ConfigurationState.dart
Normal file
@@ -0,0 +1,406 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/EnvCheck.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/StepTracker.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../comms/Comms.dart' show LogLine;
|
||||
import '../../designs/LogConsole.dart';
|
||||
import '../../designs/StepList.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import 'Configuration.dart';
|
||||
import 'ConnectConfiguration.dart';
|
||||
import 'ViewConfiguration.dart';
|
||||
|
||||
class ConfigurationState extends State<Configuration>
|
||||
implements ConnectConfiguration {
|
||||
ViewConfiguration? _model;
|
||||
|
||||
StreamSubscription<LogLine>? _logSub;
|
||||
StreamSubscription<List<DeployStep>>? _stepSub;
|
||||
|
||||
final _lines = <LogLine>[];
|
||||
List<DeployStep> _steps = [];
|
||||
final Map<String, String> _versions = {};
|
||||
|
||||
String _packageManager = 'unknown';
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
String? _busyKey;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSub?.cancel();
|
||||
_stepSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewConfiguration>.reactive(
|
||||
viewModelBuilder: () => ViewConfiguration(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'${_server.name} · configuration',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Re-scan',
|
||||
onPressed: _loading || _busyKey != null ? null : _onRescan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
||||
children: [
|
||||
_summaryCard(),
|
||||
if (_error != null) ...[const SizedBox(height: 12), _errorCard()],
|
||||
const SizedBox(height: 16),
|
||||
text(
|
||||
'ENVIRONMENT',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 720 ? 2 : 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final tool in kEnvTools)
|
||||
SizedBox(width: w, child: _toolCard(tool)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 22),
|
||||
StepList(steps: _steps),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 260,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewConfiguration 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);
|
||||
_stepSub = _model?.steps.listen(_onSteps);
|
||||
_model?.probeEnvironment(_server);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
void _onSteps(List<DeployStep> steps) {
|
||||
if (!mounted) return;
|
||||
setState(() => _steps = steps);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRescan() {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
_model?.probeEnvironment(_server);
|
||||
}
|
||||
|
||||
void _onInstall(EnvTool tool) =>
|
||||
_model?.prepareInstall(tool, _packageManager);
|
||||
|
||||
void _onClearLogs() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopyLogs() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectConfiguration ───────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onEnvironmentProbed(
|
||||
String packageManager,
|
||||
Map<String, String> versions,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_packageManager = packageManager;
|
||||
_versions
|
||||
..clear()
|
||||
..addAll(versions);
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onProbeFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = message;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallCommandReady(EnvTool tool, String command) {
|
||||
final sudoNote = _server.useSudo ? ' (via sudo)' : '';
|
||||
final unitNote = tool.unit == null
|
||||
? ''
|
||||
: '\n\nThen: systemctl enable --now ${tool.unit}';
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.info,
|
||||
'Install ${tool.label}?',
|
||||
'Peach will run$sudoNote:\n\n$command$unitNote',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Install',
|
||||
positiveAction: () => _onConfirmInstall(tool, command),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmInstall(EnvTool tool, String command) {
|
||||
Navigator.pop(context);
|
||||
setState(() {
|
||||
_busyKey = tool.key;
|
||||
_lines.clear();
|
||||
});
|
||||
_model?.installTool(_server, tool, command);
|
||||
}
|
||||
|
||||
@override
|
||||
void onToolInstalled(EnvTool tool, String version) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_versions[tool.key] = version;
|
||||
_busyKey = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _summaryCard() {
|
||||
final jp = context.jp;
|
||||
final present = kEnvTools.where((t) => _installed(t.key)).length;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.seed.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
border: Border.all(
|
||||
color: PeachTheme.seed.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.fact_check, color: PeachTheme.seed),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
_loading
|
||||
? 'Scanning environment…'
|
||||
: '$present / ${kEnvTools.length} tools ready',
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
'${_server.username}@${_server.host}',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_packageManagerChip(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _packageManagerChip() {
|
||||
final jp = context.jp;
|
||||
final known = _packageManager != 'unknown';
|
||||
final color = known ? PeachTheme.accent : jp.textFaint;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inventory_2, size: 13, color: color),
|
||||
const SizedBox(width: 5),
|
||||
text(
|
||||
_loading ? '…' : _packageManager,
|
||||
11.5,
|
||||
TextType.Bold,
|
||||
color: color,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorCard() => Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.danger.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: PeachTheme.danger),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toolCard(EnvTool tool) {
|
||||
final jp = context.jp;
|
||||
final installed = _installed(tool.key);
|
||||
final version = _versions[tool.key]?.trim() ?? '';
|
||||
final busy = _busyKey == tool.key;
|
||||
final color = installed ? PeachTheme.seed : jp.textFaint;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(tool.icon, color: color, size: 20),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(tool.label, 15, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
_loading
|
||||
? 'checking…'
|
||||
: (installed
|
||||
? (version.isEmpty ? 'installed' : version)
|
||||
: 'Not installed'),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: installed ? jp.textDim : jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (_loading)
|
||||
const SizedBox()
|
||||
else if (installed)
|
||||
const Icon(Icons.check_circle, color: PeachTheme.seed)
|
||||
else if (busy)
|
||||
const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busyKey != null ? null : () => _onInstall(tool),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: text('Install', 13, TextType.Bold, color: PeachTheme.seed),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: PeachTheme.seed,
|
||||
side: BorderSide(color: PeachTheme.seed.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/Peach/see/configuration/ConnectConfiguration.dart
Normal file
16
lib/Peach/see/configuration/ConnectConfiguration.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import '../../about/external/data/EnvCheck.dart';
|
||||
|
||||
abstract class ConnectConfiguration {
|
||||
/// The environment scan finished: the detected package manager and the
|
||||
/// version string found for each tool (empty when absent).
|
||||
void onEnvironmentProbed(String packageManager, Map<String, String> versions);
|
||||
|
||||
/// The scan failed.
|
||||
void onProbeFailed(String message);
|
||||
|
||||
/// The exact command that would install [tool], for the user to confirm.
|
||||
void onInstallCommandReady(EnvTool tool, String command);
|
||||
|
||||
/// [tool] finished installing; [version] is its freshly re-probed version.
|
||||
void onToolInstalled(EnvTool tool, String version);
|
||||
}
|
||||
79
lib/Peach/see/configuration/ViewConfiguration.dart
Normal file
79
lib/Peach/see/configuration/ViewConfiguration.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import '../../about/external/data/EnvCheck.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/StepTracker.dart';
|
||||
import '../../comms/Comms.dart' show LogLine;
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectConfiguration.dart';
|
||||
|
||||
class ViewConfiguration extends ParentViewModel {
|
||||
ConnectConfiguration connection;
|
||||
|
||||
ViewConfiguration(super.context, this.connection);
|
||||
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
|
||||
|
||||
/// Detects the package manager, then probes for every tool at once.
|
||||
void probeEnvironment(ServerProfile server) async {
|
||||
if (!await canReach(server, () => probeEnvironment(server))) return;
|
||||
try {
|
||||
final packageManager = await getDataManager().detectPackageManager(
|
||||
server,
|
||||
);
|
||||
final versions = await getDataManager().probeEnvironment(
|
||||
server,
|
||||
kEnvTools.map((t) => '${t.key} ${t.detect}').toList(),
|
||||
);
|
||||
connection.onEnvironmentProbed(packageManager, versions);
|
||||
} catch (error) {
|
||||
connection.onProbeFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the install command for [tool] under [packageManager] and hands it
|
||||
/// back for confirmation — nothing runs on the server until the user agrees.
|
||||
void prepareInstall(EnvTool tool, String packageManager) {
|
||||
if (packageManager == 'unknown') {
|
||||
connection.onProbeFailed(
|
||||
'Unknown package manager — cannot auto-install.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
connection.onInstallCommandReady(
|
||||
tool,
|
||||
installCommandFor(packageManager, tool.packagesFor(packageManager)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Installs [tool], enables its unit where it has one, then re-probes just
|
||||
/// that tool so the card updates without a full re-scan.
|
||||
void installTool(ServerProfile server, EnvTool tool, String command) async {
|
||||
try {
|
||||
await getDataManager().installPackages(
|
||||
server: server,
|
||||
command: command,
|
||||
enableUnit: tool.unit,
|
||||
);
|
||||
var version = '';
|
||||
try {
|
||||
final probed = await getDataManager().probeEnvironment(server, [
|
||||
'${tool.key} ${tool.detect}',
|
||||
]);
|
||||
version = probed[tool.key] ?? '';
|
||||
} catch (_) {
|
||||
// The install itself succeeded; a failed re-probe just leaves the
|
||||
// version blank until the next full scan.
|
||||
}
|
||||
connection.onToolInstalled(tool, version);
|
||||
} catch (error) {
|
||||
connection.onToolInstalled(tool, '');
|
||||
handleError(
|
||||
error,
|
||||
() => installTool(server, tool, command),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/Peach/see/deploy/batch/BatchDeploy.dart
Normal file
15
lib/Peach/see/deploy/batch/BatchDeploy.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'BatchDeployState.dart';
|
||||
|
||||
/// Batch "build all & deploy": pick which services to include, choose a mode,
|
||||
/// and run them one after another with live per-service status.
|
||||
class BatchDeploy extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const BatchDeploy({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<BatchDeploy> createState() => BatchDeployState();
|
||||
}
|
||||
549
lib/Peach/see/deploy/batch/BatchDeployState.dart
Normal file
549
lib/Peach/see/deploy/batch/BatchDeployState.dart
Normal file
@@ -0,0 +1,549 @@
|
||||
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/StepTracker.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../comms/Comms.dart' show LogLine;
|
||||
import '../../../designs/LogConsole.dart';
|
||||
import '../../../designs/StepList.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import 'BatchDeploy.dart';
|
||||
import 'ConnectBatchDeploy.dart';
|
||||
import 'ViewBatchDeploy.dart';
|
||||
|
||||
enum BatchMode { buildDeploy, buildOnly }
|
||||
|
||||
class BatchDeployState extends State<BatchDeploy>
|
||||
implements ConnectBatchDeploy {
|
||||
ViewBatchDeploy? _model;
|
||||
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
final _lines = <LogLine>[];
|
||||
List<DeployStep> _steps = [];
|
||||
|
||||
final Set<String> _selected = {};
|
||||
final Map<String, BatchState> _status = {};
|
||||
final Map<String, String> _detail = {};
|
||||
|
||||
late List<ServiceConfig> _ordered;
|
||||
BatchMode _mode = BatchMode.buildDeploy;
|
||||
bool _running = false;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
/// A service can join a batch only if Peach knows how to build it.
|
||||
bool _eligible(ServiceConfig s) => s.canBuild;
|
||||
|
||||
int get _eligibleCount => _ordered.where(_eligible).length;
|
||||
|
||||
int get _doneCount =>
|
||||
_status.values.where((s) => s == BatchState.done).length;
|
||||
|
||||
int get _failCount =>
|
||||
_status.values.where((s) => s == BatchState.failed).length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ordered = _server.orderedServices;
|
||||
for (final service in _ordered) {
|
||||
if (_eligible(service)) _selected.add(service.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewBatchDeploy>.reactive(
|
||||
viewModelBuilder: () => ViewBatchDeploy(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Build & deploy · ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_header(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
children: [
|
||||
_reorderHint(),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: _onReorder,
|
||||
itemCount: _ordered.length,
|
||||
itemBuilder: (context, i) => Padding(
|
||||
key: ValueKey(_ordered[i].id),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _serviceTile(_ordered[i], i),
|
||||
),
|
||||
),
|
||||
if (_steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
StepList(steps: _steps),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 300,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_bottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewBatchDeploy 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 {
|
||||
final model = _model;
|
||||
if (model == null) return;
|
||||
_subscriptions.addAll([
|
||||
model.logs.listen(_onLogLine),
|
||||
model.buildLogs.listen(_onLogLine),
|
||||
model.steps.listen(_onSteps),
|
||||
model.buildSteps.listen(_onSteps),
|
||||
]);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
void _onSteps(List<DeployStep> steps) {
|
||||
if (!mounted) return;
|
||||
setState(() => _steps = steps);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// [newIndex] already accounts for the removed item — `onReorderItem` does
|
||||
/// that adjustment for us, unlike the older `onReorder`.
|
||||
void _onReorder(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final moved = _ordered.removeAt(oldIndex);
|
||||
_ordered.insert(newIndex, moved);
|
||||
});
|
||||
_model?.saveDeployOrder(_server, _ordered.map((s) => s.id).toList());
|
||||
}
|
||||
|
||||
void _onRun() {
|
||||
final batch = _ordered.where((s) => _selected.contains(s.id)).toList();
|
||||
if (batch.isEmpty) {
|
||||
_notify('Select at least one service.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_running = true;
|
||||
_lines.clear();
|
||||
});
|
||||
_model?.runBatch(
|
||||
_server,
|
||||
batch,
|
||||
deployAfterBuild: _mode == BatchMode.buildDeploy,
|
||||
);
|
||||
}
|
||||
|
||||
void _onStop() => _model?.requestCancel();
|
||||
|
||||
void _onSelectMode(BatchMode mode) => setState(() => _mode = mode);
|
||||
|
||||
void _onToggleService(ServiceConfig service, bool? selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selected.add(service.id);
|
||||
} else {
|
||||
_selected.remove(service.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onToggleAll() {
|
||||
final eligible = _ordered.where(_eligible).map((s) => s.id).toSet();
|
||||
setState(() {
|
||||
if (_selected.length >= eligible.length) {
|
||||
_selected.clear();
|
||||
} else {
|
||||
_selected
|
||||
..clear()
|
||||
..addAll(eligible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onClearLogs() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopyLogs() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectBatchDeploy ─────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onBatchStarted(List<String> serviceIds) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (final id in serviceIds) {
|
||||
_status[id] = BatchState.queued;
|
||||
_detail.remove(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onServiceState(String serviceId, BatchState state, {String? detail}) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status[serviceId] = state;
|
||||
if (detail != null) _detail[serviceId] = detail;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onBatchFinished() {
|
||||
if (!mounted) return;
|
||||
setState(() => _running = false);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _header() {
|
||||
final jp = context.jp;
|
||||
final allSelected =
|
||||
_selected.length >= _eligibleCount && _eligibleCount > 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text('Mode', 13, TextType.Bold, color: jp.textDim),
|
||||
const Spacer(),
|
||||
if (_running || _doneCount + _failCount > 0)
|
||||
text(
|
||||
'${_doneCount + _failCount}/${_selected.length} · '
|
||||
'$_doneCount ok · $_failCount failed',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_modeChip(
|
||||
BatchMode.buildDeploy,
|
||||
'Build & deploy',
|
||||
Icons.rocket_launch,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_modeChip(BatchMode.buildOnly, 'Build only', Icons.build),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _running ? null : _onToggleAll,
|
||||
icon: Icon(
|
||||
allSelected ? Icons.remove_done : Icons.done_all,
|
||||
size: 16,
|
||||
),
|
||||
label: text(
|
||||
allSelected ? 'Clear' : 'Select all',
|
||||
13,
|
||||
TextType.Bold,
|
||||
color: colorPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reorderHint() => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.drag_indicator, size: 15, color: context.jp.textFaint),
|
||||
const SizedBox(width: 4),
|
||||
text(
|
||||
'Drag to set deployment order — saved automatically',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: context.jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _modeChip(BatchMode mode, String label, IconData icon) {
|
||||
final jp = context.jp;
|
||||
final active = _mode == mode;
|
||||
return GestureDetector(
|
||||
onTap: _running ? null : () => _onSelectMode(mode),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? PeachTheme.seed.withValues(alpha: 0.14) : jp.surface2,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: active ? PeachTheme.seed : jp.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: active ? PeachTheme.seed : jp.textDim),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
label,
|
||||
12.5,
|
||||
TextType.Bold,
|
||||
color: active ? PeachTheme.seed : jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serviceTile(ServiceConfig service, int index) {
|
||||
final jp = context.jp;
|
||||
final eligible = _eligible(service);
|
||||
final selected = _selected.contains(service.id);
|
||||
final state = _status[service.id];
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
|
||||
decoration: PeachTheme.panel(context, radius: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.seed.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: text(
|
||||
'${index + 1}',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (eligible)
|
||||
Checkbox(
|
||||
value: selected,
|
||||
onChanged: _running ? null : (v) => _onToggleService(service, v),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40, child: Icon(Icons.block, size: 18)),
|
||||
Icon(
|
||||
service.isWeb
|
||||
? Icons.language
|
||||
: (service.isGeneric ? Icons.terminal : Icons.settings_suggest),
|
||||
size: 18,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
service.name,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
text(
|
||||
eligible
|
||||
? (_detail[service.id] ??
|
||||
'${service.kind.label} · builds from source')
|
||||
: 'Add a source path to include',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_statusPill(state),
|
||||
if (!_running) ...[
|
||||
const SizedBox(width: 4),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Icon(Icons.drag_handle, size: 20, color: jp.textFaint),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusPill(BatchState? state) {
|
||||
if (state == null) return const SizedBox.shrink();
|
||||
final jp = context.jp;
|
||||
final (color, label, spinning) = switch (state) {
|
||||
BatchState.queued => (jp.textFaint, 'QUEUED', false),
|
||||
BatchState.building => (PeachTheme.warn, 'BUILDING', true),
|
||||
BatchState.deploying => (PeachTheme.accent, 'DEPLOYING', true),
|
||||
BatchState.done => (PeachTheme.seed, 'DONE', false),
|
||||
BatchState.failed => (PeachTheme.danger, 'FAILED', false),
|
||||
BatchState.skipped => (jp.textFaint, 'SKIPPED', false),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (spinning)
|
||||
SizedBox(
|
||||
width: 11,
|
||||
height: 11,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: color),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
state == BatchState.done
|
||||
? Icons.check
|
||||
: state == BatchState.failed
|
||||
? Icons.close
|
||||
: Icons.circle,
|
||||
size: 11,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
text(label, 10, TextType.Bold, color: color),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running || _selected.isEmpty ? null : _onRun,
|
||||
icon: _running
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_mode == BatchMode.buildOnly
|
||||
? Icons.build
|
||||
: Icons.rocket_launch,
|
||||
),
|
||||
label: text(
|
||||
_running
|
||||
? 'Running…'
|
||||
: (_mode == BatchMode.buildOnly
|
||||
? 'Build ${_selected.length} selected'
|
||||
: 'Build & deploy ${_selected.length} selected'),
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_running) ...[
|
||||
const SizedBox(width: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _onStop,
|
||||
icon: const Icon(Icons.cancel, size: 18),
|
||||
label: text(
|
||||
'Stop',
|
||||
13,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: PeachTheme.danger,
|
||||
side: BorderSide(
|
||||
color: PeachTheme.danger.withValues(alpha: 0.6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
lib/Peach/see/deploy/batch/ConnectBatchDeploy.dart
Normal file
13
lib/Peach/see/deploy/batch/ConnectBatchDeploy.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
/// Where one service has got to in the batch.
|
||||
enum BatchState { queued, building, deploying, done, failed, skipped }
|
||||
|
||||
abstract class ConnectBatchDeploy {
|
||||
/// The run started; every selected service is queued.
|
||||
void onBatchStarted(List<String> serviceIds);
|
||||
|
||||
/// One service moved to a new state, optionally with a detail line.
|
||||
void onServiceState(String serviceId, BatchState state, {String? detail});
|
||||
|
||||
/// Every service in the batch has been attempted.
|
||||
void onBatchFinished();
|
||||
}
|
||||
180
lib/Peach/see/deploy/batch/ViewBatchDeploy.dart
Normal file
180
lib/Peach/see/deploy/batch/ViewBatchDeploy.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../../about/internal/application/StepTracker.dart';
|
||||
import '../../../comms/Comms.dart' show DeployResult, LogLine;
|
||||
import '../../../utils/CommonUtils.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectBatchDeploy.dart';
|
||||
|
||||
class ViewBatchDeploy extends ParentViewModel {
|
||||
ConnectBatchDeploy connection;
|
||||
|
||||
ViewBatchDeploy(super.context, this.connection);
|
||||
|
||||
bool _cancelRequested = false;
|
||||
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
Stream<LogLine> get buildLogs => getDataManager().buildLogs;
|
||||
|
||||
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
|
||||
|
||||
Stream<List<DeployStep>> get buildSteps => getDataManager().buildStepStream;
|
||||
|
||||
/// Builds (and optionally deploys) each service in [batch], in order. One
|
||||
/// service failing doesn't stop the rest — the run reports per-service.
|
||||
void runBatch(
|
||||
ServerProfile server,
|
||||
List<ServiceConfig> batch, {
|
||||
required bool deployAfterBuild,
|
||||
}) async {
|
||||
if (deployAfterBuild &&
|
||||
!await canReach(
|
||||
server,
|
||||
() => runBatch(server, batch, deployAfterBuild: deployAfterBuild),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_cancelRequested = false;
|
||||
connection.onBatchStarted(batch.map((s) => s.id).toList());
|
||||
|
||||
for (final service in batch) {
|
||||
if (_cancelRequested) {
|
||||
connection.onServiceState(service.id, BatchState.skipped);
|
||||
continue;
|
||||
}
|
||||
await _runOne(server, service, deployAfterBuild);
|
||||
}
|
||||
|
||||
connection.onBatchFinished();
|
||||
}
|
||||
|
||||
Future<void> _runOne(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
bool deployAfterBuild,
|
||||
) async {
|
||||
connection.onServiceState(service.id, BatchState.building);
|
||||
|
||||
final built = service.isWeb
|
||||
? await getDataManager().buildWeb(service)
|
||||
: await getDataManager().build(service);
|
||||
|
||||
if (!built.ok) {
|
||||
connection.onServiceState(
|
||||
service.id,
|
||||
BatchState.failed,
|
||||
detail: built.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deployAfterBuild) {
|
||||
connection.onServiceState(service.id, BatchState.done, detail: 'Built');
|
||||
return;
|
||||
}
|
||||
|
||||
connection.onServiceState(service.id, BatchState.deploying);
|
||||
|
||||
DeployResult result;
|
||||
String artifact;
|
||||
try {
|
||||
if (service.isWeb) {
|
||||
final directory = built.dir!.path;
|
||||
if (!File('$directory/index.html').existsSync()) {
|
||||
connection.onServiceState(
|
||||
service.id,
|
||||
BatchState.failed,
|
||||
detail: 'No index.html in build output.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final (bytes, count) = await getDataManager().packageWeb(directory);
|
||||
artifact = service.framework.label;
|
||||
result = await getDataManager().deployWeb(
|
||||
server: server,
|
||||
service: service,
|
||||
archiveBytes: bytes,
|
||||
fileCount: count,
|
||||
);
|
||||
} else {
|
||||
final jar = built.jar!;
|
||||
artifact = baseName(jar.path);
|
||||
result = await getDataManager().deploy(
|
||||
server: server,
|
||||
service: service,
|
||||
jarBytes: await jar.readAsBytes(),
|
||||
jarLabel: artifact,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
connection.onServiceState(
|
||||
service.id,
|
||||
BatchState.failed,
|
||||
detail: 'Error: $error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _record(server, service, result, artifact);
|
||||
connection.onServiceState(
|
||||
service.id,
|
||||
result.ok ? BatchState.done : BatchState.failed,
|
||||
detail: result.message,
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops after the service currently running finishes.
|
||||
void requestCancel() {
|
||||
_cancelRequested = true;
|
||||
getDataManager().requestCancel();
|
||||
getDataManager().cancelBuild();
|
||||
}
|
||||
|
||||
/// Remembers the order services should be deployed in next time.
|
||||
void saveDeployOrder(ServerProfile server, List<String> ids) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final index = servers.indexWhere((s) => s.id == server.id);
|
||||
if (index < 0) return;
|
||||
servers[index].deployOrder = ids;
|
||||
await getDataManager().setServers(servers);
|
||||
} catch (error) {
|
||||
handleError(error, () => saveDeployOrder(server, ids), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _record(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
DeployResult result,
|
||||
String artifact,
|
||||
) async {
|
||||
try {
|
||||
await getDataManager().addDeployment(
|
||||
DeploymentRecord(
|
||||
id: const Uuid().v4(),
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serviceId: service.id,
|
||||
serviceLabel: service.name,
|
||||
jarName: artifact,
|
||||
remotePath: service.isWeb
|
||||
? service.remoteRoot
|
||||
: service.remotePath(artifact),
|
||||
time: DateTime.now(),
|
||||
status: result.ok ? DeployStatus.success : DeployStatus.failure,
|
||||
message: result.message,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// A missing history row must not change what the batch reports.
|
||||
}
|
||||
}
|
||||
}
|
||||
33
lib/Peach/see/deploy/single/ConnectDeploy.dart
Normal file
33
lib/Peach/see/deploy/single/ConnectDeploy.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../../comms/Comms.dart' show DeployResult;
|
||||
|
||||
abstract class ConnectDeploy {
|
||||
/// nginx's configured roots, used to warn when a web service deploys
|
||||
/// somewhere nginx isn't serving.
|
||||
void onNginxRootsLoaded(List<String> roots);
|
||||
|
||||
/// A jar / binary is staged and ready to ship.
|
||||
void onJarStaged(Uint8List bytes, String name, String source);
|
||||
|
||||
/// A built site directory is staged and ready to ship.
|
||||
void onWebStaged(String directory, String source);
|
||||
|
||||
/// A local build finished; [message] explains a failure.
|
||||
void onBuildFailed(String message);
|
||||
|
||||
/// Packaging a web build began (it can take a while on big sites).
|
||||
void onPackagingStarted();
|
||||
|
||||
/// The deploy finished, successfully or not.
|
||||
void onDeployFinished(DeployResult result);
|
||||
|
||||
/// A revert finished.
|
||||
void onRevertFinished(DeployResult result);
|
||||
|
||||
/// The kept-backup was discarded.
|
||||
void onBackupDiscarded();
|
||||
|
||||
/// Live tail started or stopped.
|
||||
void onFollowChanged(bool following);
|
||||
}
|
||||
17
lib/Peach/see/deploy/single/Deploy.dart
Normal file
17
lib/Peach/see/deploy/single/Deploy.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import 'DeployState.dart';
|
||||
|
||||
/// Stage an artifact — dropped, picked, or built from source — then ship it to
|
||||
/// the server, with live steps, transfer stats and a revert path.
|
||||
class Deploy extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
final ServiceConfig service;
|
||||
|
||||
const Deploy({super.key, required this.server, required this.service});
|
||||
|
||||
@override
|
||||
State<Deploy> createState() => DeployState();
|
||||
}
|
||||
1030
lib/Peach/see/deploy/single/DeployState.dart
Normal file
1030
lib/Peach/see/deploy/single/DeployState.dart
Normal file
File diff suppressed because it is too large
Load Diff
404
lib/Peach/see/deploy/single/ViewDeploy.dart
Normal file
404
lib/Peach/see/deploy/single/ViewDeploy.dart
Normal file
@@ -0,0 +1,404 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../../about/internal/application/StepTracker.dart';
|
||||
import '../../../comms/Comms.dart' show DeployResult, LogLine, TransferProgress;
|
||||
import '../../../utils/CommonUtils.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectDeploy.dart';
|
||||
|
||||
class ViewDeploy extends ParentViewModel {
|
||||
ConnectDeploy connection;
|
||||
|
||||
ViewDeploy(super.context, this.connection);
|
||||
|
||||
/// Both the SSH session and the local build write to the same log stream on
|
||||
/// screen, so the console reads as one continuous story.
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
Stream<LogLine> get buildLogs => getDataManager().buildLogs;
|
||||
|
||||
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
|
||||
|
||||
Stream<List<DeployStep>> get buildSteps => getDataManager().buildStepStream;
|
||||
|
||||
Stream<TransferProgress?> get transferProgress =>
|
||||
getDataManager().transferProgress;
|
||||
|
||||
bool get isConnected => getDataManager().isConnected;
|
||||
|
||||
bool get canRevert => getDataManager().canRevert;
|
||||
|
||||
bool get buildSupported => getDataManager().buildSupported;
|
||||
|
||||
// ── staging ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads where nginx actually serves from, so a mismatched web root can be
|
||||
/// flagged before the user waits through a deploy that won't show up.
|
||||
void loadNginxRoots(ServerProfile server) async {
|
||||
try {
|
||||
final roots = await getDataManager().nginxRoots(server);
|
||||
connection.onNginxRootsLoaded(roots);
|
||||
} catch (_) {
|
||||
connection.onNginxRootsLoaded(const []);
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks a jar (or any file, for a generic service).
|
||||
void pickArtifact(bool anyFile) async {
|
||||
try {
|
||||
final picked = anyFile
|
||||
? await FilePicker.pickFiles(withData: true)
|
||||
: await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jar'],
|
||||
withData: true,
|
||||
);
|
||||
if (picked == null || picked.files.isEmpty) return;
|
||||
final file = picked.files.first;
|
||||
final bytes = file.bytes ?? await File(file.path!).readAsBytes();
|
||||
connection.onJarStaged(bytes, file.name, 'Dropped file');
|
||||
} catch (error) {
|
||||
handleError(error, () => pickArtifact(anyFile), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the folder holding a built site.
|
||||
void pickWebFolder() async {
|
||||
try {
|
||||
final directory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Select the built site folder (contains index.html)',
|
||||
);
|
||||
if (directory == null) return;
|
||||
connection.onWebStaged(directory, 'Selected folder');
|
||||
} catch (error) {
|
||||
handleError(error, pickWebFolder, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a drag-and-drop payload, rejecting anything the service can't use.
|
||||
void stageDropped(DropDoneDetails details, bool isWeb, bool isGeneric) async {
|
||||
if (details.files.isEmpty) return;
|
||||
try {
|
||||
if (isWeb) {
|
||||
final entity = details.files.first;
|
||||
if (FileSystemEntity.isDirectorySync(entity.path)) {
|
||||
connection.onWebStaged(entity.path, 'Dropped folder');
|
||||
} else {
|
||||
connection.onBuildFailed('Drop the built site folder here.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneric) {
|
||||
final file = details.files.first;
|
||||
if (FileSystemEntity.isDirectorySync(file.path)) {
|
||||
connection.onBuildFailed('Drop a single file, not a folder.');
|
||||
return;
|
||||
}
|
||||
connection.onJarStaged(
|
||||
await file.readAsBytes(),
|
||||
file.name,
|
||||
'Dropped file',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final jar = details.files.firstWhere(
|
||||
(f) => f.name.toLowerCase().endsWith('.jar'),
|
||||
orElse: () => details.files.first,
|
||||
);
|
||||
if (!jar.name.toLowerCase().endsWith('.jar')) {
|
||||
connection.onBuildFailed('That is not a .jar file.');
|
||||
return;
|
||||
}
|
||||
connection.onJarStaged(await jar.readAsBytes(), jar.name, 'Dropped file');
|
||||
} catch (error) {
|
||||
handleError(error, () {}, () {}, "Okay");
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the source project folder and remembers it on the service.
|
||||
void pickProjectDirectory(ServerProfile server, ServiceConfig service) async {
|
||||
try {
|
||||
final directory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Select the service project folder',
|
||||
);
|
||||
if (directory == null) return;
|
||||
await saveProjectPath(server, service, directory);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => pickProjectDirectory(server, service),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the project path so the next deploy doesn't ask again.
|
||||
Future<void> saveProjectPath(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
String path,
|
||||
) async {
|
||||
if (service.localProjectPath == path) return;
|
||||
service.localProjectPath = path;
|
||||
final servers = await getDataManager().getServers();
|
||||
final serverIndex = servers.indexWhere((s) => s.id == server.id);
|
||||
if (serverIndex < 0) return;
|
||||
final services = servers[serverIndex].services;
|
||||
final serviceIndex = services.indexWhere((s) => s.id == service.id);
|
||||
if (serviceIndex < 0) return;
|
||||
services[serviceIndex] = service;
|
||||
await getDataManager().setServers(servers);
|
||||
}
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compiles the project and stages whatever it produces.
|
||||
void buildFromSource(ServiceConfig service, bool isWeb) async {
|
||||
try {
|
||||
final result = isWeb
|
||||
? await getDataManager().buildWeb(service)
|
||||
: await getDataManager().build(service);
|
||||
|
||||
if (!result.ok) {
|
||||
connection.onBuildFailed(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
connection.onWebStaged(result.dir!.path, 'Built from source');
|
||||
} else {
|
||||
final jar = result.jar!;
|
||||
connection.onJarStaged(
|
||||
await jar.readAsBytes(),
|
||||
_basename(jar.path),
|
||||
'Built from source',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
connection.onBuildFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── deploy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Ships a staged jar/binary, then records the outcome in the history.
|
||||
void deployJar(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
Uint8List bytes,
|
||||
String label,
|
||||
) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => deployJar(server, service, bytes, label),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
DeployResult result;
|
||||
try {
|
||||
result = await getDataManager().deploy(
|
||||
server: server,
|
||||
service: service,
|
||||
jarBytes: bytes,
|
||||
jarLabel: label,
|
||||
);
|
||||
} catch (error) {
|
||||
result = DeployResult(false, canRevert, 'Error: $error');
|
||||
}
|
||||
await _record(
|
||||
server,
|
||||
service,
|
||||
result,
|
||||
label,
|
||||
service.remotePath(label.isEmpty ? '<jar>' : label),
|
||||
);
|
||||
connection.onDeployFinished(result);
|
||||
}
|
||||
|
||||
/// Packages the built site, then publishes it to nginx.
|
||||
void deployWeb(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
String directory,
|
||||
) async {
|
||||
if (!await canReach(server, () => deployWeb(server, service, directory))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The archive is extracted at the web root, so index.html has to be at the
|
||||
// folder's top level — for Angular that's dist/<project>/browser, which is
|
||||
// an easy directory to miss.
|
||||
if (!File('$directory/index.html').existsSync()) {
|
||||
connection.onDeployFinished(
|
||||
DeployResult(
|
||||
false,
|
||||
false,
|
||||
'No index.html in "$directory". Point at the folder that CONTAINS '
|
||||
'index.html (for Angular that\'s dist/<project>/browser).',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
DeployResult result;
|
||||
try {
|
||||
connection.onPackagingStarted();
|
||||
final (bytes, count) = await getDataManager().packageWeb(directory);
|
||||
if (count == 0) {
|
||||
connection.onDeployFinished(
|
||||
DeployResult(
|
||||
false,
|
||||
false,
|
||||
'That folder is empty — nothing to deploy.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
result = await getDataManager().deployWeb(
|
||||
server: server,
|
||||
service: service,
|
||||
archiveBytes: bytes,
|
||||
fileCount: count,
|
||||
);
|
||||
} catch (error) {
|
||||
result = DeployResult(false, canRevert, 'Error: $error');
|
||||
}
|
||||
await _record(
|
||||
server,
|
||||
service,
|
||||
result,
|
||||
_basename(directory),
|
||||
service.remoteRoot,
|
||||
);
|
||||
connection.onDeployFinished(result);
|
||||
}
|
||||
|
||||
/// Restores the backup the last deploy left behind.
|
||||
void revert(ServerProfile server, ServiceConfig service) async {
|
||||
try {
|
||||
final result = await getDataManager().revert();
|
||||
if (result.ok) {
|
||||
await _writeRecord(
|
||||
server,
|
||||
service,
|
||||
DeployStatus.reverted,
|
||||
'(previous)',
|
||||
result.message,
|
||||
service.remoteRoot,
|
||||
);
|
||||
}
|
||||
connection.onRevertFinished(result);
|
||||
} catch (error) {
|
||||
connection.onRevertFinished(DeployResult(false, false, '$error'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the backup, committing to the version just deployed.
|
||||
void discardBackup() async {
|
||||
try {
|
||||
await getDataManager().discardBackup();
|
||||
connection.onBackupDiscarded();
|
||||
} catch (error) {
|
||||
handleError(error, discardBackup, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts whichever half of the pipeline is running.
|
||||
void cancel(bool building) {
|
||||
if (building) {
|
||||
getDataManager().cancelBuild();
|
||||
} else {
|
||||
getDataManager().requestCancel();
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
getDataManager().disconnect();
|
||||
connection.onFollowChanged(false);
|
||||
}
|
||||
|
||||
void toggleFollow(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
bool currentlyFollowing,
|
||||
) async {
|
||||
if (currentlyFollowing) {
|
||||
getDataManager().stopLogs();
|
||||
connection.onFollowChanged(false);
|
||||
return;
|
||||
}
|
||||
connection.onFollowChanged(true);
|
||||
try {
|
||||
await getDataManager().followLogs(server, service);
|
||||
} catch (error) {
|
||||
connection.onFollowChanged(false);
|
||||
handleError(
|
||||
error,
|
||||
() => toggleFollow(server, service, false),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── history ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _record(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
DeployResult result,
|
||||
String artifact,
|
||||
String target,
|
||||
) {
|
||||
return _writeRecord(
|
||||
server,
|
||||
service,
|
||||
result.ok ? DeployStatus.success : DeployStatus.failure,
|
||||
artifact,
|
||||
result.message,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writeRecord(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
DeployStatus status,
|
||||
String artifact,
|
||||
String message,
|
||||
String target,
|
||||
) async {
|
||||
try {
|
||||
await getDataManager().addDeployment(
|
||||
DeploymentRecord(
|
||||
id: const Uuid().v4(),
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serviceId: service.id,
|
||||
serviceLabel: service.name,
|
||||
jarName: artifact,
|
||||
remotePath: target,
|
||||
time: DateTime.now(),
|
||||
status: status,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Losing a history row must never mask the deploy's own outcome, which
|
||||
// the caller is about to report.
|
||||
}
|
||||
}
|
||||
|
||||
String _basename(String path) => baseName(path);
|
||||
}
|
||||
22
lib/Peach/see/docker/manager/ConnectDockerManager.dart
Normal file
22
lib/Peach/see/docker/manager/ConnectDockerManager.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
|
||||
abstract class ConnectDockerManager {
|
||||
/// Docker's state on the server, plus its containers when it's running.
|
||||
void onDockerLoaded(DockerStatus status, List<DockerContainer> containers);
|
||||
|
||||
/// A container action (start/stop/restart/rm) finished.
|
||||
void onActionFinished(String output);
|
||||
|
||||
/// Output to show in a sheet — container logs, or a test run.
|
||||
void onOutputReady(String title, String output);
|
||||
|
||||
/// The install command was resolved for this distro; the screen confirms it
|
||||
/// with the user before anything runs.
|
||||
void onInstallCommandReady(String command);
|
||||
|
||||
/// The Docker install finished; the caller should re-check status.
|
||||
void onInstallFinished();
|
||||
|
||||
/// Something failed with a message worth surfacing inline.
|
||||
void onDockerFailed(String message);
|
||||
}
|
||||
15
lib/Peach/see/docker/manager/DockerManager.dart
Normal file
15
lib/Peach/see/docker/manager/DockerManager.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'DockerManagerState.dart';
|
||||
|
||||
/// See, start/stop/restart, inspect logs and remove Docker containers — and
|
||||
/// install Docker if it's missing.
|
||||
class DockerManager extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const DockerManager({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<DockerManager> createState() => DockerManagerState();
|
||||
}
|
||||
495
lib/Peach/see/docker/manager/DockerManagerState.dart
Normal file
495
lib/Peach/see/docker/manager/DockerManagerState.dart
Normal file
@@ -0,0 +1,495 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
import '../../../about/internal/application/NotificationType.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../configs/Navigator.dart';
|
||||
import '../../../designs/OutputSheet.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import '../run/DockerRun.dart';
|
||||
import 'ConnectDockerManager.dart';
|
||||
import 'DockerManager.dart';
|
||||
import 'ViewDockerManager.dart';
|
||||
|
||||
class DockerManagerState extends State<DockerManager>
|
||||
implements ConnectDockerManager {
|
||||
ViewDockerManager? _model;
|
||||
|
||||
DockerStatus? _status;
|
||||
List<DockerContainer> _containers = [];
|
||||
bool _loading = true;
|
||||
bool _installing = false;
|
||||
String? _busyId;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
bool get _ready => _status != null && _status!.installed && _status!.running;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewDockerManager>.reactive(
|
||||
viewModelBuilder: () => ViewDockerManager(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Docker · ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
if (_ready)
|
||||
IconButton(
|
||||
tooltip: 'Test (hello-world)',
|
||||
onPressed: _onTest,
|
||||
icon: const Icon(Icons.verified),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _loading ? null : _onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _ready
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: _onOpenRun,
|
||||
icon: const Icon(Icons.add),
|
||||
label: text(
|
||||
'Run container',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
_statusCard(),
|
||||
if (_ready) ...[
|
||||
const SizedBox(height: 16),
|
||||
_sectionHeader(),
|
||||
const SizedBox(height: 10),
|
||||
if (_loading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_containers.isEmpty)
|
||||
_empty()
|
||||
else
|
||||
LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 720 ? 2 : 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final container in _containers)
|
||||
SizedBox(width: w, child: _containerCard(container)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewDockerManager 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 {
|
||||
_model?.loadDocker(_server);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRefresh() {
|
||||
setState(() => _loading = true);
|
||||
_model?.loadDocker(_server);
|
||||
}
|
||||
|
||||
void _onTest() => _model?.runTest(_server);
|
||||
|
||||
void _onInstall() => _model?.prepareInstall(_server);
|
||||
|
||||
Future<void> _onOpenRun() async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
DockerRun(server: _server),
|
||||
context,
|
||||
);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
void _onStart(DockerContainer c) => _action(c, 'start');
|
||||
|
||||
void _onStop(DockerContainer c) => _action(c, 'stop');
|
||||
|
||||
void _onRestart(DockerContainer c) => _action(c, 'restart');
|
||||
|
||||
void _onRemove(DockerContainer c) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
'Remove "${c.name}"?',
|
||||
'This force-removes the container (docker rm -f).',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Remove',
|
||||
positiveAction: () => _onConfirmRemove(c),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmRemove(DockerContainer c) {
|
||||
Navigator.pop(context);
|
||||
_action(c, 'rm');
|
||||
}
|
||||
|
||||
void _action(DockerContainer c, String action) {
|
||||
setState(() => _busyId = c.id);
|
||||
_model?.runAction(_server, c.id, action);
|
||||
}
|
||||
|
||||
void _onShowLogs(DockerContainer c) {
|
||||
setState(() => _busyId = c.id);
|
||||
_model?.loadContainerLogs(_server, c.id, c.name);
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectDockerManager ───────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onDockerLoaded(DockerStatus status, List<DockerContainer> containers) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = status;
|
||||
_containers = containers;
|
||||
_loading = false;
|
||||
_busyId = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onActionFinished(String output) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busyId = null);
|
||||
if (output.toLowerCase().contains('error')) _notify(output);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void onOutputReady(String title, String output) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busyId = null);
|
||||
showOutputSheet(context, title, output);
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallCommandReady(String command) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.info,
|
||||
'Install Docker?',
|
||||
'Runs:\n$command\nthen: systemctl enable --now docker',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Install',
|
||||
positiveAction: () => _onConfirmInstall(command),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmInstall(String command) {
|
||||
Navigator.pop(context);
|
||||
setState(() => _installing = true);
|
||||
_model?.installDocker(_server, command);
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallFinished() {
|
||||
if (!mounted) return;
|
||||
setState(() => _installing = false);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void onDockerFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_busyId = null;
|
||||
});
|
||||
_notify(message);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _sectionHeader() => Row(
|
||||
children: [
|
||||
text(
|
||||
'CONTAINERS',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text('${_containers.length}', 12, TextType.Bold, color: PeachTheme.seed),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _statusCard() {
|
||||
final jp = context.jp;
|
||||
final installed = _status?.installed ?? false;
|
||||
final running = _status?.running ?? false;
|
||||
final (color, label) = !installed
|
||||
? (jp.textFaint, 'NOT INSTALLED')
|
||||
: running
|
||||
? (PeachTheme.seed, 'RUNNING')
|
||||
: (PeachTheme.warn, 'STOPPED');
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.accent),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const Icon(Icons.inventory_2, color: PeachTheme.accent),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
_loading ? 'Checking Docker…' : 'Docker',
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
text(
|
||||
installed ? (_status?.version ?? '') : 'Not installed',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: text(label, 10.5, TextType.Bold, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!installed) ...[
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _installing ? null : _onInstall,
|
||||
icon: _installing
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download),
|
||||
label: text(
|
||||
_installing ? 'Installing…' : 'Install Docker',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
] else if (!running) ...[
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
'The Docker daemon is not running. Start it with '
|
||||
'systemctl start docker (Configuration → install also enables it).',
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
alignment: Alignment.center,
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 40, color: jp.textFaint),
|
||||
const SizedBox(height: 10),
|
||||
text('No containers yet', 14, TextType.Regular, color: jp.textDim),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'Tap "Run container" to start one.',
|
||||
12,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _containerCard(DockerContainer c) {
|
||||
final jp = context.jp;
|
||||
final busy = _busyId == c.id;
|
||||
final (color, label) = switch (c.state.toLowerCase()) {
|
||||
'running' => (PeachTheme.seed, 'UP'),
|
||||
'exited' => (jp.textFaint, 'EXITED'),
|
||||
'paused' => (PeachTheme.warn, 'PAUSED'),
|
||||
'created' => (PeachTheme.accent, 'CREATED'),
|
||||
'restarting' => (PeachTheme.warn, 'RESTARTING'),
|
||||
_ => (PeachTheme.danger, c.state.toUpperCase()),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.widgets,
|
||||
size: 18,
|
||||
color: c.up ? PeachTheme.seed : jp.textFaint,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: text(
|
||||
c.name,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(label, 10, TextType.Bold, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
c.image,
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
text(
|
||||
c.status,
|
||||
11.5,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (c.ports.trim().isNotEmpty)
|
||||
text(
|
||||
c.ports,
|
||||
11.5,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (busy)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else ...[
|
||||
if (c.up) ...[
|
||||
_act(Icons.restart_alt, 'Restart', () => _onRestart(c)),
|
||||
_act(Icons.stop, 'Stop', () => _onStop(c)),
|
||||
] else
|
||||
_act(Icons.play_arrow, 'Start', () => _onStart(c)),
|
||||
_act(Icons.article_outlined, 'Logs', () => _onShowLogs(c)),
|
||||
_act(
|
||||
Icons.delete_outline,
|
||||
'Remove',
|
||||
() => _onRemove(c),
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _act(IconData icon, String tip, VoidCallback onTap, {Color? color}) =>
|
||||
IconButton(
|
||||
tooltip: tip,
|
||||
iconSize: 20,
|
||||
color: color ?? context.jp.textDim,
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon),
|
||||
);
|
||||
}
|
||||
117
lib/Peach/see/docker/manager/ViewDockerManager.dart
Normal file
117
lib/Peach/see/docker/manager/ViewDockerManager.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
import '../../../about/external/data/EnvCheck.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectDockerManager.dart';
|
||||
|
||||
class ViewDockerManager extends ParentViewModel {
|
||||
ConnectDockerManager connection;
|
||||
|
||||
ViewDockerManager(super.context, this.connection);
|
||||
|
||||
/// Reads Docker's state and, when the daemon is up, its containers.
|
||||
void loadDocker(ServerProfile server) async {
|
||||
if (!await canReach(server, () => loadDocker(server))) return;
|
||||
try {
|
||||
final status = await getDataManager().dockerStatus(server);
|
||||
var containers = <DockerContainer>[];
|
||||
if (status.installed && status.running) {
|
||||
containers = await getDataManager().dockerPs(server, all: true);
|
||||
}
|
||||
connection.onDockerLoaded(status, containers);
|
||||
} catch (error) {
|
||||
connection.onDockerFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// start | stop | restart | rm on one container.
|
||||
void runAction(
|
||||
ServerProfile server,
|
||||
String containerId,
|
||||
String action,
|
||||
) async {
|
||||
try {
|
||||
final output = await getDataManager().dockerAction(
|
||||
server,
|
||||
containerId,
|
||||
action,
|
||||
);
|
||||
connection.onActionFinished(output);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => runAction(server, containerId, action),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void loadContainerLogs(
|
||||
ServerProfile server,
|
||||
String containerId,
|
||||
String name,
|
||||
) async {
|
||||
try {
|
||||
final output = await getDataManager().dockerLogs(server, containerId);
|
||||
connection.onOutputReady(
|
||||
'$name · logs',
|
||||
output.isEmpty ? '(no output)' : output,
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => loadContainerLogs(server, containerId, name),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `hello-world` to prove the daemon can pull and execute an image.
|
||||
void runTest(ServerProfile server) async {
|
||||
showLoading("Running hello-world …");
|
||||
try {
|
||||
final output = await getDataManager().dockerTest(server);
|
||||
closeLoading();
|
||||
connection.onOutputReady('docker run --rm hello-world', output);
|
||||
} catch (error) {
|
||||
handleError(error, () => runTest(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the right package-manager command for this distro, then hands
|
||||
/// it back so the screen can confirm before anything is installed.
|
||||
void prepareInstall(ServerProfile server) async {
|
||||
if (!await canReach(server, () => prepareInstall(server))) return;
|
||||
try {
|
||||
final manager = await getDataManager().detectPackageManager(server);
|
||||
if (manager == 'unknown') {
|
||||
connection.onDockerFailed(
|
||||
'Unknown package manager — install Docker manually.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final tool = kEnvTools.firstWhere((t) => t.key == 'docker');
|
||||
connection.onInstallCommandReady(
|
||||
installCommandFor(manager, tool.packagesFor(manager)),
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, () => prepareInstall(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void installDocker(ServerProfile server, String command) async {
|
||||
try {
|
||||
await getDataManager().installPackages(
|
||||
server: server,
|
||||
command: command,
|
||||
enableUnit: 'docker',
|
||||
);
|
||||
connection.onInstallFinished();
|
||||
} catch (error) {
|
||||
connection.onInstallFinished();
|
||||
handleError(error, () => installDocker(server, command), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
5
lib/Peach/see/docker/run/ConnectDockerRun.dart
Normal file
5
lib/Peach/see/docker/run/ConnectDockerRun.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
abstract class ConnectDockerRun {
|
||||
/// The run finished: [started] is whether the container came up, and
|
||||
/// [output] is the container id on success or the failure text.
|
||||
void onRunFinished(bool started, String output);
|
||||
}
|
||||
14
lib/Peach/see/docker/run/DockerRun.dart
Normal file
14
lib/Peach/see/docker/run/DockerRun.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'DockerRunState.dart';
|
||||
|
||||
/// Form to `docker run` a new container.
|
||||
class DockerRun extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const DockerRun({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<DockerRun> createState() => DockerRunState();
|
||||
}
|
||||
313
lib/Peach/see/docker/run/DockerRunState.dart
Normal file
313
lib/Peach/see/docker/run/DockerRunState.dart
Normal file
@@ -0,0 +1,313 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
import '../../../about/internal/application/ServerProfile.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 'ConnectDockerRun.dart';
|
||||
import 'DockerRun.dart';
|
||||
import 'ViewDockerRun.dart';
|
||||
|
||||
class DockerRunState extends State<DockerRun> implements ConnectDockerRun {
|
||||
ViewDockerRun? _model;
|
||||
|
||||
StreamSubscription<LogLine>? _logSub;
|
||||
final _lines = <LogLine>[];
|
||||
|
||||
final _image = TextEditingController();
|
||||
final _name = TextEditingController();
|
||||
final _ports = TextEditingController();
|
||||
final _env = TextEditingController();
|
||||
final _volumes = TextEditingController();
|
||||
final _command = TextEditingController();
|
||||
|
||||
String _restart = 'unless-stopped';
|
||||
bool _detached = true;
|
||||
bool _running = false;
|
||||
(bool, String)? _result;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSub?.cancel();
|
||||
for (final c in [_image, _name, _ports, _env, _volumes, _command]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewDockerRun>.reactive(
|
||||
viewModelBuilder: () => ViewDockerRun(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Run container',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 40),
|
||||
children: [
|
||||
_field(
|
||||
'Image',
|
||||
_image,
|
||||
hint: 'nginx:latest · redis:7 · postgres:16',
|
||||
),
|
||||
_field('Container name (optional)', _name, hint: 'my-nginx'),
|
||||
_field(
|
||||
'Ports (host:container, one per line)',
|
||||
_ports,
|
||||
hint: '8080:80\n443:443',
|
||||
lines: 2,
|
||||
mono: true,
|
||||
),
|
||||
_field(
|
||||
'Environment (KEY=VALUE, one per line)',
|
||||
_env,
|
||||
hint: 'POSTGRES_PASSWORD=secret',
|
||||
lines: 2,
|
||||
mono: true,
|
||||
),
|
||||
_field(
|
||||
'Volumes (host:container, one per line)',
|
||||
_volumes,
|
||||
hint: '/data/pg:/var/lib/postgresql/data',
|
||||
lines: 2,
|
||||
mono: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _restart,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Restart policy',
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'no', child: Text('no')),
|
||||
DropdownMenuItem(
|
||||
value: 'on-failure',
|
||||
child: Text('on-failure'),
|
||||
),
|
||||
DropdownMenuItem(value: 'always', child: Text('always')),
|
||||
DropdownMenuItem(
|
||||
value: 'unless-stopped',
|
||||
child: Text('unless-stopped'),
|
||||
),
|
||||
],
|
||||
onChanged: _onRestartChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
value: _detached,
|
||||
onChanged: _onDetachedChanged,
|
||||
title: text(
|
||||
'Detached (-d)',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_field(
|
||||
'Command / args (optional)',
|
||||
_command,
|
||||
hint: 'redis-server --appendonly yes',
|
||||
mono: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _running ? null : _onRun,
|
||||
icon: _running
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: text(
|
||||
_running ? 'Starting…' : 'Run container',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
if (_result != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
_banner(_result!),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 220,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewDockerRun 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);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
DockerRunSpec _spec() => DockerRunSpec(
|
||||
image: _image.text,
|
||||
name: _name.text,
|
||||
ports: _ports.text,
|
||||
env: _env.text,
|
||||
volumes: _volumes.text,
|
||||
restart: _restart,
|
||||
detached: _detached,
|
||||
command: _command.text,
|
||||
);
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRun() {
|
||||
if (_image.text.trim().isEmpty) {
|
||||
_notify('Enter an image (e.g. nginx:latest).');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_running = true;
|
||||
_lines.clear();
|
||||
_result = null;
|
||||
});
|
||||
_model?.runContainer(_server, _spec());
|
||||
}
|
||||
|
||||
void _onRestartChanged(String? v) => setState(() => _restart = v ?? 'no');
|
||||
|
||||
void _onDetachedChanged(bool v) => setState(() => _detached = v);
|
||||
|
||||
void _onClearLogs() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopyLogs() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _onViewContainers() => Navigator.pop(context, true);
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectDockerRun ───────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onRunFinished(bool started, String output) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_running = false;
|
||||
_result = (started, output);
|
||||
});
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _banner((bool, String) result) {
|
||||
final (ok, output) = result;
|
||||
final color = ok ? PeachTheme.seed : PeachTheme.danger;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(ok ? Icons.check_circle : Icons.error, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(
|
||||
ok ? 'Container started ✓ (${_shortId(output)})' : output,
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
if (ok)
|
||||
FilledButton(
|
||||
onPressed: _onViewContainers,
|
||||
child: text('View', 13, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `docker run -d` echoes the full container id; the short form is what the
|
||||
/// CLI shows everywhere else.
|
||||
String _shortId(String output) {
|
||||
final id = output.trim().split('\n').last.trim();
|
||||
return id.length <= 12 ? id : '${id.substring(0, 12)}…';
|
||||
}
|
||||
|
||||
Widget _field(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
String? hint,
|
||||
int lines = 1,
|
||||
bool mono = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
maxLines: lines,
|
||||
style: mono
|
||||
? const TextStyle(fontFamily: 'monospace', fontSize: 13)
|
||||
: null,
|
||||
decoration: InputDecoration(labelText: label, hintText: hint),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/Peach/see/docker/run/ViewDockerRun.dart
Normal file
25
lib/Peach/see/docker/run/ViewDockerRun.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../comms/Comms.dart' show LogLine;
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectDockerRun.dart';
|
||||
|
||||
class ViewDockerRun extends ParentViewModel {
|
||||
ConnectDockerRun connection;
|
||||
|
||||
ViewDockerRun(super.context, this.connection);
|
||||
|
||||
/// The command's output as it runs, shown in the console.
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
void runContainer(ServerProfile server, DockerRunSpec spec) async {
|
||||
if (!await canReach(server, () => runContainer(server, spec))) return;
|
||||
try {
|
||||
final (started, output) = await getDataManager().dockerRun(server, spec);
|
||||
connection.onRunFinished(started, output);
|
||||
} catch (error) {
|
||||
connection.onRunFinished(false, error.toString());
|
||||
handleError(error, () => runContainer(server, spec), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/Peach/see/files/ConnectFiles.dart
Normal file
18
lib/Peach/see/files/ConnectFiles.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import '../../comms/Comms.dart' show RemoteEntry;
|
||||
|
||||
abstract class ConnectFiles {
|
||||
/// The user's home directory, resolved on open — the starting location.
|
||||
void onHomeResolved(String path);
|
||||
|
||||
/// The contents of the directory currently being shown.
|
||||
void onDirectoryLoaded(List<RemoteEntry> entries);
|
||||
|
||||
/// The listing failed; [message] explains why.
|
||||
void onDirectoryFailed(String message);
|
||||
|
||||
/// A transfer finished (or failed) with a message to surface.
|
||||
void onTransferFinished(String message);
|
||||
|
||||
/// A remote entry was deleted.
|
||||
void onEntryDeleted();
|
||||
}
|
||||
15
lib/Peach/see/files/Files.dart
Normal file
15
lib/Peach/see/files/Files.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'FilesState.dart';
|
||||
|
||||
/// Browse the server over SFTP: navigate directories, upload local files into
|
||||
/// the current directory, and download remote files to local disk.
|
||||
class Files extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const Files({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<Files> createState() => FilesState();
|
||||
}
|
||||
357
lib/Peach/see/files/FilesState.dart
Normal file
357
lib/Peach/see/files/FilesState.dart
Normal file
@@ -0,0 +1,357 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../comms/Comms.dart' show RemoteEntry, TransferProgress;
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectFiles.dart';
|
||||
import 'Files.dart';
|
||||
import 'ViewFiles.dart';
|
||||
|
||||
class FilesState extends State<Files> implements ConnectFiles {
|
||||
ViewFiles? _model;
|
||||
|
||||
StreamSubscription<TransferProgress?>? _progressSub;
|
||||
|
||||
String _path = '.';
|
||||
List<RemoteEntry> _entries = [];
|
||||
bool _loading = true;
|
||||
bool _busy = false;
|
||||
bool _dragging = false;
|
||||
String? _error;
|
||||
double? _progress;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progressSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewFiles>.reactive(
|
||||
viewModelBuilder: () => ViewFiles(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'${_server.name} · files',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _busy ? null : _onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _busy ? null : _onUploadPicked,
|
||||
icon: const Icon(Icons.upload),
|
||||
label: text('Upload', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
body: DropTarget(
|
||||
onDragEntered: _onDragEntered,
|
||||
onDragExited: _onDragExited,
|
||||
onDragDone: _onDragDone,
|
||||
child: Column(
|
||||
children: [
|
||||
_pathBar(),
|
||||
if (_progress != null)
|
||||
LinearProgressIndicator(
|
||||
value: _progress == 0 ? null : _progress,
|
||||
minHeight: 3,
|
||||
),
|
||||
Expanded(child: _body()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewFiles 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 {
|
||||
_progressSub = _model?.transferProgress.listen(_onProgress);
|
||||
_model?.resolveHome(_server);
|
||||
}
|
||||
|
||||
void _onProgress(TransferProgress? p) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = p?.fraction);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRefresh() {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
_model?.listDirectory(_server, _path);
|
||||
}
|
||||
|
||||
void _onEnter(RemoteEntry e) {
|
||||
if (!e.isDir) return;
|
||||
setState(() => _path = joinRemote(_path, e.name));
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
void _onUp() {
|
||||
setState(() => _path = parentRemote(_path));
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
void _onUploadPicked() {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_progress = 0;
|
||||
});
|
||||
_model?.uploadPicked(_server, _path, joinRemote);
|
||||
}
|
||||
|
||||
void _onDragEntered(DropEventDetails _) => setState(() => _dragging = true);
|
||||
|
||||
void _onDragExited(DropEventDetails _) => setState(() => _dragging = false);
|
||||
|
||||
Future<void> _onDragDone(DropDoneDetails details) async {
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_busy = true;
|
||||
_progress = 0;
|
||||
});
|
||||
for (final file in details.files) {
|
||||
final bytes = await file.readAsBytes();
|
||||
_model?.uploadBytes(
|
||||
_server,
|
||||
joinRemote(_path, file.name),
|
||||
bytes,
|
||||
file.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDownload(RemoteEntry e) {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_progress = 0;
|
||||
});
|
||||
_model?.downloadFile(_server, joinRemote(_path, e.name), e.name);
|
||||
}
|
||||
|
||||
void _onDelete(RemoteEntry e) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
'Delete "${e.name}"?',
|
||||
e.isDir
|
||||
? 'Removes the directory (it must be empty).'
|
||||
: 'Permanently deletes this file on the server.',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Delete',
|
||||
positiveAction: () => _onConfirmDelete(e),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmDelete(RemoteEntry e) {
|
||||
Navigator.pop(context);
|
||||
_model?.deleteEntry(_server, joinRemote(_path, e.name), e.isDir);
|
||||
}
|
||||
|
||||
void _onRowMenu(String value, RemoteEntry e) {
|
||||
if (value == 'download') _onDownload(e);
|
||||
if (value == 'delete') _onDelete(e);
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectFiles ───────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onHomeResolved(String path) {
|
||||
if (!mounted) return;
|
||||
setState(() => _path = path);
|
||||
_model?.listDirectory(_server, path);
|
||||
}
|
||||
|
||||
@override
|
||||
void onDirectoryLoaded(List<RemoteEntry> entries) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onDirectoryFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = message;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onTransferFinished(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_progress = null;
|
||||
});
|
||||
_notify(message);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void onEntryDeleted() {
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _pathBar() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
color: _dragging ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface,
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Up',
|
||||
onPressed: _busy || _path == '/' ? null : _onUp,
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
),
|
||||
Expanded(
|
||||
child: text(
|
||||
_dragging ? 'Drop files to upload here' : _path,
|
||||
13,
|
||||
_dragging ? TextType.Bold : TextType.Regular,
|
||||
color: _dragging ? PeachTheme.seed : jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) return _errorView();
|
||||
if (_entries.isEmpty) {
|
||||
return Center(
|
||||
child: text(
|
||||
'Empty directory',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.textFaint,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: _entries.length,
|
||||
separatorBuilder: (_, i) => Divider(height: 1, color: context.jp.border),
|
||||
itemBuilder: (_, i) => _row(_entries[i]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _errorView() => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorNegative, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
_error!,
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: colorNegative,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: text('Retry', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row(RemoteEntry e) {
|
||||
final jp = context.jp;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
e.isDir ? Icons.folder : Icons.insert_drive_file_outlined,
|
||||
color: e.isDir ? PeachTheme.accent : jp.textDim,
|
||||
),
|
||||
title: text(
|
||||
e.name,
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: e.isDir
|
||||
? null
|
||||
: text(formatBytes(e.size), 12, TextType.Light, color: jp.textFaint),
|
||||
onTap: e.isDir ? () => _onEnter(e) : null,
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (v) => _onRowMenu(v, e),
|
||||
itemBuilder: (_) => [
|
||||
if (!e.isDir)
|
||||
const PopupMenuItem(
|
||||
value: 'download',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.download),
|
||||
title: Text('Download'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Delete'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/Peach/see/files/ViewFiles.dart
Normal file
118
lib/Peach/see/files/ViewFiles.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../comms/Comms.dart' show TransferProgress;
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectFiles.dart';
|
||||
|
||||
class ViewFiles extends ParentViewModel {
|
||||
ConnectFiles connection;
|
||||
|
||||
ViewFiles(super.context, this.connection);
|
||||
|
||||
/// Upload/download progress, subscribed to by the screen so the bar can
|
||||
/// track a transfer as it runs.
|
||||
Stream<TransferProgress?> get transferProgress =>
|
||||
getDataManager().transferProgress;
|
||||
|
||||
/// Resolves the login directory so the browser opens somewhere useful.
|
||||
void resolveHome(ServerProfile server) async {
|
||||
if (!await canReach(server, () => resolveHome(server))) return;
|
||||
try {
|
||||
final home = await getDataManager().resolveHome(server);
|
||||
connection.onHomeResolved(home);
|
||||
} catch (error) {
|
||||
connection.onDirectoryFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void listDirectory(ServerProfile server, String path) async {
|
||||
try {
|
||||
final entries = await getDataManager().listDir(server, path);
|
||||
connection.onDirectoryLoaded(entries);
|
||||
} catch (error) {
|
||||
connection.onDirectoryFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads bytes already in memory (a drag-and-drop payload).
|
||||
void uploadBytes(
|
||||
ServerProfile server,
|
||||
String remotePath,
|
||||
Uint8List bytes,
|
||||
String label,
|
||||
) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => uploadBytes(server, remotePath, bytes, label),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getDataManager().uploadFile(server, bytes, remotePath);
|
||||
connection.onTransferFinished('Uploaded $label');
|
||||
} catch (error) {
|
||||
connection.onTransferFinished('Upload failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for a local file, then uploads it into [directory].
|
||||
void uploadPicked(
|
||||
ServerProfile server,
|
||||
String directory,
|
||||
String Function(String, String) join,
|
||||
) async {
|
||||
try {
|
||||
final picked = await FilePicker.pickFiles(withData: true);
|
||||
if (picked == null || picked.files.isEmpty) return;
|
||||
final file = picked.files.first;
|
||||
final bytes = file.bytes ?? await File(file.path!).readAsBytes();
|
||||
uploadBytes(server, join(directory, file.name), bytes, file.name);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => uploadPicked(server, directory, join),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads [remotePath] and writes it wherever the user chooses.
|
||||
void downloadFile(
|
||||
ServerProfile server,
|
||||
String remotePath,
|
||||
String fileName,
|
||||
) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => downloadFile(server, remotePath, fileName),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final bytes = await getDataManager().downloadFile(server, remotePath);
|
||||
final saved = await FilePicker.saveFile(fileName: fileName, bytes: bytes);
|
||||
connection.onTransferFinished(
|
||||
saved == null ? 'Download cancelled' : 'Saved to $saved',
|
||||
);
|
||||
} catch (error) {
|
||||
connection.onTransferFinished('Download failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void deleteEntry(ServerProfile server, String remotePath, bool isDir) async {
|
||||
if (!await canReach(server, () => deleteEntry(server, remotePath, isDir))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getDataManager().deleteRemote(server, remotePath, isDir);
|
||||
connection.onEntryDeleted();
|
||||
} catch (error) {
|
||||
connection.onTransferFinished('Delete failed: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
13
lib/Peach/see/health/ConnectHealth.dart
Normal file
13
lib/Peach/see/health/ConnectHealth.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import '../../about/external/data/Health.dart';
|
||||
|
||||
abstract class ConnectHealth {
|
||||
/// Every known service was marked as pending a check.
|
||||
void onSweepStarted();
|
||||
|
||||
/// One service's check came back. Results arrive as they complete rather
|
||||
/// than in a single batch, so the dashboard fills in progressively.
|
||||
void onServiceChecked(String serviceId, HealthResult result);
|
||||
|
||||
/// All services in this sweep have been checked.
|
||||
void onSweepFinished();
|
||||
}
|
||||
14
lib/Peach/see/health/Health.dart
Normal file
14
lib/Peach/see/health/Health.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'HealthState.dart';
|
||||
|
||||
/// Live health dashboard across all servers and their services.
|
||||
class Health extends StatefulWidget {
|
||||
final List<ServerProfile> servers;
|
||||
|
||||
const Health({super.key, required this.servers});
|
||||
|
||||
@override
|
||||
State<Health> createState() => HealthState();
|
||||
}
|
||||
294
lib/Peach/see/health/HealthState.dart
Normal file
294
lib/Peach/see/health/HealthState.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Health.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/StatCard.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import 'ConnectHealth.dart';
|
||||
import 'Health.dart';
|
||||
import 'ViewHealth.dart';
|
||||
|
||||
class HealthState extends State<Health> implements ConnectHealth {
|
||||
ViewHealth? _model;
|
||||
|
||||
final Map<String, HealthResult> _results = {};
|
||||
bool _running = false;
|
||||
bool _auto = false;
|
||||
Timer? _timer;
|
||||
|
||||
List<ServerProfile> get _servers => widget.servers;
|
||||
|
||||
int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewHealth>.reactive(
|
||||
viewModelBuilder: () => ViewHealth(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text('Health', 20, TextType.Bold, color: context.jp.text),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
text('Auto', 13, TextType.Regular, color: context.jp.textDim),
|
||||
Switch(value: _auto, onChanged: _onToggleAuto),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Run checks',
|
||||
onPressed: _running ? null : _onRunChecks,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
||||
children: [
|
||||
_statRow(),
|
||||
const SizedBox(height: 20),
|
||||
if (_totalServices == 0)
|
||||
_empty()
|
||||
else
|
||||
for (final server in _servers)
|
||||
if (server.services.isNotEmpty) _serverGroup(server),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewHealth 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 {
|
||||
_model?.runSweep(_servers);
|
||||
}
|
||||
|
||||
int _count(HealthStatus s) =>
|
||||
_results.values.where((r) => r.status == s).length;
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRunChecks() => _model?.runSweep(_servers);
|
||||
|
||||
void _onToggleAuto(bool v) {
|
||||
setState(() => _auto = v);
|
||||
_timer?.cancel();
|
||||
if (v) {
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _model?.runSweep(_servers),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ConnectHealth ──────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onSweepStarted() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_running = true;
|
||||
for (final server in _servers) {
|
||||
for (final service in server.services) {
|
||||
_results[service.id] = HealthResult.checking();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onServiceChecked(String serviceId, HealthResult result) {
|
||||
if (!mounted) return;
|
||||
setState(() => _results[serviceId] = result);
|
||||
}
|
||||
|
||||
@override
|
||||
void onSweepFinished() {
|
||||
if (!mounted) return;
|
||||
setState(() => _running = false);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _statRow() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 640 ? 3 : 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
final cards = [
|
||||
StatCard(
|
||||
icon: Icons.favorite,
|
||||
label: 'Healthy',
|
||||
value: '${_count(HealthStatus.healthy)}',
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
StatCard(
|
||||
icon: Icons.heart_broken,
|
||||
label: 'Unhealthy',
|
||||
value: '${_count(HealthStatus.unhealthy)}',
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
StatCard(
|
||||
icon: Icons.dns,
|
||||
label: 'Services',
|
||||
value: '$_totalServices',
|
||||
color: PeachTheme.accent,
|
||||
),
|
||||
];
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [for (final card in cards) SizedBox(width: w, child: card)],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty() => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
alignment: Alignment.center,
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: text(
|
||||
'No services to monitor yet.',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _serverGroup(ServerProfile server) {
|
||||
final jp = context.jp;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.dns, size: 15, color: PeachTheme.seed),
|
||||
const SizedBox(width: 8),
|
||||
text(server.name, 15, TextType.Bold, color: jp.text),
|
||||
const SizedBox(width: 8),
|
||||
text(server.host, 12, TextType.Regular, color: jp.textFaint),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < server.services.length; i++) ...[
|
||||
if (i > 0) Divider(height: 1, color: jp.border),
|
||||
_serviceRow(server.services[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serviceRow(ServiceConfig svc) {
|
||||
final jp = context.jp;
|
||||
final result = _results[svc.id];
|
||||
final (color, icon, label) = _style(result?.status ?? HealthStatus.unknown);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
svc.isWeb ? Icons.language : Icons.settings_suggest,
|
||||
size: 18,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
svc.name,
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
text(
|
||||
'${svc.healthType.label}'
|
||||
'${result != null ? ' · ${result.detail}' : ''}',
|
||||
11.5,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (result?.status == HealthStatus.checking)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
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.45)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: color),
|
||||
const SizedBox(width: 5),
|
||||
text(label, 11, TextType.Bold, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, IconData, String) _style(HealthStatus s) => switch (s) {
|
||||
HealthStatus.healthy => (PeachTheme.seed, Icons.check_circle, 'HEALTHY'),
|
||||
HealthStatus.unhealthy => (PeachTheme.danger, Icons.error, 'DOWN'),
|
||||
HealthStatus.disabled => (context.jp.textFaint, Icons.remove_circle, 'OFF'),
|
||||
HealthStatus.checking => (PeachTheme.accent, Icons.sync, 'CHECK'),
|
||||
HealthStatus.unknown => (PeachTheme.warn, Icons.help, 'UNKNOWN'),
|
||||
};
|
||||
}
|
||||
48
lib/Peach/see/health/ViewHealth.dart
Normal file
48
lib/Peach/see/health/ViewHealth.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import '../../about/external/data/Health.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectHealth.dart';
|
||||
|
||||
class ViewHealth extends ParentViewModel {
|
||||
ConnectHealth connection;
|
||||
|
||||
ViewHealth(super.context, this.connection);
|
||||
|
||||
bool _running = false;
|
||||
|
||||
bool get isRunning => _running;
|
||||
|
||||
/// Checks every service on every server, reporting each result as it lands.
|
||||
/// A server that can't be reached marks its own services unhealthy and the
|
||||
/// sweep moves on, so one dead box doesn't stall the whole dashboard.
|
||||
void runSweep(List<ServerProfile> servers) async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
notifyListeners();
|
||||
connection.onSweepStarted();
|
||||
|
||||
for (final server in servers) {
|
||||
for (final service in server.services) {
|
||||
try {
|
||||
final result = await getDataManager().checkHealth(server, service);
|
||||
connection.onServiceChecked(service.id, result);
|
||||
} catch (error) {
|
||||
connection.onServiceChecked(
|
||||
service.id,
|
||||
HealthResult(HealthStatus.unhealthy, _describe(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDataManager().disconnect();
|
||||
_running = false;
|
||||
notifyListeners();
|
||||
connection.onSweepFinished();
|
||||
}
|
||||
|
||||
String _describe(Object error) {
|
||||
final text = error.toString();
|
||||
return text.length > 80 ? '${text.substring(0, 77)}…' : text;
|
||||
}
|
||||
}
|
||||
7
lib/Peach/see/history/ConnectHistory.dart
Normal file
7
lib/Peach/see/history/ConnectHistory.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
|
||||
abstract class ConnectHistory {
|
||||
void onHistoryLoaded(List<DeploymentRecord> history);
|
||||
|
||||
void onHistoryCleared();
|
||||
}
|
||||
15
lib/Peach/see/history/History.dart
Normal file
15
lib/Peach/see/history/History.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import 'HistoryState.dart';
|
||||
|
||||
class History extends StatefulWidget {
|
||||
/// Seeded by the caller so the timeline paints immediately; the ViewModel
|
||||
/// re-reads it on open.
|
||||
final List<DeploymentRecord> history;
|
||||
|
||||
const History({super.key, this.history = const []});
|
||||
|
||||
@override
|
||||
State<History> createState() => HistoryState();
|
||||
}
|
||||
337
lib/Peach/see/history/HistoryState.dart
Normal file
337
lib/Peach/see/history/HistoryState.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/CommonUtils.dart';
|
||||
import 'ConnectHistory.dart';
|
||||
import 'History.dart';
|
||||
import 'ViewHistory.dart';
|
||||
|
||||
class HistoryState extends State<History> implements ConnectHistory {
|
||||
ViewHistory? _model;
|
||||
|
||||
List<DeploymentRecord> _history = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_history = widget.history;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewHistory>.reactive(
|
||||
viewModelBuilder: () => ViewHistory(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Deployment history',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
if (_history.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Clear history',
|
||||
onPressed: _onClear,
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: _history.isEmpty ? _empty() : _timeline(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewHistory 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 {
|
||||
_model?.loadHistory();
|
||||
}
|
||||
|
||||
// ── derived state ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Records (already newest-first) grouped by calendar day.
|
||||
List<MapEntry<DateTime, List<DeploymentRecord>>> get _groups {
|
||||
final map = <DateTime, List<DeploymentRecord>>{};
|
||||
for (final r in _history) {
|
||||
final day = DateTime(r.time.year, r.time.month, r.time.day);
|
||||
map.putIfAbsent(day, () => []).add(r);
|
||||
}
|
||||
return map.entries.toList()..sort((a, b) => b.key.compareTo(a.key));
|
||||
}
|
||||
|
||||
String _dayLabel(DateTime d) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final diff = today.difference(d).inDays;
|
||||
if (diff == 0) return 'Today';
|
||||
if (diff == 1) return 'Yesterday';
|
||||
if (diff < 7) return '$diff days ago';
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${d.year}-${two(d.month)}-${two(d.day)}';
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onClear() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
'Clear history?',
|
||||
'This deletes all tracked deployment records. It cannot be undone.',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Clear history',
|
||||
positiveAction: _onConfirmClear,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmClear() {
|
||||
Navigator.pop(context);
|
||||
_model?.clearHistory();
|
||||
}
|
||||
|
||||
// ── ConnectHistory ─────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onHistoryLoaded(List<DeploymentRecord> history) {
|
||||
if (!mounted) return;
|
||||
setState(() => _history = history);
|
||||
}
|
||||
|
||||
@override
|
||||
void onHistoryCleared() {
|
||||
if (!mounted) return;
|
||||
setState(() => _history = []);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _empty() => Center(
|
||||
child: text(
|
||||
'No deployments recorded yet.',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _timeline() {
|
||||
final groups = _groups;
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, i) => _dayGroup(
|
||||
_dayLabel(groups[i].key),
|
||||
groups[i].value,
|
||||
i == groups.length - 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dayGroup(String label, List<DeploymentRecord> records, bool isLast) {
|
||||
final jp = context.jp;
|
||||
final ok = records.where((r) => r.status == DeployStatus.success).length;
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timeline rail: dot + connecting line.
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.seed,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: PeachTheme.glow(
|
||||
PeachTheme.seed,
|
||||
opacity: 0.5,
|
||||
blur: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: isLast ? Colors.transparent : jp.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 26),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
text(label, 16, TextType.Bold, color: jp.text),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'${records.length} deploys · $ok ok',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = (c.maxWidth / 300).floor().clamp(1, 4);
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final r in records)
|
||||
SizedBox(width: w, child: _timelineTile(r)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timelineTile(DeploymentRecord record) {
|
||||
final jp = context.jp;
|
||||
final (color, icon) = switch (record.status) {
|
||||
DeployStatus.success => (PeachTheme.seed, Icons.check_circle),
|
||||
DeployStatus.failure => (PeachTheme.danger, Icons.error),
|
||||
DeployStatus.reverted => (PeachTheme.warn, Icons.undo),
|
||||
};
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final clock = '${two(record.time.hour)}:${two(record.time.minute)}';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(clock, 15, TextType.Bold, color: jp.text),
|
||||
text(
|
||||
timeAgo(record.time),
|
||||
11,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(
|
||||
record.status.label,
|
||||
10.5,
|
||||
TextType.Bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
'${record.serverName} · ${record.serviceLabel}',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
_metaRow(
|
||||
Icons.inventory_2,
|
||||
record.jarName.isEmpty ? '—' : record.jarName,
|
||||
12,
|
||||
jp.textDim,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
_metaRow(
|
||||
Icons.folder_outlined,
|
||||
record.remotePath,
|
||||
11.5,
|
||||
jp.textFaint,
|
||||
),
|
||||
if (record.message.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
record.message,
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _metaRow(IconData icon, String label, double size, Color color) => Row(
|
||||
children: [
|
||||
Icon(icon, size: 12, color: context.jp.textFaint),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: text(
|
||||
label,
|
||||
size,
|
||||
TextType.Regular,
|
||||
color: color,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
28
lib/Peach/see/history/ViewHistory.dart
Normal file
28
lib/Peach/see/history/ViewHistory.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectHistory.dart';
|
||||
|
||||
class ViewHistory extends ParentViewModel {
|
||||
ConnectHistory connection;
|
||||
|
||||
ViewHistory(super.context, this.connection);
|
||||
|
||||
void loadHistory() async {
|
||||
try {
|
||||
final history = await getDataManager().getDeployments();
|
||||
connection.onHistoryLoaded(history);
|
||||
} catch (error) {
|
||||
handleError(error, loadHistory, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void clearHistory() async {
|
||||
showLoading("Clearing history …");
|
||||
try {
|
||||
await getDataManager().clearDeployments();
|
||||
closeLoading();
|
||||
connection.onHistoryCleared();
|
||||
} catch (error) {
|
||||
handleError(error, clearHistory, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
16
lib/Peach/see/home/ConnectHome.dart
Normal file
16
lib/Peach/see/home/ConnectHome.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
|
||||
abstract class ConnectHome {
|
||||
/// The dashboard's data, freshly read from storage.
|
||||
void onDashboardLoaded(
|
||||
List<ServerProfile> servers,
|
||||
List<DeploymentRecord> history,
|
||||
);
|
||||
|
||||
/// A server was saved; [server] is the stored version.
|
||||
void onServerSaved(ServerProfile server);
|
||||
|
||||
/// The theme preference changed to [mode].
|
||||
void onThemeChanged(String mode);
|
||||
}
|
||||
17
lib/Peach/see/home/Home.dart
Normal file
17
lib/Peach/see/home/Home.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'HomeState.dart';
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
/// Seeded by the splash so the dashboard paints populated on first frame;
|
||||
/// every later change is reloaded through the ViewModel.
|
||||
final List<ServerProfile> servers;
|
||||
final List<DeploymentRecord> history;
|
||||
|
||||
const Home({super.key, this.servers = const [], this.history = const []});
|
||||
|
||||
@override
|
||||
State<Home> createState() => HomeState();
|
||||
}
|
||||
723
lib/Peach/see/home/HomeState.dart
Normal file
723
lib/Peach/see/home/HomeState.dart
Normal file
@@ -0,0 +1,723 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/NavigatorType.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../configs/ThemeController.dart';
|
||||
import '../../designs/ActivityChart.dart';
|
||||
import '../../designs/InsightsCard.dart';
|
||||
import '../../designs/RecentDeploys.dart';
|
||||
import '../../designs/Responsive.dart';
|
||||
import '../../designs/StatCard.dart';
|
||||
import '../../designs/StatsDonut.dart';
|
||||
import '../../designs/navigation/SideNav.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../health/Health.dart';
|
||||
import '../history/History.dart';
|
||||
import '../server/detail/ServerDetail.dart';
|
||||
import '../server/editor/ServerEditor.dart';
|
||||
import '../server/setup/ServerSetup.dart';
|
||||
import 'ConnectHome.dart';
|
||||
import 'Home.dart';
|
||||
import 'ViewHome.dart';
|
||||
|
||||
enum HomeView { list, grid }
|
||||
|
||||
class HomeState extends State<Home> implements ConnectHome {
|
||||
static const _viewKey = 'peach.homeview';
|
||||
|
||||
ViewHome? _model;
|
||||
|
||||
List<ServerProfile> _servers = [];
|
||||
List<DeploymentRecord> _history = [];
|
||||
HomeView _view = HomeView.grid;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_servers = widget.servers;
|
||||
_history = widget.history;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewHome>.reactive(
|
||||
viewModelBuilder: () => ViewHome(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => LayoutBuilder(
|
||||
// The rail only earns its space on a real desktop width, so the
|
||||
// breakpoint here is wider than Responsive's tablet cut-off.
|
||||
builder: (context, constraints) => Responsive(
|
||||
mobile: _mobileView(),
|
||||
tablet: constraints.maxWidth >= 900
|
||||
? _desktopView(constraints.maxWidth)
|
||||
: _tabletView(),
|
||||
desktop: _desktopView(constraints.maxWidth),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewHome 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 {
|
||||
await _loadView();
|
||||
_model?.loadDashboard();
|
||||
}
|
||||
|
||||
// ── derived state ──────────────────────────────────────────────────────────
|
||||
|
||||
int get _success =>
|
||||
_history.where((h) => h.status == DeployStatus.success).length;
|
||||
|
||||
int get _failed =>
|
||||
_history.where((h) => h.status == DeployStatus.failure).length;
|
||||
|
||||
int get _reverted =>
|
||||
_history.where((h) => h.status == DeployStatus.reverted).length;
|
||||
|
||||
int get _rate {
|
||||
final attempted = _success + _failed;
|
||||
return attempted == 0 ? 0 : ((_success / attempted) * 100).round();
|
||||
}
|
||||
|
||||
int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length);
|
||||
|
||||
// ── view preference ────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _loadView() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getString(_viewKey);
|
||||
if (stored == HomeView.list.name && mounted) {
|
||||
setState(() => _view = HomeView.list);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setView(HomeView v) async {
|
||||
setState(() => _view = v);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_viewKey, v.name);
|
||||
}
|
||||
|
||||
void _onGridView() => _setView(HomeView.grid);
|
||||
|
||||
void _onListView() => _setView(HomeView.list);
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onCycleTheme() => _model?.cycleTheme(ThemeController.name);
|
||||
|
||||
Future<void> _onNewServer() async {
|
||||
final created = await PeachNavigation().navigateToPageWithData(
|
||||
const ServerEditor(),
|
||||
context,
|
||||
);
|
||||
if (created is! ServerProfile) return;
|
||||
_model?.saveServer(created);
|
||||
if (!mounted) return;
|
||||
// Guided environment setup for the freshly-registered server.
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
ServerSetup(server: created),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onOpenServer(ServerProfile server) async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
ServerDetail(server: server, history: _history),
|
||||
context,
|
||||
);
|
||||
// The detail screen can add, edit or remove services and record deploys.
|
||||
_model?.loadDashboard();
|
||||
}
|
||||
|
||||
Future<void> _onOpenHistory() async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
History(history: _history),
|
||||
context,
|
||||
);
|
||||
_model?.loadDashboard();
|
||||
}
|
||||
|
||||
void _onOpenHealth() {
|
||||
PeachNavigation().navigateToPage(
|
||||
NavigatorType.justOpen,
|
||||
Health(servers: _servers),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectHome ────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onDashboardLoaded(
|
||||
List<ServerProfile> servers,
|
||||
List<DeploymentRecord> history,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_servers = servers;
|
||||
_history = history;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onServerSaved(ServerProfile server) {
|
||||
// The dashboard reload that follows carries the new value; nothing extra
|
||||
// to do here beyond keeping the interface honest.
|
||||
}
|
||||
|
||||
@override
|
||||
void onThemeChanged(String mode) {
|
||||
ThemeController.apply(mode);
|
||||
}
|
||||
|
||||
// ── layouts ────────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _desktopView(double width) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
SideNav(
|
||||
expanded: width >= 1140,
|
||||
themeMode: ThemeController.mode.value,
|
||||
onCycleTheme: () async => _onCycleTheme(),
|
||||
onNewServer: _onNewServer,
|
||||
items: [
|
||||
NavItemData(
|
||||
icon: Icons.grid_view_rounded,
|
||||
label: 'Dashboard',
|
||||
selected: true,
|
||||
onTap: _onDashboardTap,
|
||||
),
|
||||
NavItemData(
|
||||
icon: Icons.monitor_heart,
|
||||
label: 'Health',
|
||||
badge: _totalServices,
|
||||
onTap: _onOpenHealth,
|
||||
),
|
||||
NavItemData(
|
||||
icon: Icons.history,
|
||||
label: 'History',
|
||||
badge: _history.length,
|
||||
onTap: _onOpenHistory,
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(child: _mainArea()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tablet sits between the two: the dashboard body without the side rail.
|
||||
Widget _tabletView() => Scaffold(body: _mainArea());
|
||||
|
||||
Widget _mobileView() {
|
||||
final jp = context.jp;
|
||||
final (themeIcon, themeTip) = switch (ThemeController.mode.value) {
|
||||
ThemeMode.dark => (Icons.dark_mode, 'Dark theme'),
|
||||
ThemeMode.light => (Icons.light_mode, 'Light theme'),
|
||||
ThemeMode.system => (Icons.brightness_auto, 'System theme'),
|
||||
};
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
titleSpacing: 20,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.rocket_launch, color: colorPrimary),
|
||||
const SizedBox(width: 10),
|
||||
text('Peach', 17, TextType.Bold, color: jp.text),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: themeTip,
|
||||
onPressed: _onCycleTheme,
|
||||
icon: Icon(themeIcon),
|
||||
),
|
||||
_viewToggle(jp),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Health',
|
||||
onPressed: _onOpenHealth,
|
||||
icon: const Icon(Icons.monitor_heart),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'History',
|
||||
onPressed: _onOpenHistory,
|
||||
icon: const Icon(Icons.history),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _onNewServer,
|
||||
icon: const Icon(Icons.add),
|
||||
label: text('New server', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
_statRow(),
|
||||
const SizedBox(height: 16),
|
||||
_bento(),
|
||||
const SizedBox(height: 24),
|
||||
_sectionHeader('SERVERS', _servers.length),
|
||||
const SizedBox(height: 12),
|
||||
if (_servers.isEmpty) _emptyServers() else _serversWrap(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDashboardTap() {}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _mainArea() {
|
||||
final jp = context.jp;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 22, 28, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text('Dashboard', 26, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
'${_servers.length} servers · ${_history.length} deployments',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_viewToggle(jp),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(28, 8, 28, 32),
|
||||
children: [
|
||||
_statRow(),
|
||||
const SizedBox(height: 16),
|
||||
_bento(),
|
||||
const SizedBox(height: 24),
|
||||
_sectionHeader('SERVERS', _servers.length),
|
||||
const SizedBox(height: 12),
|
||||
if (_servers.isEmpty) _emptyServers() else _serversWrap(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bento() {
|
||||
final activity = ActivityChart(history: _history);
|
||||
final donut = StatsDonut(
|
||||
success: _success,
|
||||
failed: _failed,
|
||||
reverted: _reverted,
|
||||
);
|
||||
final insights = InsightsCard(
|
||||
servers: _servers,
|
||||
history: _history,
|
||||
onViewHistory: _onOpenHistory,
|
||||
);
|
||||
final recent = RecentDeploys(
|
||||
history: _history,
|
||||
onViewAll: _onOpenHistory,
|
||||
max: 5,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
if (c.maxWidth <= 760) {
|
||||
return Column(
|
||||
children: [
|
||||
activity,
|
||||
const SizedBox(height: 16),
|
||||
donut,
|
||||
const SizedBox(height: 16),
|
||||
insights,
|
||||
if (_history.isNotEmpty) ...[const SizedBox(height: 16), recent],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 5, child: activity),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 3, child: donut),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 5, child: insights),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _history.isEmpty ? const SizedBox() : recent,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statRow() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 14.0;
|
||||
final cols = c.maxWidth >= 900
|
||||
? 4
|
||||
: c.maxWidth >= 560
|
||||
? 2
|
||||
: 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
final cards = [
|
||||
StatCard(
|
||||
icon: Icons.rocket_launch,
|
||||
label: 'Deployments',
|
||||
value: '${_history.length}',
|
||||
color: PeachTheme.accent,
|
||||
),
|
||||
StatCard(
|
||||
icon: Icons.verified,
|
||||
label: 'Success rate',
|
||||
value: _history.isEmpty ? '—' : '$_rate%',
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
StatCard(
|
||||
icon: Icons.error_outline,
|
||||
label: 'Failed',
|
||||
value: '$_failed',
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
StatCard(
|
||||
icon: Icons.dns,
|
||||
label: 'Servers',
|
||||
value: '${_servers.length}',
|
||||
color: PeachTheme.violet,
|
||||
),
|
||||
];
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [for (final card in cards) SizedBox(width: w, child: card)],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serversWrap() {
|
||||
if (_view == HomeView.list) {
|
||||
return Column(
|
||||
children: [
|
||||
for (final s in _servers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _serverListCard(s),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 14.0;
|
||||
final cols = (c.maxWidth / 300).floor().clamp(1, 4);
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final s in _servers)
|
||||
SizedBox(width: w, child: _serverGridCard(s)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _deployCount(ServerProfile s) =>
|
||||
_history.where((h) => h.serverId == s.id).length;
|
||||
|
||||
Widget _serverListCard(ServerProfile server) {
|
||||
final jp = context.jp;
|
||||
final count = _deployCount(server);
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _onOpenServer(server),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconTile(46, 13),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
server.name,
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
text(
|
||||
'${server.username}@${server.host}:${server.port}',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_kindChips(server),
|
||||
const SizedBox(width: 14),
|
||||
if (count > 0)
|
||||
text(
|
||||
'$count deploys',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: jp.textFaint),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _serverGridCard(ServerProfile server) {
|
||||
final jp = context.jp;
|
||||
final count = _deployCount(server);
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _onOpenServer(server),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_iconTile(42, 12),
|
||||
const Spacer(),
|
||||
if (count > 0)
|
||||
text('$count', 13, TextType.Bold, color: jp.textFaint),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
text(
|
||||
server.name,
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
text(
|
||||
'${server.username}@${server.host}',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_kindChips(server),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _iconTile(double size, double radius) => Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
PeachTheme.seed.withValues(alpha: 0.28),
|
||||
PeachTheme.seed.withValues(alpha: 0.10),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: const Icon(Icons.dns, color: PeachTheme.seed),
|
||||
);
|
||||
|
||||
Widget _kindChips(ServerProfile s) {
|
||||
final jp = context.jp;
|
||||
var jar = 0, web = 0;
|
||||
for (final svc in s.services) {
|
||||
if (svc.kind == ServiceKind.webApp) {
|
||||
web++;
|
||||
} else {
|
||||
jar++;
|
||||
}
|
||||
}
|
||||
|
||||
Widget chip(IconData icon, int n, Color color) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: color),
|
||||
const SizedBox(width: 4),
|
||||
text('$n', 11, TextType.Bold, color: color),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (jar == 0 && web == 0) {
|
||||
return text('no services', 11, TextType.Regular, color: jp.textFaint);
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (jar > 0) chip(Icons.settings_suggest, jar, PeachTheme.seed),
|
||||
if (web > 0) chip(Icons.language, web, PeachTheme.accent),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader(String title, int count) {
|
||||
return Row(
|
||||
children: [
|
||||
text(
|
||||
title,
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.seed.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text('$count', 11, TextType.Bold, color: PeachTheme.seed),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _viewToggle(JpColors jp) {
|
||||
Widget btn(HomeView v, IconData icon, String tip, VoidCallback onTap) {
|
||||
final active = _view == v;
|
||||
return IconButton(
|
||||
tooltip: tip,
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, color: active ? PeachTheme.seed : jp.textFaint),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: jp.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
btn(HomeView.grid, Icons.grid_view, 'Grid view', _onGridView),
|
||||
btn(
|
||||
HomeView.list,
|
||||
Icons.view_agenda_outlined,
|
||||
'List view',
|
||||
_onListView,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyServers() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.dns,
|
||||
size: 56,
|
||||
color: PeachTheme.seed.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
text('No servers yet', 20, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
'Register a server, then add its services.',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _onNewServer,
|
||||
icon: const Icon(Icons.add),
|
||||
label: text('New server', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/Peach/see/home/ViewHome.dart
Normal file
87
lib/Peach/see/home/ViewHome.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectHome.dart';
|
||||
|
||||
class ViewHome extends ParentViewModel {
|
||||
ConnectHome connection;
|
||||
|
||||
ViewHome(super.context, this.connection);
|
||||
|
||||
/// Re-reads servers and history. Called on open and whenever a child screen
|
||||
/// pops back having changed something.
|
||||
void loadDashboard() async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final history = await getDataManager().getDeployments();
|
||||
connection.onDashboardLoaded(servers, history);
|
||||
} catch (error) {
|
||||
handleError(error, loadDashboard, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts or updates [server] in storage, then refreshes the dashboard.
|
||||
void saveServer(ServerProfile server) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final index = servers.indexWhere((s) => s.id == server.id);
|
||||
if (index >= 0) {
|
||||
servers[index] = server;
|
||||
} else {
|
||||
servers.add(server);
|
||||
}
|
||||
await getDataManager().setServers(servers);
|
||||
connection.onServerSaved(server);
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
handleError(error, () => saveServer(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes [server] and its stored password.
|
||||
void deleteServer(ServerProfile server) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
servers.removeWhere((s) => s.id == server.id);
|
||||
await getDataManager().setServers(servers);
|
||||
await getDataManager().deleteServerSecret(server.id);
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
handleError(error, () => deleteServer(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a deployment outcome to the history.
|
||||
void recordDeployment(DeploymentRecord record) async {
|
||||
try {
|
||||
await getDataManager().addDeployment(record);
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
handleError(error, () => recordDeployment(record), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void clearHistory() async {
|
||||
try {
|
||||
await getDataManager().clearDeployments();
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
handleError(error, clearHistory, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the theme dark → light → system → dark and persists the choice.
|
||||
void cycleTheme(String current) async {
|
||||
try {
|
||||
final next = switch (current) {
|
||||
'dark' => 'light',
|
||||
'light' => 'system',
|
||||
_ => 'dark',
|
||||
};
|
||||
await getDataManager().setThemeMode(next);
|
||||
connection.onThemeChanged(next);
|
||||
} catch (error) {
|
||||
handleError(error, () => cycleTheme(current), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
lib/Peach/see/kafka/ConnectKafka.dart
Normal file
36
lib/Peach/see/kafka/ConnectKafka.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import '../../about/external/data/Kafka.dart';
|
||||
|
||||
/// One scan's worth of cluster state.
|
||||
class KafkaScan {
|
||||
final List<KafkaTopic> topics;
|
||||
final List<KafkaConsumerGroup> groups;
|
||||
final KafkaCluster cluster;
|
||||
|
||||
const KafkaScan({
|
||||
required this.topics,
|
||||
required this.groups,
|
||||
required this.cluster,
|
||||
});
|
||||
|
||||
/// Nothing came back at all — usually an unreachable bootstrap server rather
|
||||
/// than a genuinely empty cluster.
|
||||
bool get isEmpty =>
|
||||
topics.isEmpty && groups.isEmpty && cluster.brokers.isEmpty;
|
||||
}
|
||||
|
||||
abstract class ConnectKafka {
|
||||
/// Whether the Kafka CLI tools were found, and where.
|
||||
void onKafkaDetected(KafkaStatus status);
|
||||
|
||||
/// Detection itself failed (the server couldn't be probed).
|
||||
void onDetectFailed(String message);
|
||||
|
||||
/// Topics, groups and cluster health, freshly read.
|
||||
void onScanned(KafkaScan scan);
|
||||
|
||||
/// The scan failed.
|
||||
void onScanFailed(String message);
|
||||
|
||||
/// Output of the broker probe, to show in a sheet.
|
||||
void onProbeOutput(String title, String output);
|
||||
}
|
||||
16
lib/Peach/see/kafka/Kafka.dart
Normal file
16
lib/Peach/see/kafka/Kafka.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'KafkaState.dart';
|
||||
|
||||
/// Live Kafka monitoring over SSH: topics with message offsets, consumer
|
||||
/// groups with lag, and cluster/broker health. Everything comes from the
|
||||
/// standard Kafka CLI tools run on the server.
|
||||
class Kafka extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const Kafka({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<Kafka> createState() => KafkaState();
|
||||
}
|
||||
1179
lib/Peach/see/kafka/KafkaState.dart
Normal file
1179
lib/Peach/see/kafka/KafkaState.dart
Normal file
File diff suppressed because it is too large
Load Diff
94
lib/Peach/see/kafka/ViewKafka.dart
Normal file
94
lib/Peach/see/kafka/ViewKafka.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectKafka.dart';
|
||||
|
||||
class ViewKafka extends ParentViewModel {
|
||||
ConnectKafka connection;
|
||||
|
||||
ViewKafka(super.context, this.connection);
|
||||
|
||||
/// Looks for the Kafka CLI tools on PATH and in the usual install dirs.
|
||||
void detectKafka(ServerProfile server) async {
|
||||
if (!await canReach(server, () => detectKafka(server))) return;
|
||||
try {
|
||||
final status = await getDataManager().kafkaDetect(server);
|
||||
connection.onKafkaDetected(status);
|
||||
} catch (error) {
|
||||
connection.onDetectFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads topics, consumer groups and cluster health in one pass. They're
|
||||
/// three separate CLI invocations, so this is where they're sequenced.
|
||||
void scan(
|
||||
ServerProfile server, {
|
||||
required String binDir,
|
||||
required String suffix,
|
||||
required String bootstrap,
|
||||
required bool includeInternal,
|
||||
}) async {
|
||||
try {
|
||||
final topics = await getDataManager().kafkaTopics(
|
||||
server,
|
||||
binDir: binDir,
|
||||
suffix: suffix,
|
||||
bootstrap: bootstrap,
|
||||
includeInternal: includeInternal,
|
||||
);
|
||||
final groups = await getDataManager().kafkaGroups(
|
||||
server,
|
||||
binDir: binDir,
|
||||
suffix: suffix,
|
||||
bootstrap: bootstrap,
|
||||
);
|
||||
final cluster = await getDataManager().kafkaCluster(
|
||||
server,
|
||||
binDir: binDir,
|
||||
suffix: suffix,
|
||||
bootstrap: bootstrap,
|
||||
);
|
||||
|
||||
connection.onScanned(
|
||||
KafkaScan(topics: topics, groups: groups, cluster: cluster),
|
||||
);
|
||||
} catch (error) {
|
||||
connection.onScanFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `kafka-broker-api-versions` so the user can see exactly what the
|
||||
/// broker says when a scan comes back empty.
|
||||
void probeBroker(
|
||||
ServerProfile server, {
|
||||
required String binDir,
|
||||
required String suffix,
|
||||
required String bootstrap,
|
||||
}) async {
|
||||
showLoading("Probing $bootstrap …");
|
||||
try {
|
||||
final output = await getDataManager().kafkaTest(
|
||||
server,
|
||||
binDir: binDir,
|
||||
suffix: suffix,
|
||||
bootstrap: bootstrap,
|
||||
);
|
||||
closeLoading();
|
||||
connection.onProbeOutput(
|
||||
'kafka-broker-api-versions · $bootstrap',
|
||||
output.isEmpty ? '(no output — broker unreachable?)' : output,
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => probeBroker(
|
||||
server,
|
||||
binDir: binDir,
|
||||
suffix: suffix,
|
||||
bootstrap: bootstrap,
|
||||
),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
lib/Peach/see/logs/ConnectLogs.dart
Normal file
16
lib/Peach/see/logs/ConnectLogs.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
abstract class ConnectLogs {
|
||||
/// A fetch finished (the lines themselves arrive on the log stream).
|
||||
void onLogsFetched();
|
||||
|
||||
/// The unit's current systemd status, e.g. `active (running)` or `no-unit`.
|
||||
void onStatusLoaded(String status);
|
||||
|
||||
/// A start / stop / restart finished, with the message to surface.
|
||||
void onControlFinished(String message);
|
||||
|
||||
/// Live tail started or stopped.
|
||||
void onFollowChanged(bool following);
|
||||
|
||||
/// The filtered log text was written to [path], or cancelled when null.
|
||||
void onLogsSaved(String? path);
|
||||
}
|
||||
17
lib/Peach/see/logs/Logs.dart
Normal file
17
lib/Peach/see/logs/Logs.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import 'LogsState.dart';
|
||||
|
||||
/// Read-only viewer plus lightweight service control: fetch, filter and
|
||||
/// download logs, live-tail them, and start / stop / restart the unit.
|
||||
class Logs extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
final ServiceConfig service;
|
||||
|
||||
const Logs({super.key, required this.server, required this.service});
|
||||
|
||||
@override
|
||||
State<Logs> createState() => LogsState();
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/Peach/see/logs/ViewLogs.dart
Normal file
147
lib/Peach/see/logs/ViewLogs.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../comms/Comms.dart' show LogLine;
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectLogs.dart';
|
||||
|
||||
class ViewLogs extends ParentViewModel {
|
||||
ConnectLogs connection;
|
||||
|
||||
ViewLogs(super.context, this.connection);
|
||||
|
||||
/// Log lines as they arrive from the server. The screen subscribes to this
|
||||
/// directly — a stream of remote output has no request/response shape to
|
||||
/// hand back through the Connect interface one call at a time.
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
/// Pulls the journal for [service], optionally bounded by a date range.
|
||||
void fetchLogs(
|
||||
ServerProfile server,
|
||||
ServiceConfig service, {
|
||||
DateTime? since,
|
||||
DateTime? until,
|
||||
}) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => fetchLogs(server, service, since: since, until: until),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
getDataManager().stopLogs();
|
||||
try {
|
||||
await getDataManager().fetchLogs(
|
||||
server,
|
||||
service,
|
||||
since: since,
|
||||
until: until,
|
||||
);
|
||||
connection.onLogsFetched();
|
||||
} catch (error) {
|
||||
connection.onLogsFetched();
|
||||
handleError(
|
||||
error,
|
||||
() => fetchLogs(server, service, since: since, until: until),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the unit's systemd state. Failures are reported as `unknown`
|
||||
/// rather than raised — the status pill is ambient, not an operation the
|
||||
/// user asked for.
|
||||
void loadStatus(ServerProfile server, ServiceConfig service) async {
|
||||
try {
|
||||
final status = await getDataManager().serviceStatus(server, service);
|
||||
connection.onStatusLoaded(status);
|
||||
} catch (_) {
|
||||
connection.onStatusLoaded('unknown');
|
||||
}
|
||||
}
|
||||
|
||||
/// start | stop | restart on the service's unit.
|
||||
void control(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
String action,
|
||||
) async {
|
||||
if (!await canReach(server, () => control(server, service, action))) return;
|
||||
getDataManager().stopLogs();
|
||||
try {
|
||||
final result = await getDataManager().serviceControl(
|
||||
server,
|
||||
service,
|
||||
action,
|
||||
);
|
||||
connection.onControlFinished(result.message);
|
||||
loadStatus(server, service);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => control(server, service, action),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts or stops the live tail.
|
||||
void toggleFollow(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
bool currentlyFollowing,
|
||||
) async {
|
||||
if (currentlyFollowing) {
|
||||
getDataManager().stopLogs();
|
||||
connection.onFollowChanged(false);
|
||||
return;
|
||||
}
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => toggleFollow(server, service, currentlyFollowing),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
connection.onFollowChanged(true);
|
||||
try {
|
||||
await getDataManager().followLogs(server, service);
|
||||
} catch (error) {
|
||||
connection.onFollowChanged(false);
|
||||
handleError(
|
||||
error,
|
||||
() => toggleFollow(server, service, false),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes [lines] to a file the user picks.
|
||||
void saveLogs(String serviceName, List<String> lines) async {
|
||||
try {
|
||||
final body = lines.join('\n');
|
||||
final stamp = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '-')
|
||||
.split('.')
|
||||
.first;
|
||||
final safeName = serviceName.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
|
||||
final path = await FilePicker.saveFile(
|
||||
dialogTitle: 'Save logs',
|
||||
fileName: '$safeName-logs-$stamp.txt',
|
||||
bytes: Uint8List.fromList(utf8.encode(body)),
|
||||
);
|
||||
connection.onLogsSaved(path);
|
||||
} catch (error) {
|
||||
handleError(error, () => saveLogs(serviceName, lines), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops any tail still running when the screen goes away.
|
||||
void stopLogs() => getDataManager().stopLogs();
|
||||
}
|
||||
722
lib/Peach/see/parent/ParentViewModel.dart
Normal file
722
lib/Peach/see/parent/ParentViewModel.dart
Normal file
@@ -0,0 +1,722 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../about/external/data/PeachError.dart';
|
||||
import '../../about/internal/application/NotificationType.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/Severity.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../about/internal/file/FileStorage.dart';
|
||||
import '../../comms/Comms.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../informatics/AppDataManager.dart';
|
||||
import '../../informatics/DataManager.dart';
|
||||
import '../../memory/InternalMemory.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
|
||||
/// The base every ViewModel extends. It owns the three cross-cutting overlays
|
||||
/// (loading, unreachable-server, error), the single [DataManager] instance, and
|
||||
/// the one error decision tree — so no screen has to reimplement any of them.
|
||||
///
|
||||
/// The Autoreceptive original guards calls with a *connectivity* check, which
|
||||
/// suits an app talking to one public API. Peach talks to whichever box the
|
||||
/// user configured — often on a LAN or VPN where the device has no internet at
|
||||
/// all — so the equivalent guard here is [canReach]: an actual SSH handshake
|
||||
/// with the target server.
|
||||
class ParentViewModel extends ChangeNotifier {
|
||||
OverlayEntry? loadingEntry;
|
||||
|
||||
OverlayEntry? networkEntry;
|
||||
|
||||
OverlayEntry? errorEntry;
|
||||
|
||||
late DataManager dataManager;
|
||||
|
||||
BuildContext context;
|
||||
|
||||
OverlayState? overlayState;
|
||||
|
||||
ParentViewModel(this.context) {
|
||||
overlayState = Overlay.of(context);
|
||||
dataManager = AppDataManager(InternalMemory(), SshService(), FileStorage());
|
||||
}
|
||||
|
||||
DataManager getDataManager() {
|
||||
return dataManager;
|
||||
}
|
||||
|
||||
// ── Loading overlay ────────────────────────────────────────────────────────
|
||||
|
||||
void showLoading(String loadingText) async {
|
||||
if (loadingEntry != null) return;
|
||||
_hideKeyboard();
|
||||
final jp = context.jp;
|
||||
loadingEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
backgroundColor: jp.bg.withValues(alpha: 0.86),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 5,
|
||||
color: colorPrimary,
|
||||
backgroundColor: jp.surface2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
text(
|
||||
loadingText,
|
||||
18,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
text(
|
||||
'Working on the server — keep this window open.',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayState?.insert(loadingEntry!);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void closeLoading() {
|
||||
if (loadingEntry != null) {
|
||||
loadingEntry?.remove();
|
||||
loadingEntry = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unreachable-server overlay ─────────────────────────────────────────────
|
||||
|
||||
/// Shown when the target server can't be reached at all. [actions] is the
|
||||
/// retry closure that re-runs whatever the user was doing.
|
||||
void noNetwork(Function() actions) async {
|
||||
if (networkEntry != null) return;
|
||||
_hideKeyboard();
|
||||
final jp = context.jp;
|
||||
networkEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
Widget hintRow(IconData icon, String label) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: colorPrimary.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: colorPrimary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: text(label, 13, TextType.Regular, color: jp.textDim),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: jp.bg,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 116,
|
||||
height: 116,
|
||||
decoration: BoxDecoration(
|
||||
color: colorPrimary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: colorPrimary.withValues(alpha: 0.20),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.cloud_off_rounded,
|
||||
size: 44,
|
||||
color: colorPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
text(
|
||||
"Server unreachable",
|
||||
22,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
"Peach couldn't open an SSH session to this server.",
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: jp.border),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
hintRow(
|
||||
Icons.dns_outlined,
|
||||
'Check the host and port are correct',
|
||||
),
|
||||
Divider(height: 1, color: jp.border),
|
||||
hintRow(
|
||||
Icons.vpn_key_outlined,
|
||||
'Confirm the username and password still work',
|
||||
),
|
||||
Divider(height: 1, color: jp.border),
|
||||
hintRow(
|
||||
Icons.lan_outlined,
|
||||
'Make sure you are on the right network or VPN',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: actions,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: text(
|
||||
'Try again',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayState?.insert(networkEntry!);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void closeNetwork() {
|
||||
if (networkEntry != null) {
|
||||
networkEntry?.remove();
|
||||
networkEntry = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard every server-bound call with this: it opens (or reuses) an SSH
|
||||
/// session to [server] and, on failure, raises the unreachable overlay wired
|
||||
/// to [actions] and returns false so the caller can bail out early.
|
||||
Future<bool> canReach(ServerProfile server, Function() actions) async {
|
||||
closeNetwork();
|
||||
try {
|
||||
await getDataManager().connect(server);
|
||||
return true;
|
||||
} catch (_) {
|
||||
closeLoading();
|
||||
noNetwork(actions);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error overlay ──────────────────────────────────────────────────────────
|
||||
|
||||
void showError(
|
||||
Function() actions,
|
||||
Function() closeActions,
|
||||
PeachError error,
|
||||
String buttonText,
|
||||
) async {
|
||||
if (errorEntry != null) return;
|
||||
_hideKeyboard();
|
||||
final jp = context.jp;
|
||||
final bool critical = error.severity == Severity.critical.name;
|
||||
errorEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
backgroundColor: jp.bg,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colorNegative.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: colorNegative.withValues(alpha: 0.22),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
critical
|
||||
? Icons.gpp_maybe_rounded
|
||||
: Icons.error_outline_rounded,
|
||||
size: 40,
|
||||
color: colorNegative,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
text(
|
||||
error.title,
|
||||
22,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
error.message,
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
height: 1.5,
|
||||
),
|
||||
if (error.helper.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: jp.border),
|
||||
),
|
||||
child: text(
|
||||
error.helper,
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: text(
|
||||
'Code ${error.code}',
|
||||
11,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
dismissError();
|
||||
actions();
|
||||
},
|
||||
child: text(
|
||||
buttonText,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
dismissError();
|
||||
closeActions();
|
||||
},
|
||||
child: text(
|
||||
'Dismiss',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayState?.insert(errorEntry!);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void dismissError() {
|
||||
if (errorEntry != null) {
|
||||
errorEntry?.remove();
|
||||
errorEntry = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard `catch` handler — every ViewModel routes failures here so the
|
||||
/// same class of problem always reads the same way to the user. Closes the
|
||||
/// loading overlay first, then maps the failure onto a [PeachError].
|
||||
void handleError(
|
||||
Object? error,
|
||||
Function() actions,
|
||||
Function() closeAction,
|
||||
String buttonName,
|
||||
) {
|
||||
closeLoading();
|
||||
|
||||
if (error is SSHAuthAbortError || error is SSHAuthFailError) {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 4010.01,
|
||||
message: "The server rejected these credentials.",
|
||||
helper:
|
||||
"Check the username and password on this server profile. If "
|
||||
"the account uses a key or 2FA, SSH password auth may be "
|
||||
"disabled on the host.",
|
||||
title: "Authentication failed",
|
||||
severity: Severity.critical.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
} else if (error is SocketException || error is SSHStateError) {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 5000.01,
|
||||
message: "Peach couldn't reach the server.",
|
||||
helper:
|
||||
"Confirm the host and port, that the machine is powered on, "
|
||||
"and that you're on the network or VPN that can see it.",
|
||||
title: "Connection failed",
|
||||
severity: Severity.message.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
} else if (error is TimeoutException) {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 5000.02,
|
||||
message: "The server took too long to respond.",
|
||||
helper:
|
||||
"The connection is slow or the command is still running. "
|
||||
"Retry, or check the server's load.",
|
||||
title: "Timed out",
|
||||
severity: Severity.message.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
} else if (error is SSHError) {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 5100.00,
|
||||
message: "The SSH session failed while running this operation.",
|
||||
helper: error.toString(),
|
||||
title: "SSH error",
|
||||
severity: Severity.message.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
} else if (error is FileSystemException) {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 6100.01,
|
||||
message: "A local file couldn't be read or written.",
|
||||
helper:
|
||||
"Check the artifact path still exists and that Peach has "
|
||||
"permission to read it.",
|
||||
title: "File error",
|
||||
severity: Severity.message.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
} else if (error is Exception) {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 6000.01,
|
||||
message: "An error occurred while processing your request.",
|
||||
helper: error.toString(),
|
||||
title: "Peach Error",
|
||||
severity: Severity.message.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
} else {
|
||||
showError(
|
||||
actions,
|
||||
closeAction,
|
||||
PeachError(
|
||||
code: 8700.02,
|
||||
message: "An error occurred while processing your request.",
|
||||
helper:
|
||||
"Relaunch Peach and try again. If the problem persists, "
|
||||
"check the server logs for what happened.",
|
||||
title: "Peach Error",
|
||||
severity: Severity.message.name,
|
||||
),
|
||||
buttonName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── In-app notification sheet ──────────────────────────────────────────────
|
||||
|
||||
/// A modal bottom sheet for confirmations and outcomes. [positiveAction]
|
||||
/// must call `Navigator.pop(context)` first to dismiss the sheet before it
|
||||
/// navigates anywhere.
|
||||
Future<void> showApplicationNotification(
|
||||
NotificationType type,
|
||||
String title,
|
||||
String description,
|
||||
bool enableDrag,
|
||||
bool barrierDismiss,
|
||||
VoidCallback? closeAction, {
|
||||
String? action,
|
||||
VoidCallback? positiveAction,
|
||||
}) async {
|
||||
final jp = context.jp;
|
||||
|
||||
Color actionColor;
|
||||
IconData actionIconData;
|
||||
|
||||
switch (type) {
|
||||
case NotificationType.success:
|
||||
actionIconData = Icons.check_circle_rounded;
|
||||
actionColor = colorPositive;
|
||||
break;
|
||||
case NotificationType.info:
|
||||
actionIconData = Icons.info_rounded;
|
||||
actionColor = colorAccent;
|
||||
break;
|
||||
case NotificationType.warning:
|
||||
actionIconData = Icons.warning_amber_rounded;
|
||||
actionColor = colorWarmYellow;
|
||||
break;
|
||||
case NotificationType.error:
|
||||
actionIconData = Icons.error_rounded;
|
||||
actionColor = colorNegative;
|
||||
break;
|
||||
}
|
||||
|
||||
final bool hasAction = action != null && positiveAction != null;
|
||||
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
enableDrag: enableDrag,
|
||||
isDismissible: barrierDismiss,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
border: Border.all(color: jp.border),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.border,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: actionColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: actionColor.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
actionIconData,
|
||||
size: 28,
|
||||
color: actionColor,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
closeAction?.call();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: jp.textDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: actionColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: actionColor.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: text(
|
||||
type.name.toUpperCase(),
|
||||
10,
|
||||
TextType.Bold,
|
||||
color: actionColor,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(title, 20, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: actionColor.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: actionColor.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: text(
|
||||
description,
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (hasAction) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: positiveAction,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: actionColor,
|
||||
),
|
||||
icon: Icon(actionIconData, size: 16),
|
||||
label: text(
|
||||
action,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
closeAction?.call();
|
||||
},
|
||||
child: text(
|
||||
hasAction ? "Cancel" : "Got it, dismiss",
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _hideKeyboard() {
|
||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
closeLoading();
|
||||
closeNetwork();
|
||||
dismissError();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
36
lib/Peach/see/redis/ConnectRedis.dart
Normal file
36
lib/Peach/see/redis/ConnectRedis.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import '../../about/external/data/Redis.dart';
|
||||
|
||||
/// One scan's worth of results — the key list plus the context needed to
|
||||
/// label it (per-database counts, DBSIZE, and per-key types when filtering).
|
||||
class RedisScan {
|
||||
final int databaseCount;
|
||||
final Map<int, int> databaseCounts;
|
||||
final int total;
|
||||
final List<String> keys;
|
||||
final Map<String, String> keyTypes;
|
||||
|
||||
const RedisScan({
|
||||
required this.databaseCount,
|
||||
required this.databaseCounts,
|
||||
required this.total,
|
||||
required this.keys,
|
||||
required this.keyTypes,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class ConnectRedis {
|
||||
/// A scan completed.
|
||||
void onScanned(RedisScan scan);
|
||||
|
||||
/// The scan couldn't run; [message] is already phrased for the user.
|
||||
void onScanFailed(String message);
|
||||
|
||||
/// A password was read out of the server's redis.conf.
|
||||
void onPasswordDetected(String password);
|
||||
|
||||
/// No `requirepass` was present in redis.conf.
|
||||
void onPasswordMissing();
|
||||
|
||||
/// One key's value was read.
|
||||
void onValueLoaded(RedisEntry entry);
|
||||
}
|
||||
14
lib/Peach/see/redis/Redis.dart
Normal file
14
lib/Peach/see/redis/Redis.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import 'RedisState.dart';
|
||||
|
||||
/// Browse and read Redis keys/values on the server via `redis-cli` over SSH.
|
||||
class Redis extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const Redis({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<Redis> createState() => RedisState();
|
||||
}
|
||||
792
lib/Peach/see/redis/RedisState.dart
Normal file
792
lib/Peach/see/redis/RedisState.dart
Normal file
@@ -0,0 +1,792 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/data/Redis.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import 'ConnectRedis.dart';
|
||||
import 'Redis.dart';
|
||||
import 'ViewRedis.dart';
|
||||
|
||||
class RedisState extends State<Redis> implements ConnectRedis {
|
||||
static const _types = [
|
||||
'all',
|
||||
'string',
|
||||
'list',
|
||||
'set',
|
||||
'hash',
|
||||
'zset',
|
||||
'stream',
|
||||
];
|
||||
|
||||
ViewRedis? _model;
|
||||
|
||||
final _host = TextEditingController(text: '127.0.0.1');
|
||||
final _port = TextEditingController(text: '6379');
|
||||
final _password = TextEditingController();
|
||||
final _pattern = TextEditingController(text: '*');
|
||||
|
||||
int _selectedDb = 0;
|
||||
int _dbCount = 16;
|
||||
Map<int, int> _dbCounts = {};
|
||||
String _typeFilter = 'all';
|
||||
|
||||
List<String> _keys = [];
|
||||
Map<String, String> _keyTypes = {};
|
||||
int _total = 0;
|
||||
bool _loading = true;
|
||||
bool _detecting = false;
|
||||
bool _connected = false;
|
||||
String? _error;
|
||||
|
||||
String? _selectedKey;
|
||||
RedisEntry? _entry;
|
||||
bool _loadingValue = false;
|
||||
bool _wide = false;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
int get _portNumber => int.tryParse(_port.text.trim()) ?? 6379;
|
||||
|
||||
String get _hostText =>
|
||||
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.dispose();
|
||||
_port.dispose();
|
||||
_password.dispose();
|
||||
_pattern.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewRedis>.reactive(
|
||||
viewModelBuilder: () => ViewRedis(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Redis · ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _loading ? null : _onScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_controls(),
|
||||
if (_loading) const LinearProgressIndicator(minHeight: 3),
|
||||
if (_error != null) _errorBar(),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
_wide = c.maxWidth >= 760;
|
||||
if (!_wide) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: _keyList(),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 4, child: _keyList()),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: _entry == null && !_loadingValue
|
||||
? _placeholder()
|
||||
: _valuePanel(_entry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewRedis 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 {
|
||||
_scan();
|
||||
}
|
||||
|
||||
void _scan({bool triedDetect = false}) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
_model?.scan(
|
||||
_server,
|
||||
host: _hostText,
|
||||
port: _portNumber,
|
||||
password: _password.text,
|
||||
database: _selectedDb,
|
||||
pattern: _pattern.text,
|
||||
typeFilter: _typeFilter,
|
||||
triedDetect: triedDetect,
|
||||
);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onScan() => _scan();
|
||||
|
||||
void _onPatternSubmitted(String _) => _scan();
|
||||
|
||||
void _onDetectPassword() {
|
||||
setState(() => _detecting = true);
|
||||
_model?.detectPassword(_server);
|
||||
}
|
||||
|
||||
void _onSelectDatabase(int db) {
|
||||
if (_selectedDb == db) return;
|
||||
setState(() => _selectedDb = db);
|
||||
_scan();
|
||||
}
|
||||
|
||||
void _onSelectType(String type) {
|
||||
if (_typeFilter == type) return;
|
||||
setState(() => _typeFilter = type);
|
||||
_scan();
|
||||
}
|
||||
|
||||
void _onSelectKey(String key) {
|
||||
setState(() {
|
||||
_selectedKey = key;
|
||||
_entry = null;
|
||||
_loadingValue = true;
|
||||
});
|
||||
_model?.loadValue(
|
||||
_server,
|
||||
key,
|
||||
host: _hostText,
|
||||
port: _portNumber,
|
||||
password: _password.text,
|
||||
database: _selectedDb,
|
||||
);
|
||||
}
|
||||
|
||||
void _onCopyValue(RedisEntry entry) {
|
||||
Clipboard.setData(ClipboardData(text: entry.value));
|
||||
_notify('Value copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
void _showValueSheet(RedisEntry entry) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: context.jp.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(sheetContext).size.height * 0.7,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _valuePanel(entry, inSheet: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── ConnectRedis ───────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onScanned(RedisScan scan) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connected = true;
|
||||
_dbCount = scan.databaseCount;
|
||||
_dbCounts = scan.databaseCounts;
|
||||
_total = scan.total;
|
||||
_keys = scan.keys;
|
||||
_keyTypes = scan.keyTypes;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onScanFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_loading = false;
|
||||
_error = message;
|
||||
_keys = [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onPasswordDetected(String password) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_password.text = password;
|
||||
_detecting = false;
|
||||
});
|
||||
_notify('Found the Redis password in redis.conf — connecting…');
|
||||
// triedDetect stops a second lookup if this password is also rejected.
|
||||
_scan(triedDetect: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onPasswordMissing() {
|
||||
if (!mounted) return;
|
||||
setState(() => _detecting = false);
|
||||
_notify('No requirepass found in redis.conf.');
|
||||
}
|
||||
|
||||
@override
|
||||
void onValueLoaded(RedisEntry entry) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entry = entry;
|
||||
_loadingValue = false;
|
||||
});
|
||||
if (!_wide) _showValueSheet(entry);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _controls() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: _host,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Host',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Port',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password (requirepass)',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _detecting ? null : _onDetectPassword,
|
||||
icon: _detecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.key, size: 18),
|
||||
label: text('Detect', 13, TextType.Bold, color: jp.textDim),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _pattern,
|
||||
onSubmitted: _onPatternSubmitted,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Key pattern',
|
||||
hintText: 'user:* · *',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _onScan,
|
||||
icon: const Icon(Icons.travel_explore, size: 18),
|
||||
label: text('Scan', 13, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_dbSelector(),
|
||||
const SizedBox(height: 10),
|
||||
_typeSelector(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dbSelector() {
|
||||
final jp = context.jp;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_selectorLabel(Icons.storage, 'Database'),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: List.generate(_dbCount, (i) {
|
||||
final selected = _selectedDb == i;
|
||||
final count = _dbCounts[i] ?? 0;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
onTap: _loading ? null : () => _onSelectDatabase(i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.14)
|
||||
: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.7)
|
||||
: jp.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
text(
|
||||
'db$i',
|
||||
12.5,
|
||||
selected ? TextType.Bold : TextType.Regular,
|
||||
color: selected ? PeachTheme.seed : jp.text,
|
||||
),
|
||||
if (count > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? PeachTheme.seed.withValues(alpha: 0.22)
|
||||
: jp.border.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: text(
|
||||
'$count',
|
||||
10.5,
|
||||
TextType.Bold,
|
||||
color: selected ? PeachTheme.seed : jp.textFaint,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeSelector() {
|
||||
final jp = context.jp;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_selectorLabel(Icons.category_outlined, 'Type filter'),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: _types.map((type) {
|
||||
final selected = _typeFilter == type;
|
||||
final color = type == 'all' ? PeachTheme.slate : _typeColor(type);
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
onTap: _loading ? null : () => _onSelectType(type),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? color.withValues(alpha: 0.14)
|
||||
: jp.surface2,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? color.withValues(alpha: 0.7)
|
||||
: jp.border,
|
||||
),
|
||||
),
|
||||
child: text(
|
||||
type == 'all' ? 'all types' : type,
|
||||
12.5,
|
||||
selected ? TextType.Bold : TextType.Regular,
|
||||
color: selected ? color : jp.text,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectorLabel(IconData icon, String label) => Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: context.jp.textFaint),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
label,
|
||||
11,
|
||||
TextType.Bold,
|
||||
color: context.jp.textFaint,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Color _typeColor(String type) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return PeachTheme.accent;
|
||||
case 'list':
|
||||
return PeachTheme.warn;
|
||||
case 'set':
|
||||
return PeachTheme.violet;
|
||||
case 'hash':
|
||||
return PeachTheme.seed;
|
||||
case 'zset':
|
||||
return PeachTheme.mint;
|
||||
case 'stream':
|
||||
return PeachTheme.pink;
|
||||
default:
|
||||
return PeachTheme.slate;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _errorBar() => Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.danger.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: PeachTheme.danger),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _keyList() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.key, size: 16, color: PeachTheme.seed),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'KEYS',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: jp.textDim,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'${_keys.length}',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
if (_total > 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
text(
|
||||
_pattern.text.trim() == '*'
|
||||
? 'of $_total'
|
||||
: '($_total total)',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
if (_keys.length >= 100000) ...[
|
||||
const SizedBox(width: 6),
|
||||
text('(capped)', 11, TextType.Regular, color: jp.textFaint),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: jp.border),
|
||||
Expanded(
|
||||
child: _keys.isEmpty
|
||||
? Center(
|
||||
child: text(
|
||||
_loading
|
||||
? 'Scanning…'
|
||||
: (_connected ? 'No keys match.' : 'Not connected.'),
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: _keys.length,
|
||||
separatorBuilder: (_, i) => Divider(
|
||||
height: 1,
|
||||
color: jp.border,
|
||||
indent: 12,
|
||||
endIndent: 12,
|
||||
),
|
||||
itemBuilder: (_, i) => _keyRow(_keys[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _keyRow(String key) {
|
||||
final jp = context.jp;
|
||||
final selected = _wide && key == _selectedKey;
|
||||
final type = _keyTypes[key];
|
||||
return InkWell(
|
||||
onTap: () => _onSelectKey(key),
|
||||
child: Container(
|
||||
color: selected ? PeachTheme.seed.withValues(alpha: 0.10) : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
if (type != null) ...[_typeBadge(type), const SizedBox(width: 9)],
|
||||
Expanded(
|
||||
child: text(
|
||||
key,
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: selected ? PeachTheme.seed : jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, size: 18, color: jp.textFaint),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeBadge(String type) {
|
||||
final color = _typeColor(type);
|
||||
return Container(
|
||||
width: 46,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: text(type, 9.5, TextType.Bold, color: color, letterSpacing: 0.2),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.data_object, size: 40, color: jp.textFaint),
|
||||
const SizedBox(height: 10),
|
||||
text(
|
||||
'Select a key to view its value',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _valuePanel(RedisEntry? entry, {bool inSheet = false}) {
|
||||
final jp = context.jp;
|
||||
if (_loadingValue) {
|
||||
return Container(
|
||||
decoration: inSheet ? null : PeachTheme.panel(context, radius: 16),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (entry == null) return _placeholder();
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: text(
|
||||
entry.key,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Copy value',
|
||||
onPressed: () => _onCopyValue(entry),
|
||||
icon: const Icon(Icons.copy_all, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_tag(entry.type.toUpperCase(), PeachTheme.accent),
|
||||
const SizedBox(width: 8),
|
||||
_tag('TTL ${entry.ttlLabel}', jp.textDim),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
entry.value.isEmpty ? '(empty)' : entry.value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
height: 1.4,
|
||||
color: ConsoleColors.body,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (inSheet) return content;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(String label, Color color) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(label, 11, TextType.Bold, color: color),
|
||||
);
|
||||
}
|
||||
177
lib/Peach/see/redis/ViewRedis.dart
Normal file
177
lib/Peach/see/redis/ViewRedis.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import '../../about/external/data/Redis.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectRedis.dart';
|
||||
|
||||
class ViewRedis extends ParentViewModel {
|
||||
ConnectRedis connection;
|
||||
|
||||
ViewRedis(super.context, this.connection);
|
||||
|
||||
/// Pings Redis, then reads the database list, the current DB's size and the
|
||||
/// keys matching [pattern]. On a NOAUTH bounce with no password entered, it
|
||||
/// reads `requirepass` out of redis.conf once and retries — the common case
|
||||
/// where the user has SSH access but hasn't been told the Redis password.
|
||||
void scan(
|
||||
ServerProfile server, {
|
||||
required String host,
|
||||
required int port,
|
||||
required String password,
|
||||
required int database,
|
||||
required String pattern,
|
||||
required String typeFilter,
|
||||
bool triedDetect = false,
|
||||
}) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => scan(
|
||||
server,
|
||||
host: host,
|
||||
port: port,
|
||||
password: password,
|
||||
database: database,
|
||||
pattern: pattern,
|
||||
typeFilter: typeFilter,
|
||||
triedDetect: triedDetect,
|
||||
),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final (ok, detail) = await getDataManager().redisPing(
|
||||
server,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
if (!triedDetect &&
|
||||
detail.toUpperCase().contains('NOAUTH') &&
|
||||
password.trim().isEmpty) {
|
||||
final found = await getDataManager().redisConfigPassword(server);
|
||||
if (found.isNotEmpty) {
|
||||
connection.onPasswordDetected(found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
connection.onScanFailed(_hint(detail, host, port));
|
||||
return;
|
||||
}
|
||||
|
||||
final effectivePattern = pattern.trim().isEmpty ? '*' : pattern.trim();
|
||||
|
||||
final (databaseCount, databaseCounts) = await getDataManager()
|
||||
.redisDatabases(server, host: host, port: port, pass: password);
|
||||
final total = await getDataManager().redisDbSize(
|
||||
server,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
|
||||
final keys = <String>[];
|
||||
final keyTypes = <String, String>{};
|
||||
|
||||
if (typeFilter == 'all') {
|
||||
keys.addAll(
|
||||
await getDataManager().redisKeys(
|
||||
server,
|
||||
pattern: effectivePattern,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final typed = await getDataManager().redisKeysTyped(
|
||||
server,
|
||||
pattern: effectivePattern,
|
||||
filterType: typeFilter,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
for (final (key, type) in typed) {
|
||||
keys.add(key);
|
||||
keyTypes[key] = type;
|
||||
}
|
||||
}
|
||||
|
||||
connection.onScanned(
|
||||
RedisScan(
|
||||
databaseCount: databaseCount,
|
||||
databaseCounts: databaseCounts,
|
||||
total: total,
|
||||
keys: keys,
|
||||
keyTypes: keyTypes,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
connection.onScanFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `requirepass` from the server's redis.conf.
|
||||
void detectPassword(ServerProfile server) async {
|
||||
if (!await canReach(server, () => detectPassword(server))) return;
|
||||
try {
|
||||
final found = await getDataManager().redisConfigPassword(server);
|
||||
if (found.isEmpty) {
|
||||
connection.onPasswordMissing();
|
||||
} else {
|
||||
connection.onPasswordDetected(found);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, () => detectPassword(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one key. A failure is surfaced as an `error`-typed entry rather
|
||||
/// than an overlay — the value panel is the natural place to show it.
|
||||
void loadValue(
|
||||
ServerProfile server,
|
||||
String key, {
|
||||
required String host,
|
||||
required int port,
|
||||
required String password,
|
||||
required int database,
|
||||
}) async {
|
||||
try {
|
||||
final entry = await getDataManager().redisGet(
|
||||
server,
|
||||
key,
|
||||
host: host,
|
||||
port: port,
|
||||
pass: password,
|
||||
db: database,
|
||||
);
|
||||
connection.onValueLoaded(entry);
|
||||
} catch (error) {
|
||||
connection.onValueLoaded(
|
||||
RedisEntry(key: key, type: 'error', ttl: '', value: '$error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a raw redis-cli complaint into something the user can act on.
|
||||
String _hint(String detail, String host, int port) {
|
||||
final message = detail.isEmpty ? 'Redis did not respond.' : detail;
|
||||
if (message.toUpperCase().contains('NOAUTH')) {
|
||||
return 'Redis requires a password. Enter it below (or tap Detect to read '
|
||||
'it from redis.conf), then Scan.';
|
||||
}
|
||||
if (message.toLowerCase().contains('wrongpass')) {
|
||||
return 'Wrong Redis password. Fix it and Scan again.';
|
||||
}
|
||||
if (message.toLowerCase().contains('connection refused')) {
|
||||
return 'Nothing is listening on $host:$port. Check the host/port '
|
||||
'(Redis in Docker often maps a different port).';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
10
lib/Peach/see/server/detail/ConnectServerDetail.dart
Normal file
10
lib/Peach/see/server/detail/ConnectServerDetail.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
|
||||
abstract class ConnectServerDetail {
|
||||
/// The stored copy of this server, re-read after any change.
|
||||
void onServerLoaded(ServerProfile server, List<DeploymentRecord> history);
|
||||
|
||||
/// The server (and its stored password) was removed.
|
||||
void onServerDeleted();
|
||||
}
|
||||
20
lib/Peach/see/server/detail/ServerDetail.dart
Normal file
20
lib/Peach/see/server/detail/ServerDetail.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'ServerDetailState.dart';
|
||||
|
||||
/// A single server: its connection, its tools, and the services on it.
|
||||
class ServerDetail extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
final List<DeploymentRecord> history;
|
||||
|
||||
const ServerDetail({
|
||||
super.key,
|
||||
required this.server,
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServerDetail> createState() => ServerDetailState();
|
||||
}
|
||||
591
lib/Peach/see/server/detail/ServerDetailState.dart
Normal file
591
lib/Peach/see/server/detail/ServerDetailState.dart
Normal file
@@ -0,0 +1,591 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/NavigatorType.dart';
|
||||
import '../../../about/internal/application/NotificationType.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../configs/Navigator.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import '../../configuration/Configuration.dart';
|
||||
import '../../deploy/batch/BatchDeploy.dart';
|
||||
import '../../deploy/single/Deploy.dart';
|
||||
import '../../docker/manager/DockerManager.dart';
|
||||
import '../../files/Files.dart';
|
||||
import '../../health/Health.dart';
|
||||
import '../../kafka/Kafka.dart';
|
||||
import '../../logs/Logs.dart';
|
||||
import '../../redis/Redis.dart';
|
||||
import '../../service/ServiceEditor.dart';
|
||||
import '../../systemd/Systemd.dart';
|
||||
import '../editor/ServerEditor.dart';
|
||||
import 'ConnectServerDetail.dart';
|
||||
import 'ServerDetail.dart';
|
||||
import 'ViewServerDetail.dart';
|
||||
|
||||
class ServerDetailState extends State<ServerDetail>
|
||||
implements ConnectServerDetail {
|
||||
ViewServerDetail? _model;
|
||||
|
||||
late ServerProfile _server;
|
||||
List<DeploymentRecord> _history = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_server = widget.server.copy();
|
||||
_history = widget.history;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServerDetail>.reactive(
|
||||
viewModelBuilder: () => ViewServerDetail(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(_server.name, 20, TextType.Bold, color: context.jp.text),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Edit server',
|
||||
onPressed: _onEditServer,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete server',
|
||||
onPressed: _onDeleteServer,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _onAddService,
|
||||
icon: const Icon(Icons.add),
|
||||
label: text('Add service', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
_connectionCard(),
|
||||
const SizedBox(height: 18),
|
||||
_toolsSection(),
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader(),
|
||||
const SizedBox(height: 10),
|
||||
if (_server.services.isEmpty)
|
||||
_noServices()
|
||||
else
|
||||
LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 760 ? 2 : 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final svc in _server.services)
|
||||
SizedBox(width: w, child: _serviceCard(svc)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServerDetail 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 {
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
// ── named handlers: server ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onEditServer() async {
|
||||
final edited = await PeachNavigation().navigateToPageWithData(
|
||||
ServerEditor(server: _server),
|
||||
context,
|
||||
);
|
||||
if (edited is! ServerProfile) return;
|
||||
// Preserve the services list on the edited connection.
|
||||
edited.services = _server.services;
|
||||
_model?.saveServer(edited);
|
||||
}
|
||||
|
||||
void _onDeleteServer() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
'Delete "${_server.name}"?',
|
||||
'Removes the server, its services and its stored password.',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Delete server',
|
||||
positiveAction: _onConfirmDeleteServer,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmDeleteServer() {
|
||||
Navigator.pop(context);
|
||||
_model?.deleteServer(_server);
|
||||
}
|
||||
|
||||
// ── named handlers: services ───────────────────────────────────────────────
|
||||
|
||||
Future<void> _onAddService() async {
|
||||
final created = await PeachNavigation().navigateToPageWithData(
|
||||
const ServiceEditor(),
|
||||
context,
|
||||
);
|
||||
if (created is! ServiceConfig) return;
|
||||
_model?.addService(_server, created);
|
||||
}
|
||||
|
||||
Future<void> _onEditService(ServiceConfig svc) async {
|
||||
final edited = await PeachNavigation().navigateToPageWithData(
|
||||
ServiceEditor(service: svc),
|
||||
context,
|
||||
);
|
||||
if (edited is! ServiceConfig) return;
|
||||
_model?.updateService(_server, edited);
|
||||
}
|
||||
|
||||
void _onDeleteService(ServiceConfig svc) =>
|
||||
_model?.removeService(_server, svc);
|
||||
|
||||
Future<void> _onDeploy(ServiceConfig svc) async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
Deploy(server: _server, service: svc),
|
||||
context,
|
||||
);
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
void _onViewLogs(ServiceConfig svc) {
|
||||
PeachNavigation().navigateToPage(
|
||||
NavigatorType.justOpen,
|
||||
Logs(server: _server, service: svc),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRegisterUnit(ServiceConfig? svc) async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
Systemd(server: _server, service: svc),
|
||||
context,
|
||||
);
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
// ── named handlers: tools ──────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onOpenBatch() async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
BatchDeploy(server: _server),
|
||||
context,
|
||||
);
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
void _onOpenConfiguration() => _open(Configuration(server: _server));
|
||||
|
||||
void _onOpenHealth() => _open(Health(servers: [_server]));
|
||||
|
||||
void _onOpenFiles() => _open(Files(server: _server));
|
||||
|
||||
void _onOpenDocker() => _open(DockerManager(server: _server));
|
||||
|
||||
void _onOpenRedis() => _open(Redis(server: _server));
|
||||
|
||||
void _onOpenKafka() => _open(Kafka(server: _server));
|
||||
|
||||
void _onNewUnit() => _onRegisterUnit(null);
|
||||
|
||||
void _open(Widget destination) => PeachNavigation().navigateToPage(
|
||||
NavigatorType.justOpen,
|
||||
destination,
|
||||
context,
|
||||
);
|
||||
|
||||
// ── ConnectServerDetail ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onServerLoaded(ServerProfile server, List<DeploymentRecord> history) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server = server;
|
||||
_history = history;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onServerDeleted() {
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _connectionCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_line(
|
||||
Icons.dns,
|
||||
'${_server.username}@${_server.host}:${_server.port}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_line(
|
||||
_server.useSudo ? Icons.shield : Icons.shield_outlined,
|
||||
_server.useSudo ? 'sudo enabled' : 'no sudo',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_line(Icons.lock, 'password stored encrypted'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _line(IconData icon, String label) => Row(
|
||||
children: [
|
||||
Icon(icon, size: 15, color: PeachTheme.seed.withValues(alpha: 0.9)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: text(label, 13, TextType.Regular, color: context.jp.textDim),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _toolsSection() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 520 ? 4 : 2;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
_tool(
|
||||
w,
|
||||
Icons.rocket_launch,
|
||||
'Build & deploy all',
|
||||
'Batch build + deploy',
|
||||
PeachTheme.seed,
|
||||
_onOpenBatch,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.fact_check_outlined,
|
||||
'Configuration',
|
||||
'Environment & setup',
|
||||
PeachTheme.warn,
|
||||
_onOpenConfiguration,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.monitor_heart,
|
||||
'Health',
|
||||
'Service status',
|
||||
PeachTheme.danger,
|
||||
_onOpenHealth,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.folder_open,
|
||||
'Files',
|
||||
'Browse / transfer',
|
||||
PeachTheme.accent,
|
||||
_onOpenFiles,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.inventory_2,
|
||||
'Docker',
|
||||
'Containers & images',
|
||||
PeachTheme.accent,
|
||||
_onOpenDocker,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.data_object,
|
||||
'Redis',
|
||||
'Browse keys / values',
|
||||
PeachTheme.warn,
|
||||
_onOpenRedis,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.hub,
|
||||
'Kafka',
|
||||
'Topics · groups · lag',
|
||||
PeachTheme.violet,
|
||||
_onOpenKafka,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.miscellaneous_services,
|
||||
'New unit',
|
||||
'systemd wizard',
|
||||
PeachTheme.violet,
|
||||
_onNewUnit,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tool(
|
||||
double w,
|
||||
IconData icon,
|
||||
String title,
|
||||
String sub,
|
||||
Color color,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
final jp = context.jp;
|
||||
return SizedBox(
|
||||
width: w,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
title,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
text(
|
||||
sub,
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader() => Row(
|
||||
children: [
|
||||
text(
|
||||
'SERVICES',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'${_server.services.length}',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _serviceCard(ServiceConfig svc) {
|
||||
final jp = context.jp;
|
||||
final count = _history.where((h) => h.serviceId == svc.id).length;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: (svc.isWeb ? PeachTheme.accent : PeachTheme.seed)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(
|
||||
svc.isWeb
|
||||
? Icons.language
|
||||
: (svc.isGeneric
|
||||
? Icons.terminal
|
||||
: Icons.settings_suggest),
|
||||
color: svc.isWeb ? PeachTheme.accent : PeachTheme.seed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
svc.name,
|
||||
15,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
svc.isWeb
|
||||
? svc.remoteRoot
|
||||
: svc.remotePath(
|
||||
svc.remoteJarName.isEmpty
|
||||
? '<jar>'
|
||||
: svc.remoteJarName,
|
||||
),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!svc.isWeb)
|
||||
IconButton(
|
||||
onPressed: () => _onRegisterUnit(svc),
|
||||
icon: const Icon(
|
||||
Icons.settings_applications_outlined,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Register systemd unit',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _onEditService(svc),
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
tooltip: 'Edit',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _onDeleteService(svc),
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (svc.isWeb)
|
||||
_tag('◆ ${svc.framework.label}')
|
||||
else if (svc.serviceName.isNotEmpty)
|
||||
_tag('unit: ${svc.serviceName}'),
|
||||
if (svc.canBuild) _tag('⚙ builds from source'),
|
||||
if (count > 0) _tag('$count deploys'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _onViewLogs(svc),
|
||||
icon: const Icon(Icons.article_outlined, size: 18),
|
||||
label: text('Logs', 13, TextType.Bold, color: jp.textDim),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: jp.textDim,
|
||||
side: BorderSide(color: jp.border),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _onDeploy(svc),
|
||||
icon: const Icon(Icons.rocket_launch, size: 18),
|
||||
label: text('Deploy', 13, TextType.Bold, color: colorWhite),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(String label) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jp.surface2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(label, 11, TextType.Regular, color: context.jp.textDim),
|
||||
);
|
||||
|
||||
Widget _noServices() => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 30),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_suggest,
|
||||
size: 40,
|
||||
color: PeachTheme.seed.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text('No services yet', 14, TextType.Bold, color: context.jp.text),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'Add a service to deploy a jar to this server.',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
87
lib/Peach/see/server/detail/ViewServerDetail.dart
Normal file
87
lib/Peach/see/server/detail/ViewServerDetail.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectServerDetail.dart';
|
||||
|
||||
class ViewServerDetail extends ParentViewModel {
|
||||
ConnectServerDetail connection;
|
||||
|
||||
ViewServerDetail(super.context, this.connection);
|
||||
|
||||
/// Re-reads this server from storage along with the deployment history, so
|
||||
/// the screen always reflects what was actually persisted.
|
||||
void loadServer(String serverId) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final history = await getDataManager().getDeployments();
|
||||
final stored = servers.where((s) => s.id == serverId).firstOrNull;
|
||||
if (stored == null) {
|
||||
connection.onServerDeleted();
|
||||
return;
|
||||
}
|
||||
connection.onServerLoaded(stored, history);
|
||||
} catch (error) {
|
||||
handleError(error, () => loadServer(serverId), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [server] — used after editing the connection, or after adding,
|
||||
/// editing or removing one of its services.
|
||||
void saveServer(ServerProfile server) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final index = servers.indexWhere((s) => s.id == server.id);
|
||||
if (index >= 0) {
|
||||
servers[index] = server;
|
||||
} else {
|
||||
servers.add(server);
|
||||
}
|
||||
await getDataManager().setServers(servers);
|
||||
loadServer(server.id);
|
||||
} catch (error) {
|
||||
handleError(error, () => saveServer(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds [service] to [server] and persists.
|
||||
void addService(ServerProfile server, ServiceConfig service) {
|
||||
server.services.add(service);
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
/// Replaces the stored copy of [service] on [server].
|
||||
void updateService(ServerProfile server, ServiceConfig service) {
|
||||
final index = server.services.indexWhere((s) => s.id == service.id);
|
||||
if (index >= 0) {
|
||||
server.services[index] = service;
|
||||
} else {
|
||||
server.services.add(service);
|
||||
}
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
void removeService(ServerProfile server, ServiceConfig service) {
|
||||
server.services.removeWhere((s) => s.id == service.id);
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
/// Saves the order services are deployed in during a batch run.
|
||||
void saveDeployOrder(ServerProfile server, List<String> ids) {
|
||||
server.deployOrder = ids;
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
void deleteServer(ServerProfile server) async {
|
||||
showLoading("Removing ${server.name} …");
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
servers.removeWhere((s) => s.id == server.id);
|
||||
await getDataManager().setServers(servers);
|
||||
await getDataManager().deleteServerSecret(server.id);
|
||||
closeLoading();
|
||||
connection.onServerDeleted();
|
||||
} catch (error) {
|
||||
handleError(error, () => deleteServer(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
lib/Peach/see/server/editor/ConnectServerEditor.dart
Normal file
6
lib/Peach/see/server/editor/ConnectServerEditor.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
|
||||
abstract class ConnectServerEditor {
|
||||
/// The connection details were verified against the live server.
|
||||
void onConnectionVerified(ServerProfile server);
|
||||
}
|
||||
16
lib/Peach/see/server/editor/ServerEditor.dart
Normal file
16
lib/Peach/see/server/editor/ServerEditor.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'ServerEditorState.dart';
|
||||
|
||||
/// Edits a server's connection details only. Services are managed on the
|
||||
/// server detail screen.
|
||||
class ServerEditor extends StatefulWidget {
|
||||
/// null = register a new server.
|
||||
final ServerProfile? server;
|
||||
|
||||
const ServerEditor({super.key, this.server});
|
||||
|
||||
@override
|
||||
State<ServerEditor> createState() => ServerEditorState();
|
||||
}
|
||||
186
lib/Peach/see/server/editor/ServerEditorState.dart
Normal file
186
lib/Peach/see/server/editor/ServerEditorState.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../designs/input/InputFields.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import '../../../utils/Validators.dart';
|
||||
import 'ConnectServerEditor.dart';
|
||||
import 'ServerEditor.dart';
|
||||
import 'ViewServerEditor.dart';
|
||||
|
||||
class ServerEditorState extends State<ServerEditor>
|
||||
implements ConnectServerEditor {
|
||||
ViewServerEditor? _model;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final ServerProfile _server;
|
||||
bool _showPassword = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_server = widget.server?.copy() ?? ServerProfile();
|
||||
}
|
||||
|
||||
bool get _isNew => widget.server == null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServerEditor>.reactive(
|
||||
viewModelBuilder: () => ViewServerEditor(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
_isNew ? 'Register server' : 'Edit server',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: _onTestConnection,
|
||||
icon: const Icon(Icons.wifi_tethering, size: 18),
|
||||
label: text('Test', 13, TextType.Bold, color: colorPrimary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12, left: 8),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _onSave,
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: text('Save', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 40),
|
||||
children: [
|
||||
inputSection(context, 'Identity'),
|
||||
inputField(
|
||||
label: 'Server name',
|
||||
hint: 'prod-1',
|
||||
initial: _server.name,
|
||||
onSaved: _onSaveName,
|
||||
validator: requiredField,
|
||||
),
|
||||
inputSection(context, 'Connection'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: inputField(
|
||||
label: 'Host / IP',
|
||||
hint: '10.0.0.5 or example.com',
|
||||
initial: _server.host,
|
||||
onSaved: _onSaveHost,
|
||||
validator: validHost,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: inputField(
|
||||
label: 'Port',
|
||||
initial: _server.port.toString(),
|
||||
keyboardType: TextInputType.number,
|
||||
onSaved: _onSavePort,
|
||||
validator: validPort,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
inputField(
|
||||
label: 'Username',
|
||||
hint: 'root / ubuntu',
|
||||
initial: _server.username,
|
||||
onSaved: _onSaveUsername,
|
||||
validator: requiredField,
|
||||
),
|
||||
inputField(
|
||||
label: 'Password',
|
||||
initial: _server.password,
|
||||
obscure: !_showPassword,
|
||||
onSaved: _onSavePassword,
|
||||
suffix: IconButton(
|
||||
icon: Icon(
|
||||
_showPassword ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: _onTogglePassword,
|
||||
),
|
||||
),
|
||||
inputHint(
|
||||
context,
|
||||
Icons.lock,
|
||||
'Stored encrypted in your OS keystore '
|
||||
'(Keychain / Keystore / DPAPI / libsecret).',
|
||||
),
|
||||
inputSwitch(
|
||||
context: context,
|
||||
title: 'Use sudo for privileged commands',
|
||||
subtitle:
|
||||
'Sends the password to sudo -S for systemctl / mkdir / rm.',
|
||||
value: _server.useSudo,
|
||||
onChanged: _onToggleSudo,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServerEditor 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 {}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onSaveName(String v) => _server.name = v;
|
||||
|
||||
void _onSaveHost(String v) => _server.host = v;
|
||||
|
||||
void _onSavePort(String v) => _server.port = int.tryParse(v) ?? 22;
|
||||
|
||||
void _onSaveUsername(String v) => _server.username = v;
|
||||
|
||||
void _onSavePassword(String v) => _server.password = v;
|
||||
|
||||
void _onTogglePassword() => setState(() => _showPassword = !_showPassword);
|
||||
|
||||
void _onToggleSudo(bool v) => setState(() => _server.useSudo = v);
|
||||
|
||||
/// Commits the form and hands the profile back to whoever opened the editor.
|
||||
void _onSave() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
Navigator.of(context).pop(_server);
|
||||
}
|
||||
|
||||
/// Verifies the details as typed, without leaving the editor.
|
||||
void _onTestConnection() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
_model?.testConnection(_server);
|
||||
}
|
||||
|
||||
// ── ConnectServerEditor ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onConnectionVerified(ServerProfile server) {
|
||||
// The success sheet raised by the ViewModel is the whole outcome; the form
|
||||
// keeps its values so the user can save straight away.
|
||||
}
|
||||
}
|
||||
32
lib/Peach/see/server/editor/ViewServerEditor.dart
Normal file
32
lib/Peach/see/server/editor/ViewServerEditor.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import '../../../about/internal/application/NotificationType.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectServerEditor.dart';
|
||||
|
||||
class ViewServerEditor extends ParentViewModel {
|
||||
ConnectServerEditor connection;
|
||||
|
||||
ViewServerEditor(super.context, this.connection);
|
||||
|
||||
/// Opens an SSH session with the details as entered, so the user finds out
|
||||
/// now — not at first deploy — whether they work.
|
||||
void testConnection(ServerProfile server) async {
|
||||
showLoading("Connecting to ${server.host} …");
|
||||
try {
|
||||
await getDataManager().connect(server);
|
||||
closeLoading();
|
||||
connection.onConnectionVerified(server);
|
||||
showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Connection verified",
|
||||
"Peach signed in to ${server.username}@${server.host}:${server.port} "
|
||||
"successfully.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, () => testConnection(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
12
lib/Peach/see/server/setup/ConnectServerSetup.dart
Normal file
12
lib/Peach/see/server/setup/ConnectServerSetup.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import '../../../comms/Comms.dart' show DeployResult;
|
||||
|
||||
abstract class ConnectServerSetup {
|
||||
/// The server answered: its package manager and what's already installed.
|
||||
void onServerProbed(String packageManager, Map<String, String> versions);
|
||||
|
||||
/// The server couldn't be reached or probed.
|
||||
void onProbeFailed(String message);
|
||||
|
||||
/// The install finished; [versions] carries the re-probed tools.
|
||||
void onInstallFinished(DeployResult result, Map<String, String> versions);
|
||||
}
|
||||
15
lib/Peach/see/server/setup/ServerSetup.dart
Normal file
15
lib/Peach/see/server/setup/ServerSetup.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'ServerSetupState.dart';
|
||||
|
||||
/// Guided onboarding after registering a new server: connect → show what the
|
||||
/// environment already has → ask what else to set up → install it.
|
||||
class ServerSetup extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const ServerSetup({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<ServerSetup> createState() => ServerSetupState();
|
||||
}
|
||||
652
lib/Peach/see/server/setup/ServerSetupState.dart
Normal file
652
lib/Peach/see/server/setup/ServerSetupState.dart
Normal file
@@ -0,0 +1,652 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/external/data/EnvCheck.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/StepTracker.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../comms/Comms.dart' show DeployResult, LogLine;
|
||||
import '../../../designs/LogConsole.dart';
|
||||
import '../../../designs/StepList.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import 'ConnectServerSetup.dart';
|
||||
import 'ServerSetup.dart';
|
||||
import 'ViewServerSetup.dart';
|
||||
|
||||
class ServerSetupState extends State<ServerSetup>
|
||||
implements ConnectServerSetup {
|
||||
static const _labels = ['Connect', 'Choose', 'Install', 'Done'];
|
||||
|
||||
ViewServerSetup? _model;
|
||||
|
||||
StreamSubscription<LogLine>? _logSub;
|
||||
StreamSubscription<List<DeployStep>>? _stepSub;
|
||||
|
||||
final _lines = <LogLine>[];
|
||||
List<DeployStep> _steps = [];
|
||||
final Map<String, String> _versions = {};
|
||||
final Set<String> _selected = {};
|
||||
|
||||
int _step = 0; // 0 connect · 1 choose · 2 install · 3 done
|
||||
bool _connecting = true;
|
||||
bool _connected = false;
|
||||
String? _connectError;
|
||||
String _packageManager = 'unknown';
|
||||
bool _installing = false;
|
||||
DeployResult? _result;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty;
|
||||
|
||||
List<EnvTool> get _toInstall =>
|
||||
kEnvTools.where((t) => _selected.contains(t.key)).toList();
|
||||
|
||||
int get _presentCount => kEnvTools.where((t) => _installed(t.key)).length;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSub?.cancel();
|
||||
_stepSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServerSetup>.reactive(
|
||||
viewModelBuilder: () => ViewServerSetup(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Set up ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _onSkip,
|
||||
child: text('Skip', 13, TextType.Bold, color: colorPrimary),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_progressBar(),
|
||||
Expanded(
|
||||
child: switch (_step) {
|
||||
0 => _connectStep(),
|
||||
1 => _chooseStep(viewModel),
|
||||
2 => _installStep(viewModel),
|
||||
_ => _doneStep(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServerSetup 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);
|
||||
_stepSub = _model?.steps.listen(_onSteps);
|
||||
_model?.probeServer(_server);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
void _onSteps(List<DeployStep> steps) {
|
||||
if (!mounted) return;
|
||||
setState(() => _steps = steps);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onSkip() => Navigator.of(context).pop();
|
||||
|
||||
void _onFinish() => Navigator.of(context).pop(true);
|
||||
|
||||
void _onRetryConnect() {
|
||||
setState(() {
|
||||
_connecting = true;
|
||||
_connectError = null;
|
||||
});
|
||||
_model?.probeServer(_server);
|
||||
}
|
||||
|
||||
void _onGoToChoose() => setState(() => _step = 1);
|
||||
|
||||
void _onGoToConnect() => setState(() => _step = 0);
|
||||
|
||||
void _onGoToInstall() => setState(() => _step = 2);
|
||||
|
||||
void _onToggleTool(EnvTool tool, bool? selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selected.add(tool.key);
|
||||
} else {
|
||||
_selected.remove(tool.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onInstall(ViewServerSetup model) {
|
||||
setState(() {
|
||||
_installing = true;
|
||||
_lines.clear();
|
||||
_result = null;
|
||||
});
|
||||
model.installEnvironment(
|
||||
_server,
|
||||
_toInstall,
|
||||
model.buildInstallCommand(_toInstall, _packageManager),
|
||||
);
|
||||
}
|
||||
|
||||
void _onClearLogs() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopyLogs() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectServerSetup ─────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onServerProbed(String packageManager, Map<String, String> versions) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_packageManager = packageManager;
|
||||
_versions
|
||||
..clear()
|
||||
..addAll(versions);
|
||||
_connected = true;
|
||||
_connecting = false;
|
||||
_connectError = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onProbeFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
_connectError = message;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallFinished(DeployResult result, Map<String, String> versions) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_installing = false;
|
||||
_result = result;
|
||||
_versions.addAll(versions);
|
||||
if (result.ok) _step = 3;
|
||||
});
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _progressBar() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _labels.length; i++) ...[
|
||||
_dot(i, _labels[i]),
|
||||
if (i < _labels.length - 1)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 2,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
color: i < _step ? PeachTheme.seed : jp.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dot(int index, String label) {
|
||||
final jp = context.jp;
|
||||
final done = index < _step;
|
||||
final active = index == _step;
|
||||
final color = done || active ? PeachTheme.seed : jp.textFaint;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: active ? 0.18 : (done ? 0.14 : 0.0)),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color.withValues(alpha: 0.6)),
|
||||
),
|
||||
child: done
|
||||
? const Icon(Icons.check, size: 14, color: PeachTheme.seed)
|
||||
: text('${index + 1}', 12, TextType.Bold, color: color),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
label,
|
||||
12.5,
|
||||
active ? TextType.Bold : TextType.Regular,
|
||||
color: active ? jp.text : jp.textFaint,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _connectStep() {
|
||||
final jp = context.jp;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_connecting) ...[
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
text(
|
||||
'Connecting to ${_server.host} …',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
] else if (_connected) ...[
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: PeachTheme.seed,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text('Connected', 18, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
'Package manager: $_packageManager · '
|
||||
'$_presentCount/${kEnvTools.length} tools present',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
] else ...[
|
||||
const Icon(Icons.error, color: PeachTheme.danger, size: 44),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
'Could not connect',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
_connectError ?? '',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
if (!_connected && !_connecting) ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _onRetryConnect,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: text('Retry', 14, TextType.Bold, color: jp.textDim),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _connected ? _onGoToChoose : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: text(
|
||||
'Choose environment',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chooseStep(ViewServerSetup model) {
|
||||
final jp = context.jp;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
text(
|
||||
'What should this server run?',
|
||||
18,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'Tick anything missing you want Peach to install '
|
||||
'(via $_packageManager). Installed tools are already ready.',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final tool in kEnvTools) _toolTile(tool),
|
||||
],
|
||||
),
|
||||
),
|
||||
_bottomBar(
|
||||
back: _onGoToConnect,
|
||||
nextLabel: _selected.isEmpty
|
||||
? 'Nothing to install'
|
||||
: 'Review & install',
|
||||
onNext: _selected.isEmpty ? null : _onGoToInstall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolTile(EnvTool tool) {
|
||||
final jp = context.jp;
|
||||
final installed = _installed(tool.key);
|
||||
final selected = _selected.contains(tool.key);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: PeachTheme.panel(context, radius: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: (installed ? PeachTheme.seed : jp.textFaint).withValues(
|
||||
alpha: 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Icon(
|
||||
tool.icon,
|
||||
color: installed ? PeachTheme.seed : jp.textDim,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(tool.label, 14, TextType.Bold, color: jp.text),
|
||||
text(
|
||||
installed
|
||||
? (_versions[tool.key] ?? 'installed')
|
||||
: 'not installed',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: installed ? jp.textDim : jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (installed)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(Icons.check_circle, color: PeachTheme.seed),
|
||||
)
|
||||
else
|
||||
Checkbox(value: selected, onChanged: (v) => _onToggleTool(tool, v)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _installStep(ViewServerSetup model) {
|
||||
final jp = context.jp;
|
||||
final command = model.buildInstallCommand(_toInstall, _packageManager);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
text('Review', 18, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'These will be installed${_server.useSudo ? ' via sudo' : ''}:',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _toInstall
|
||||
.map(
|
||||
(t) => Chip(
|
||||
avatar: Icon(t.icon, size: 16),
|
||||
label: Text(t.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: SelectableText(
|
||||
command,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
color: ConsoleColors.body,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepList(steps: _steps),
|
||||
],
|
||||
if (_result != null && !_result!.ok) ...[
|
||||
const SizedBox(height: 12),
|
||||
_banner(_result!),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 240,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_bottomBar(
|
||||
back: _installing ? null : _onGoToChoose,
|
||||
nextLabel: _installing ? 'Installing…' : 'Install now',
|
||||
onNext: _installing ? null : () => _onInstall(model),
|
||||
nextIcon: Icons.download,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _doneStep() {
|
||||
final jp = context.jp;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: PeachTheme.brandGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: PeachTheme.glow(
|
||||
PeachTheme.seed,
|
||||
opacity: 0.5,
|
||||
blur: 28,
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.check, color: colorWhite, size: 38),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
text(
|
||||
'${_server.name} is ready',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
'$_presentCount/${kEnvTools.length} tools installed. '
|
||||
'Add a service to start deploying.',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _onFinish,
|
||||
icon: const Icon(Icons.check),
|
||||
label: text('Finish', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar({
|
||||
VoidCallback? back,
|
||||
required String nextLabel,
|
||||
VoidCallback? onNext,
|
||||
IconData nextIcon = Icons.arrow_forward,
|
||||
}) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: back,
|
||||
child: text('Back', 13, TextType.Bold, color: context.jp.textDim),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onNext,
|
||||
icon: _installing
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: Icon(nextIcon),
|
||||
label: text(nextLabel, 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _banner(DeployResult result) {
|
||||
final color = result.ok ? PeachTheme.seed : PeachTheme.danger;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(result.ok ? Icons.check_circle : Icons.error, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(result.message, 14, TextType.Regular, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/Peach/see/server/setup/ViewServerSetup.dart
Normal file
76
lib/Peach/see/server/setup/ViewServerSetup.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import '../../../about/external/data/EnvCheck.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/StepTracker.dart';
|
||||
import '../../../comms/Comms.dart' show LogLine;
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectServerSetup.dart';
|
||||
|
||||
class ViewServerSetup extends ParentViewModel {
|
||||
ConnectServerSetup connection;
|
||||
|
||||
ViewServerSetup(super.context, this.connection);
|
||||
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
|
||||
|
||||
/// Step 1: prove the credentials work and read what's already there. The
|
||||
/// failure is reported inline rather than as an overlay — on this screen a
|
||||
/// failed connection is a step of the wizard, not an interruption.
|
||||
void probeServer(ServerProfile server) async {
|
||||
try {
|
||||
final packageManager = await getDataManager().detectPackageManager(
|
||||
server,
|
||||
);
|
||||
final versions = await getDataManager().probeEnvironment(
|
||||
server,
|
||||
kEnvTools.map((t) => '${t.key} ${t.detect}').toList(),
|
||||
);
|
||||
connection.onServerProbed(packageManager, versions);
|
||||
} catch (error) {
|
||||
connection.onProbeFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 3: install everything the user ticked in one package-manager call,
|
||||
/// enable the units that need it, then re-probe those tools.
|
||||
void installEnvironment(
|
||||
ServerProfile server,
|
||||
List<EnvTool> tools,
|
||||
String command,
|
||||
) async {
|
||||
try {
|
||||
final result = await getDataManager().installEnvironment(
|
||||
server: server,
|
||||
command: command,
|
||||
units: tools.where((t) => t.unit != null).map((t) => t.unit!).toList(),
|
||||
);
|
||||
|
||||
var versions = <String, String>{};
|
||||
try {
|
||||
versions = await getDataManager().probeEnvironment(
|
||||
server,
|
||||
tools.map((t) => '${t.key} ${t.detect}').toList(),
|
||||
);
|
||||
} catch (_) {
|
||||
// The install result is what matters; a failed re-probe just leaves
|
||||
// the tiles showing their previous state.
|
||||
}
|
||||
|
||||
connection.onInstallFinished(result, versions);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => installEnvironment(server, tools, command),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The single command that installs every selected tool.
|
||||
String buildInstallCommand(List<EnvTool> tools, String packageManager) {
|
||||
final packages = tools.map((t) => t.packagesFor(packageManager)).join(' ');
|
||||
return installCommandFor(packageManager, packages);
|
||||
}
|
||||
}
|
||||
4
lib/Peach/see/service/ConnectServiceEditor.dart
Normal file
4
lib/Peach/see/service/ConnectServiceEditor.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
abstract class ConnectServiceEditor {
|
||||
/// A local project folder was chosen for the build step.
|
||||
void onProjectDirectoryPicked(String path);
|
||||
}
|
||||
16
lib/Peach/see/service/ServiceEditor.dart
Normal file
16
lib/Peach/see/service/ServiceEditor.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import 'ServiceEditorState.dart';
|
||||
|
||||
/// Edits a single service belonging to a server (jar service, web app or a
|
||||
/// generic binary).
|
||||
class ServiceEditor extends StatefulWidget {
|
||||
/// null = create new.
|
||||
final ServiceConfig? service;
|
||||
|
||||
const ServiceEditor({super.key, this.service});
|
||||
|
||||
@override
|
||||
State<ServiceEditor> createState() => ServiceEditorState();
|
||||
}
|
||||
454
lib/Peach/see/service/ServiceEditorState.dart
Normal file
454
lib/Peach/see/service/ServiceEditorState.dart
Normal file
@@ -0,0 +1,454 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../designs/input/InputFields.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../../utils/Validators.dart';
|
||||
import 'ConnectServiceEditor.dart';
|
||||
import 'ServiceEditor.dart';
|
||||
import 'ViewServiceEditor.dart';
|
||||
|
||||
class ServiceEditorState extends State<ServiceEditor>
|
||||
implements ConnectServiceEditor {
|
||||
ViewServiceEditor? _model;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final ServiceConfig _svc;
|
||||
late final TextEditingController _projectPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_svc = widget.service?.copy() ?? ServiceConfig();
|
||||
_projectPath = TextEditingController(text: _svc.localProjectPath);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_projectPath.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isNew => widget.service == null;
|
||||
bool get _isWeb => _svc.kind == ServiceKind.webApp;
|
||||
bool get _isGeneric => _svc.kind == ServiceKind.generic;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServiceEditor>.reactive(
|
||||
viewModelBuilder: () => ViewServiceEditor(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
_isNew ? 'New service' : 'Edit service',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _onSave,
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: text('Save', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 40),
|
||||
children: [
|
||||
inputField(
|
||||
label: 'Service label',
|
||||
hint: _isWeb ? 'marketing-site' : 'orders-api',
|
||||
initial: _svc.name,
|
||||
onSaved: _onSaveName,
|
||||
validator: requiredField,
|
||||
),
|
||||
inputSection(context, 'Deployment type'),
|
||||
_kindSelector(),
|
||||
if (_isWeb)
|
||||
..._webFields()
|
||||
else if (_isGeneric)
|
||||
..._genericFields()
|
||||
else
|
||||
..._jarFields(),
|
||||
inputSection(context, 'Build from source (optional)'),
|
||||
_sectionHint(_buildBlurb(viewModel)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: TextFormField(
|
||||
controller: _projectPath,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Local project path',
|
||||
hintText: r'C:\code\app or /home/me/app',
|
||||
suffixIcon: IconButton(
|
||||
tooltip: 'Choose folder',
|
||||
icon: const Icon(Icons.folder_open),
|
||||
onPressed: _onPickProjectDir,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
inputField(
|
||||
label: 'Build command (optional)',
|
||||
hint: _buildCommandHint(),
|
||||
initial: _svc.buildCommand,
|
||||
onSaved: _onSaveBuildCommand,
|
||||
),
|
||||
if (_isWeb)
|
||||
inputField(
|
||||
label: 'Build output dir (optional)',
|
||||
hint: 'blank → auto (dist/ · build/ · build/web)',
|
||||
initial: _svc.outputSubdir,
|
||||
onSaved: _onSaveOutputSubdir,
|
||||
)
|
||||
else
|
||||
inputField(
|
||||
label: _isGeneric
|
||||
? 'Built artifact path (optional)'
|
||||
: 'Built jar pattern (optional)',
|
||||
hint: _isGeneric
|
||||
? 'e.g. target/release/myapp (explicit path)'
|
||||
: 'blank → target/*.jar or build/libs/*.jar',
|
||||
initial: _svc.jarPattern,
|
||||
onSaved: _onSaveJarPattern,
|
||||
),
|
||||
..._healthSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServiceEditor 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 {}
|
||||
|
||||
String _buildBlurb(ViewServiceEditor model) {
|
||||
if (!model.canBuildLocally) return 'Local builds run on desktop only.';
|
||||
return _isWeb
|
||||
? 'Point at the local project and Peach builds the site, then '
|
||||
'publishes it to nginx.'
|
||||
: 'Point at the local project and Peach builds the jar '
|
||||
'(Maven/Gradle) before deploying.';
|
||||
}
|
||||
|
||||
String _buildCommandHint() {
|
||||
if (_isWeb) {
|
||||
final fallback = _svc.framework.defaultBuild;
|
||||
return 'blank → ${fallback.isEmpty ? "none" : fallback}';
|
||||
}
|
||||
return _isGeneric
|
||||
? 'e.g. make build / cargo build --release'
|
||||
: 'blank → mvn -DskipTests package / gradlew build';
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onSaveName(String v) => _svc.name = v;
|
||||
|
||||
void _onSaveBuildCommand(String v) => _svc.buildCommand = v;
|
||||
|
||||
void _onSaveOutputSubdir(String v) => _svc.outputSubdir = v;
|
||||
|
||||
void _onSaveJarPattern(String v) => _svc.jarPattern = v;
|
||||
|
||||
void _onSaveRemoteDir(String v) => _svc.remoteDir = v;
|
||||
|
||||
void _onSaveRemoteJarName(String v) => _svc.remoteJarName = v;
|
||||
|
||||
void _onSaveServiceName(String v) => _svc.serviceName = v;
|
||||
|
||||
void _onSaveLogCommand(String v) => _svc.logCommand = v;
|
||||
|
||||
void _onSaveNginxService(String v) =>
|
||||
_svc.nginxService = v.trim().isEmpty ? 'nginx' : v;
|
||||
|
||||
void _onSaveHealthTarget(String v) => _svc.healthTarget = v;
|
||||
|
||||
void _onPickProjectDir() => _model?.pickProjectDirectory();
|
||||
|
||||
void _onSave() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
_svc.localProjectPath = _projectPath.text.trim();
|
||||
Navigator.of(context).pop(_svc);
|
||||
}
|
||||
|
||||
/// Switching kind moves the remote directory to that kind's default, but
|
||||
/// only when the user hasn't typed their own.
|
||||
void _onSelectKind(ServiceKind kind) {
|
||||
setState(() {
|
||||
_svc.kind = kind;
|
||||
final dir = _svc.remoteDir.trim();
|
||||
if (kind == ServiceKind.webApp && (dir.isEmpty || dir == '/opt/app')) {
|
||||
_svc.remoteDir = '/var/www/html';
|
||||
} else if (kind != ServiceKind.webApp && dir == '/var/www/html') {
|
||||
_svc.remoteDir = '/opt/app';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onSelectFramework(WebFramework f) => setState(() => _svc.framework = f);
|
||||
|
||||
void _onSelectHealthType(HealthCheckType h) =>
|
||||
setState(() => _svc.healthType = h);
|
||||
|
||||
// ── ConnectServiceEditor ───────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onProjectDirectoryPicked(String path) {
|
||||
if (!mounted) return;
|
||||
setState(() => _projectPath.text = path);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
List<Widget> _healthSection() => [
|
||||
inputSection(context, 'Health check'),
|
||||
_sectionHint(
|
||||
'How the Health dashboard probes this service. "systemd (auto)" '
|
||||
'checks the unit is active.',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: HealthCheckType.values
|
||||
.map(
|
||||
(h) => _choice(
|
||||
h.label,
|
||||
_svc.healthType == h,
|
||||
() => _onSelectHealthType(h),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
if (_svc.healthType == HealthCheckType.http ||
|
||||
_svc.healthType == HealthCheckType.tcp ||
|
||||
_svc.healthType == HealthCheckType.command)
|
||||
inputField(
|
||||
label: switch (_svc.healthType) {
|
||||
HealthCheckType.http => 'Health URL',
|
||||
HealthCheckType.tcp => 'Port',
|
||||
_ => 'Health command',
|
||||
},
|
||||
hint: switch (_svc.healthType) {
|
||||
HealthCheckType.http => 'http://localhost:8080/health',
|
||||
HealthCheckType.tcp => '8080',
|
||||
_ => 'systemctl is-active myapp',
|
||||
},
|
||||
initial: _svc.healthTarget,
|
||||
onSaved: _onSaveHealthTarget,
|
||||
),
|
||||
];
|
||||
|
||||
Widget _kindSelector() {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_kindCard(
|
||||
ServiceKind.jarService,
|
||||
Icons.settings_suggest,
|
||||
'Jar service',
|
||||
'systemd + jar',
|
||||
),
|
||||
_kindCard(
|
||||
ServiceKind.webApp,
|
||||
Icons.language,
|
||||
'Web app',
|
||||
'static → nginx',
|
||||
),
|
||||
_kindCard(ServiceKind.generic, Icons.terminal, 'Generic', 'any binary'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kindCard(ServiceKind kind, IconData icon, String title, String sub) {
|
||||
final jp = context.jp;
|
||||
final active = _svc.kind == kind;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _onSelectKind(kind),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: 150,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface2,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: active ? PeachTheme.seed : jp.border,
|
||||
width: active ? 1.6 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: active ? PeachTheme.seed : jp.textDim),
|
||||
const SizedBox(height: 10),
|
||||
text(title, 14, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 2),
|
||||
text(sub, 11.5, TextType.Regular, color: jp.textFaint),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _frameworkSelector() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: text(
|
||||
'Framework',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: WebFramework.values
|
||||
.map(
|
||||
(f) => _choice(
|
||||
f.label,
|
||||
_svc.framework == f,
|
||||
() => _onSelectFramework(f),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _choice(String label, bool active, VoidCallback onSelected) {
|
||||
final jp = context.jp;
|
||||
return ChoiceChip(
|
||||
selected: active,
|
||||
label: Text(label),
|
||||
onSelected: (_) => onSelected(),
|
||||
showCheckmark: false,
|
||||
selectedColor: PeachTheme.seed.withValues(alpha: 0.20),
|
||||
backgroundColor: jp.surface2,
|
||||
side: BorderSide(color: active ? PeachTheme.seed : jp.border),
|
||||
labelStyle: TextStyle(
|
||||
color: active ? PeachTheme.seed : jp.textDim,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _jarFields() => [
|
||||
inputSection(context, 'Jar service'),
|
||||
inputField(
|
||||
label: 'Remote directory',
|
||||
hint: '/opt/orders',
|
||||
initial: _svc.remoteDir,
|
||||
onSaved: _onSaveRemoteDir,
|
||||
validator: validRemotePath,
|
||||
),
|
||||
inputField(
|
||||
label: 'Jar filename on server (optional)',
|
||||
hint: 'orders.jar — blank keeps the dropped name',
|
||||
initial: _svc.remoteJarName,
|
||||
onSaved: _onSaveRemoteJarName,
|
||||
),
|
||||
inputField(
|
||||
label: 'systemd unit name (optional)',
|
||||
hint: 'orders → systemctl restart orders',
|
||||
initial: _svc.serviceName,
|
||||
onSaved: _onSaveServiceName,
|
||||
),
|
||||
inputField(
|
||||
label: 'Log command (optional)',
|
||||
hint: 'blank → journalctl -u <unit> -n 200',
|
||||
initial: _svc.logCommand,
|
||||
onSaved: _onSaveLogCommand,
|
||||
),
|
||||
];
|
||||
|
||||
List<Widget> _webFields() => [
|
||||
const SizedBox(height: 6),
|
||||
_frameworkSelector(),
|
||||
inputField(
|
||||
label: 'nginx web root',
|
||||
hint: '/var/www/marketing',
|
||||
initial: _svc.remoteDir,
|
||||
onSaved: _onSaveRemoteDir,
|
||||
validator: validRemotePath,
|
||||
),
|
||||
inputField(
|
||||
label: 'nginx unit name',
|
||||
hint: 'nginx',
|
||||
initial: _svc.nginxService,
|
||||
onSaved: _onSaveNginxService,
|
||||
),
|
||||
inputField(
|
||||
label: 'Log command (optional)',
|
||||
hint: 'blank → journalctl -u nginx -n 200',
|
||||
initial: _svc.logCommand,
|
||||
onSaved: _onSaveLogCommand,
|
||||
),
|
||||
];
|
||||
|
||||
List<Widget> _genericFields() => [
|
||||
inputSection(context, 'Service (any binary / script)'),
|
||||
inputField(
|
||||
label: 'Remote directory',
|
||||
hint: '/opt/myapp',
|
||||
initial: _svc.remoteDir,
|
||||
onSaved: _onSaveRemoteDir,
|
||||
validator: validRemotePath,
|
||||
),
|
||||
inputField(
|
||||
label: 'Target filename on server',
|
||||
hint: 'myapp (the uploaded file is chmod +x)',
|
||||
initial: _svc.remoteJarName,
|
||||
onSaved: _onSaveRemoteJarName,
|
||||
),
|
||||
inputField(
|
||||
label: 'systemd unit name (optional)',
|
||||
hint: 'myapp → systemctl restart myapp',
|
||||
initial: _svc.serviceName,
|
||||
onSaved: _onSaveServiceName,
|
||||
),
|
||||
inputField(
|
||||
label: 'Log command (optional)',
|
||||
hint: 'blank → journalctl -u <unit> -n 200',
|
||||
initial: _svc.logCommand,
|
||||
onSaved: _onSaveLogCommand,
|
||||
),
|
||||
];
|
||||
|
||||
Widget _sectionHint(String message) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: text(message, 12.5, TextType.Regular, color: context.jp.textFaint),
|
||||
);
|
||||
}
|
||||
26
lib/Peach/see/service/ViewServiceEditor.dart
Normal file
26
lib/Peach/see/service/ViewServiceEditor.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectServiceEditor.dart';
|
||||
|
||||
class ViewServiceEditor extends ParentViewModel {
|
||||
ConnectServiceEditor connection;
|
||||
|
||||
ViewServiceEditor(super.context, this.connection);
|
||||
|
||||
/// True on desktop, where a local build can actually run.
|
||||
bool get canBuildLocally => getDataManager().buildSupported;
|
||||
|
||||
/// Opens the OS folder picker for the service's source project.
|
||||
void pickProjectDirectory() async {
|
||||
try {
|
||||
final directory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Select the service project folder',
|
||||
);
|
||||
if (directory == null) return;
|
||||
connection.onProjectDirectoryPicked(directory);
|
||||
} catch (error) {
|
||||
handleError(error, pickProjectDirectory, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
lib/Peach/see/splash/ConnectSplash.dart
Normal file
13
lib/Peach/see/splash/ConnectSplash.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
|
||||
/// What [ViewSplash] hands back to the screen once the app's stored state has
|
||||
/// been read. The ViewModel never touches widgets — it calls these.
|
||||
abstract class ConnectSplash {
|
||||
/// Everything needed to open Home, loaded and ready.
|
||||
void onBootstrapped(
|
||||
List<ServerProfile> servers,
|
||||
List<DeploymentRecord> history,
|
||||
String themeMode,
|
||||
);
|
||||
}
|
||||
10
lib/Peach/see/splash/Splash.dart
Normal file
10
lib/Peach/see/splash/Splash.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import 'SplashState.dart';
|
||||
|
||||
class Splash extends StatefulWidget {
|
||||
const Splash({super.key});
|
||||
|
||||
@override
|
||||
State<Splash> createState() => SplashState();
|
||||
}
|
||||
168
lib/Peach/see/splash/SplashState.dart
Normal file
168
lib/Peach/see/splash/SplashState.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../about/internal/application/NavigatorType.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../configs/Navigator.dart';
|
||||
import '../../configs/ThemeController.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import '../home/Home.dart';
|
||||
import 'ConnectSplash.dart';
|
||||
import 'Splash.dart';
|
||||
import 'ViewSplash.dart';
|
||||
|
||||
class SplashState extends State<Splash>
|
||||
with SingleTickerProviderStateMixin
|
||||
implements ConnectSplash {
|
||||
ViewSplash? _model;
|
||||
|
||||
late final AnimationController _pulse;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulse.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewSplash>.reactive(
|
||||
viewModelBuilder: () => ViewSplash(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_brandMark(),
|
||||
const SizedBox(height: 24),
|
||||
text(
|
||||
'Peach',
|
||||
36,
|
||||
TextType.Bold,
|
||||
context: context,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
'DEPLOYMENT COCKPIT',
|
||||
11.5,
|
||||
TextType.Bold,
|
||||
color: context.jp.textFaint,
|
||||
letterSpacing: 4,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
backgroundColor: context.jp.surface2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewSplash 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 {
|
||||
_model?.bootstrap();
|
||||
}
|
||||
|
||||
// ── ConnectSplash ──────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onBootstrapped(
|
||||
List<ServerProfile> servers,
|
||||
List<DeploymentRecord> history,
|
||||
String themeMode,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
// Apply the stored theme before the dashboard paints, so it never flashes
|
||||
// the default and then switches.
|
||||
ThemeController.apply(themeMode);
|
||||
PeachNavigation().navigateToPage(
|
||||
NavigatorType.makeNewMain,
|
||||
Home(servers: servers, history: history),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
/// Layered logo: a soft pulsing halo behind a beveled brand tile.
|
||||
Widget _brandMark() {
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
height: 132,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: Tween(
|
||||
begin: 0.9,
|
||||
end: 1.12,
|
||||
).animate(CurvedAnimation(parent: _pulse, curve: Curves.easeInOut)),
|
||||
child: Container(
|
||||
width: 132,
|
||||
height: 132,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
PeachTheme.seed.withValues(alpha: 0.28),
|
||||
PeachTheme.seed.withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 90,
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
gradient: PeachTheme.brandGradient,
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
border: Border.all(
|
||||
color: colorWhite.withValues(alpha: 0.18),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: PeachTheme.glow(
|
||||
PeachTheme.seed,
|
||||
opacity: 0.5,
|
||||
blur: 44,
|
||||
spread: 0,
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.rocket_launch, color: colorWhite, size: 44),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/Peach/see/splash/ViewSplash.dart
Normal file
28
lib/Peach/see/splash/ViewSplash.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectSplash.dart';
|
||||
|
||||
class ViewSplash extends ParentViewModel {
|
||||
ConnectSplash connection;
|
||||
|
||||
ViewSplash(super.context, this.connection);
|
||||
|
||||
/// Reads the stored servers, deployment history and theme preference, then
|
||||
/// hands them to the screen. The splash is deliberately held for a minimum
|
||||
/// duration so a fast read doesn't make it flash.
|
||||
void bootstrap() async {
|
||||
final started = DateTime.now();
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final history = await getDataManager().getDeployments();
|
||||
final themeMode = await getDataManager().getThemeMode();
|
||||
|
||||
const minShow = Duration(milliseconds: 1300);
|
||||
final elapsed = DateTime.now().difference(started);
|
||||
if (elapsed < minShow) await Future.delayed(minShow - elapsed);
|
||||
|
||||
connection.onBootstrapped(servers, history, themeMode);
|
||||
} catch (error) {
|
||||
handleError(error, bootstrap, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
lib/Peach/see/systemd/ConnectSystemd.dart
Normal file
6
lib/Peach/see/systemd/ConnectSystemd.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import '../../comms/Comms.dart' show DeployResult;
|
||||
|
||||
abstract class ConnectSystemd {
|
||||
/// The unit was written and registered (or failed to be).
|
||||
void onUnitInstalled(DeployResult result);
|
||||
}
|
||||
20
lib/Peach/see/systemd/Systemd.dart
Normal file
20
lib/Peach/see/systemd/Systemd.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import 'SystemdState.dart';
|
||||
|
||||
/// Step-by-step builder that generates a systemd `.service` unit, previews it,
|
||||
/// then registers it (daemon-reload → enable → start → status).
|
||||
class Systemd extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
/// Prefills the form and, once the unit registers, has its `serviceName`
|
||||
/// updated so deploys and log views use the new unit.
|
||||
final ServiceConfig? service;
|
||||
|
||||
const Systemd({super.key, required this.server, this.service});
|
||||
|
||||
@override
|
||||
State<Systemd> createState() => SystemdState();
|
||||
}
|
||||
583
lib/Peach/see/systemd/SystemdState.dart
Normal file
583
lib/Peach/see/systemd/SystemdState.dart
Normal file
@@ -0,0 +1,583 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../about/external/initial/SystemdSpec.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/StepTracker.dart';
|
||||
import '../../about/internal/application/TextType.dart';
|
||||
import '../../comms/Comms.dart' show DeployResult, LogLine;
|
||||
import '../../designs/LogConsole.dart';
|
||||
import '../../designs/StepList.dart';
|
||||
import '../../designs/text/Text.dart';
|
||||
import '../../utils/Colors.dart';
|
||||
import 'ConnectSystemd.dart';
|
||||
import 'Systemd.dart';
|
||||
import 'ViewSystemd.dart';
|
||||
|
||||
class SystemdState extends State<Systemd> implements ConnectSystemd {
|
||||
static const _dependencyOptions = {
|
||||
'mariadb.service': 'MariaDB',
|
||||
'mysql.service': 'MySQL',
|
||||
'postgresql.service': 'PostgreSQL',
|
||||
'docker.service': 'Docker',
|
||||
'redis.service': 'Redis',
|
||||
'nginx.service': 'nginx',
|
||||
};
|
||||
|
||||
ViewSystemd? _model;
|
||||
|
||||
StreamSubscription<LogLine>? _logSub;
|
||||
StreamSubscription<List<DeployStep>>? _stepSub;
|
||||
|
||||
final _lines = <LogLine>[];
|
||||
List<DeployStep> _steps = [];
|
||||
bool _applying = false;
|
||||
DeployResult? _result;
|
||||
|
||||
late final TextEditingController _name;
|
||||
late final TextEditingController _description;
|
||||
late final TextEditingController _user;
|
||||
late final TextEditingController _workDir;
|
||||
late final TextEditingController _exec;
|
||||
late final TextEditingController _restartSec;
|
||||
late final TextEditingController _environment;
|
||||
|
||||
SystemdType _type = SystemdType.simple;
|
||||
RestartPolicy _restart = RestartPolicy.onFailure;
|
||||
final Set<String> _after = {};
|
||||
bool _javaExit = false;
|
||||
bool _enableOnBoot = true;
|
||||
bool _startNow = true;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final service = widget.service;
|
||||
final isJar = service != null && !service.isWeb;
|
||||
final jarPath = service == null
|
||||
? '/opt/app/app.jar'
|
||||
: service.remotePath(
|
||||
service.remoteJarName.trim().isEmpty
|
||||
? 'app.jar'
|
||||
: service.remoteJarName,
|
||||
);
|
||||
|
||||
_name = TextEditingController(
|
||||
text: service?.serviceName.trim().isNotEmpty == true
|
||||
? service!.serviceName.trim()
|
||||
: (service?.name ?? 'myapp'),
|
||||
);
|
||||
_description = TextEditingController(
|
||||
text: 'Peach · ${service?.name ?? 'service'}',
|
||||
);
|
||||
_user = TextEditingController(text: _server.username);
|
||||
_workDir = TextEditingController(text: service?.remoteRoot ?? '/opt/app');
|
||||
_exec = TextEditingController(
|
||||
text: isJar ? '/usr/bin/java -jar $jarPath' : '',
|
||||
);
|
||||
_restartSec = TextEditingController(text: '5');
|
||||
_environment = TextEditingController();
|
||||
_javaExit = isJar;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSub?.cancel();
|
||||
_stepSub?.cancel();
|
||||
for (final c in [
|
||||
_name,
|
||||
_description,
|
||||
_user,
|
||||
_workDir,
|
||||
_exec,
|
||||
_restartSec,
|
||||
_environment,
|
||||
]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewSystemd>.reactive(
|
||||
viewModelBuilder: () => ViewSystemd(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Register systemd service',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 40),
|
||||
children: [
|
||||
_sectionTitle('1 · Unit', Icons.badge_outlined),
|
||||
_field('Unit name', _name, hint: 'orders → orders.service'),
|
||||
_field('Description', _description),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _typeDropdown()),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _restartDropdown()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_sectionTitle('2 · Process', Icons.terminal),
|
||||
_field('User', _user),
|
||||
_field('Working directory', _workDir, hint: '/opt/app'),
|
||||
_field(
|
||||
'ExecStart',
|
||||
_exec,
|
||||
hint: '/usr/bin/java -jar /opt/app/app.jar',
|
||||
mono: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: _field(
|
||||
'RestartSec',
|
||||
_restartSec,
|
||||
keyboard: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
value: _javaExit,
|
||||
onChanged: _onJavaExitChanged,
|
||||
title: text(
|
||||
'JVM SuccessExitStatus=143',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_field(
|
||||
'Environment (KEY=VALUE per line)',
|
||||
_environment,
|
||||
hint: 'SPRING_PROFILES_ACTIVE=prod\nJAVA_OPTS=-Xmx512m',
|
||||
mono: true,
|
||||
lines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_sectionTitle('3 · Dependencies', Icons.account_tree),
|
||||
text(
|
||||
'Starts after network.target',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: context.jp.textFaint,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dependencyChips(),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _enableOnBoot,
|
||||
onChanged: _onEnableChanged,
|
||||
title: text(
|
||||
'Enable on boot',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.text,
|
||||
),
|
||||
subtitle: text(
|
||||
'systemctl enable',
|
||||
12,
|
||||
TextType.Light,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _startNow,
|
||||
onChanged: _onStartNowChanged,
|
||||
title: text(
|
||||
'Start now',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: context.jp.text,
|
||||
),
|
||||
subtitle: text(
|
||||
'systemctl restart after install',
|
||||
12,
|
||||
TextType.Light,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_sectionTitle('4 · Preview & confirm', Icons.rule),
|
||||
_previewCard(_spec()),
|
||||
const SizedBox(height: 10),
|
||||
_plannedCommands(_spec()),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _applying ? null : _onApply,
|
||||
icon: _applying
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check_circle),
|
||||
label: text(
|
||||
_applying ? 'Registering…' : 'Install & register unit',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
if (_steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepList(steps: _steps),
|
||||
],
|
||||
if (_result != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
_resultBanner(_result!),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 260,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewSystemd 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);
|
||||
_stepSub = _model?.steps.listen(_onSteps);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
void _onSteps(List<DeployStep> steps) {
|
||||
if (!mounted) return;
|
||||
setState(() => _steps = steps);
|
||||
}
|
||||
|
||||
SystemdSpec _spec() => SystemdSpec(
|
||||
unitName: _name.text,
|
||||
description: _description.text,
|
||||
type: _type,
|
||||
user: _user.text,
|
||||
workingDirectory: _workDir.text,
|
||||
execStart: _exec.text,
|
||||
restart: _restart,
|
||||
restartSec: int.tryParse(_restartSec.text) ?? 5,
|
||||
environmentLines: _environment.text,
|
||||
afterUnits: ['network.target', ..._after],
|
||||
javaSuccessExit: _javaExit,
|
||||
enableOnBoot: _enableOnBoot,
|
||||
startNow: _startNow,
|
||||
);
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onApply() {
|
||||
final spec = _spec();
|
||||
if (spec.safeName.isEmpty) {
|
||||
_notify('Enter a unit name.');
|
||||
return;
|
||||
}
|
||||
if (spec.execStart.trim().isEmpty) {
|
||||
_notify('Enter an ExecStart command.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_applying = true;
|
||||
_lines.clear();
|
||||
_result = null;
|
||||
});
|
||||
_model?.installUnit(_server, spec, widget.service);
|
||||
}
|
||||
|
||||
void _onTypeChanged(SystemdType? v) =>
|
||||
setState(() => _type = v ?? SystemdType.simple);
|
||||
|
||||
void _onRestartChanged(RestartPolicy? v) =>
|
||||
setState(() => _restart = v ?? RestartPolicy.onFailure);
|
||||
|
||||
void _onJavaExitChanged(bool v) => setState(() => _javaExit = v);
|
||||
|
||||
void _onEnableChanged(bool v) => setState(() => _enableOnBoot = v);
|
||||
|
||||
void _onStartNowChanged(bool v) => setState(() => _startNow = v);
|
||||
|
||||
void _onDependencyChanged(String unit, bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_after.add(unit);
|
||||
} else {
|
||||
_after.remove(unit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The preview re-renders from the live field values, so every keystroke
|
||||
/// has to repaint.
|
||||
void _onFieldChanged(String _) => setState(() {});
|
||||
|
||||
void _onCopyUnit(SystemdSpec spec) {
|
||||
Clipboard.setData(ClipboardData(text: spec.render()));
|
||||
_notify('Unit file copied.');
|
||||
}
|
||||
|
||||
void _onClearLogs() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopyLogs() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectSystemd ─────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onUnitInstalled(DeployResult result) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_applying = false;
|
||||
_result = result;
|
||||
});
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _sectionTitle(String title, IconData icon) => Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: PeachTheme.seed),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
title.toUpperCase(),
|
||||
12.5,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _field(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
String? hint,
|
||||
bool mono = false,
|
||||
int lines = 1,
|
||||
TextInputType? keyboard,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
maxLines: lines,
|
||||
keyboardType: keyboard,
|
||||
onChanged: _onFieldChanged,
|
||||
style: mono
|
||||
? const TextStyle(fontFamily: 'monospace', fontSize: 13)
|
||||
: null,
|
||||
decoration: InputDecoration(labelText: label, hintText: hint),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typeDropdown() => DropdownButtonFormField<SystemdType>(
|
||||
initialValue: _type,
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
items: SystemdType.values
|
||||
.map((t) => DropdownMenuItem(value: t, child: Text(t.name)))
|
||||
.toList(),
|
||||
onChanged: _onTypeChanged,
|
||||
);
|
||||
|
||||
Widget _restartDropdown() => DropdownButtonFormField<RestartPolicy>(
|
||||
initialValue: _restart,
|
||||
decoration: const InputDecoration(labelText: 'Restart'),
|
||||
items: RestartPolicy.values
|
||||
.map((r) => DropdownMenuItem(value: r, child: Text(r.label)))
|
||||
.toList(),
|
||||
onChanged: _onRestartChanged,
|
||||
);
|
||||
|
||||
Widget _dependencyChips() {
|
||||
final jp = context.jp;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _dependencyOptions.entries.map((entry) {
|
||||
final on = _after.contains(entry.key);
|
||||
return FilterChip(
|
||||
label: Text(entry.value),
|
||||
selected: on,
|
||||
onSelected: (s) => _onDependencyChanged(entry.key, s),
|
||||
showCheckmark: false,
|
||||
selectedColor: PeachTheme.seed.withValues(alpha: 0.20),
|
||||
backgroundColor: jp.surface2,
|
||||
side: BorderSide(color: on ? PeachTheme.seed : jp.border),
|
||||
labelStyle: TextStyle(
|
||||
color: on ? PeachTheme.seed : jp.textDim,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _previewCard(SystemdSpec spec) {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.description, size: 15, color: PeachTheme.seed),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'/etc/systemd/system/${spec.safeName}.service',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: ConsoleColors.header,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Copy',
|
||||
iconSize: 15,
|
||||
color: ConsoleColors.header,
|
||||
onPressed: () => _onCopyUnit(spec),
|
||||
icon: const Icon(Icons.copy_all),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: ConsoleColors.frame),
|
||||
SelectableText(
|
||||
spec.render(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
height: 1.4,
|
||||
color: ConsoleColors.body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _plannedCommands(SystemdSpec spec) {
|
||||
final jp = context.jp;
|
||||
final commands = spec.plannedCommands(sudo: _server.useSudo);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
'THESE COMMANDS WILL RUN',
|
||||
11,
|
||||
TextType.Bold,
|
||||
color: jp.textDim,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final command in commands)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.chevron_right, size: 15, color: jp.textFaint),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: text(
|
||||
command,
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultBanner(DeployResult result) {
|
||||
final color = result.ok ? PeachTheme.seed : PeachTheme.danger;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(result.ok ? Icons.check_circle : Icons.error, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(result.message, 14, TextType.Regular, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/Peach/see/systemd/ViewSystemd.dart
Normal file
68
lib/Peach/see/systemd/ViewSystemd.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import '../../about/external/initial/SystemdSpec.dart';
|
||||
import '../../about/internal/application/ServerProfile.dart';
|
||||
import '../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../about/internal/application/StepTracker.dart';
|
||||
import '../../comms/Comms.dart' show LogLine;
|
||||
import '../parent/ParentViewModel.dart';
|
||||
import 'ConnectSystemd.dart';
|
||||
|
||||
class ViewSystemd extends ParentViewModel {
|
||||
ConnectSystemd connection;
|
||||
|
||||
ViewSystemd(super.context, this.connection);
|
||||
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
|
||||
|
||||
/// Writes the unit file, reloads systemd and (optionally) enables and starts
|
||||
/// it. On success the unit name is recorded against [service] so the deploy
|
||||
/// and log screens target the right unit from now on.
|
||||
void installUnit(
|
||||
ServerProfile server,
|
||||
SystemdSpec spec,
|
||||
ServiceConfig? service,
|
||||
) async {
|
||||
if (!await canReach(server, () => installUnit(server, spec, service))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final result = await getDataManager().installSystemdUnit(
|
||||
server: server,
|
||||
unitName: spec.safeName,
|
||||
unitContent: spec.render(),
|
||||
enable: spec.enableOnBoot,
|
||||
startNow: spec.startNow,
|
||||
);
|
||||
|
||||
if (result.ok && service != null) {
|
||||
service.serviceName = spec.safeName;
|
||||
await _persistService(server, service);
|
||||
}
|
||||
|
||||
connection.onUnitInstalled(result);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => installUnit(server, spec, service),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the service back onto its server in storage.
|
||||
Future<void> _persistService(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
) async {
|
||||
final servers = await getDataManager().getServers();
|
||||
final serverIndex = servers.indexWhere((s) => s.id == server.id);
|
||||
if (serverIndex < 0) return;
|
||||
final services = servers[serverIndex].services;
|
||||
final serviceIndex = services.indexWhere((s) => s.id == service.id);
|
||||
if (serviceIndex < 0) return;
|
||||
services[serviceIndex] = service;
|
||||
await getDataManager().setServers(servers);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user