455 lines
14 KiB
Dart
455 lines
14 KiB
Dart
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),
|
|
);
|
|
}
|