584 lines
17 KiB
Dart
584 lines
17 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|