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