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