314 lines
9.4 KiB
Dart
314 lines
9.4 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|