Add app source and tests

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

View File

@@ -0,0 +1,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();
}

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

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

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

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

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

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

View 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.
}
}
}

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
import '../../about/internal/application/DeploymentRecord.dart';
abstract class ConnectHistory {
void onHistoryLoaded(List<DeploymentRecord> history);
void onHistoryCleared();
}

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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.
}
}

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
abstract class ConnectServiceEditor {
/// A local project folder was chosen for the build step.
void onProjectDirectoryPicked(String path);
}

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

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

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

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

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

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

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

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

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

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

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