Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
22
lib/Peach/see/docker/manager/ConnectDockerManager.dart
Normal file
22
lib/Peach/see/docker/manager/ConnectDockerManager.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
|
||||
abstract class ConnectDockerManager {
|
||||
/// Docker's state on the server, plus its containers when it's running.
|
||||
void onDockerLoaded(DockerStatus status, List<DockerContainer> containers);
|
||||
|
||||
/// A container action (start/stop/restart/rm) finished.
|
||||
void onActionFinished(String output);
|
||||
|
||||
/// Output to show in a sheet — container logs, or a test run.
|
||||
void onOutputReady(String title, String output);
|
||||
|
||||
/// The install command was resolved for this distro; the screen confirms it
|
||||
/// with the user before anything runs.
|
||||
void onInstallCommandReady(String command);
|
||||
|
||||
/// The Docker install finished; the caller should re-check status.
|
||||
void onInstallFinished();
|
||||
|
||||
/// Something failed with a message worth surfacing inline.
|
||||
void onDockerFailed(String message);
|
||||
}
|
||||
15
lib/Peach/see/docker/manager/DockerManager.dart
Normal file
15
lib/Peach/see/docker/manager/DockerManager.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'DockerManagerState.dart';
|
||||
|
||||
/// See, start/stop/restart, inspect logs and remove Docker containers — and
|
||||
/// install Docker if it's missing.
|
||||
class DockerManager extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const DockerManager({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<DockerManager> createState() => DockerManagerState();
|
||||
}
|
||||
495
lib/Peach/see/docker/manager/DockerManagerState.dart
Normal file
495
lib/Peach/see/docker/manager/DockerManagerState.dart
Normal file
@@ -0,0 +1,495 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
import '../../../about/internal/application/NotificationType.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../configs/Navigator.dart';
|
||||
import '../../../designs/OutputSheet.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import '../run/DockerRun.dart';
|
||||
import 'ConnectDockerManager.dart';
|
||||
import 'DockerManager.dart';
|
||||
import 'ViewDockerManager.dart';
|
||||
|
||||
class DockerManagerState extends State<DockerManager>
|
||||
implements ConnectDockerManager {
|
||||
ViewDockerManager? _model;
|
||||
|
||||
DockerStatus? _status;
|
||||
List<DockerContainer> _containers = [];
|
||||
bool _loading = true;
|
||||
bool _installing = false;
|
||||
String? _busyId;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
bool get _ready => _status != null && _status!.installed && _status!.running;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewDockerManager>.reactive(
|
||||
viewModelBuilder: () => ViewDockerManager(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Docker · ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
if (_ready)
|
||||
IconButton(
|
||||
tooltip: 'Test (hello-world)',
|
||||
onPressed: _onTest,
|
||||
icon: const Icon(Icons.verified),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _loading ? null : _onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: _ready
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: _onOpenRun,
|
||||
icon: const Icon(Icons.add),
|
||||
label: text(
|
||||
'Run container',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
_statusCard(),
|
||||
if (_ready) ...[
|
||||
const SizedBox(height: 16),
|
||||
_sectionHeader(),
|
||||
const SizedBox(height: 10),
|
||||
if (_loading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_containers.isEmpty)
|
||||
_empty()
|
||||
else
|
||||
LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 720 ? 2 : 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final container in _containers)
|
||||
SizedBox(width: w, child: _containerCard(container)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewDockerManager 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 {
|
||||
_model?.loadDocker(_server);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onRefresh() {
|
||||
setState(() => _loading = true);
|
||||
_model?.loadDocker(_server);
|
||||
}
|
||||
|
||||
void _onTest() => _model?.runTest(_server);
|
||||
|
||||
void _onInstall() => _model?.prepareInstall(_server);
|
||||
|
||||
Future<void> _onOpenRun() async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
DockerRun(server: _server),
|
||||
context,
|
||||
);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
void _onStart(DockerContainer c) => _action(c, 'start');
|
||||
|
||||
void _onStop(DockerContainer c) => _action(c, 'stop');
|
||||
|
||||
void _onRestart(DockerContainer c) => _action(c, 'restart');
|
||||
|
||||
void _onRemove(DockerContainer c) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
'Remove "${c.name}"?',
|
||||
'This force-removes the container (docker rm -f).',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Remove',
|
||||
positiveAction: () => _onConfirmRemove(c),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmRemove(DockerContainer c) {
|
||||
Navigator.pop(context);
|
||||
_action(c, 'rm');
|
||||
}
|
||||
|
||||
void _action(DockerContainer c, String action) {
|
||||
setState(() => _busyId = c.id);
|
||||
_model?.runAction(_server, c.id, action);
|
||||
}
|
||||
|
||||
void _onShowLogs(DockerContainer c) {
|
||||
setState(() => _busyId = c.id);
|
||||
_model?.loadContainerLogs(_server, c.id, c.name);
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectDockerManager ───────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onDockerLoaded(DockerStatus status, List<DockerContainer> containers) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = status;
|
||||
_containers = containers;
|
||||
_loading = false;
|
||||
_busyId = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onActionFinished(String output) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busyId = null);
|
||||
if (output.toLowerCase().contains('error')) _notify(output);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void onOutputReady(String title, String output) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busyId = null);
|
||||
showOutputSheet(context, title, output);
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallCommandReady(String command) {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.info,
|
||||
'Install Docker?',
|
||||
'Runs:\n$command\nthen: systemctl enable --now docker',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Install',
|
||||
positiveAction: () => _onConfirmInstall(command),
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmInstall(String command) {
|
||||
Navigator.pop(context);
|
||||
setState(() => _installing = true);
|
||||
_model?.installDocker(_server, command);
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallFinished() {
|
||||
if (!mounted) return;
|
||||
setState(() => _installing = false);
|
||||
_onRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void onDockerFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_busyId = null;
|
||||
});
|
||||
_notify(message);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _sectionHeader() => Row(
|
||||
children: [
|
||||
text(
|
||||
'CONTAINERS',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text('${_containers.length}', 12, TextType.Bold, color: PeachTheme.seed),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _statusCard() {
|
||||
final jp = context.jp;
|
||||
final installed = _status?.installed ?? false;
|
||||
final running = _status?.running ?? false;
|
||||
final (color, label) = !installed
|
||||
? (jp.textFaint, 'NOT INSTALLED')
|
||||
: running
|
||||
? (PeachTheme.seed, 'RUNNING')
|
||||
: (PeachTheme.warn, 'STOPPED');
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.accent),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: PeachTheme.accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const Icon(Icons.inventory_2, color: PeachTheme.accent),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
_loading ? 'Checking Docker…' : 'Docker',
|
||||
16,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
text(
|
||||
installed ? (_status?.version ?? '') : 'Not installed',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: text(label, 10.5, TextType.Bold, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!installed) ...[
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _installing ? null : _onInstall,
|
||||
icon: _installing
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download),
|
||||
label: text(
|
||||
_installing ? 'Installing…' : 'Install Docker',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
] else if (!running) ...[
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
'The Docker daemon is not running. Start it with '
|
||||
'systemctl start docker (Configuration → install also enables it).',
|
||||
12.5,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
alignment: Alignment.center,
|
||||
decoration: PeachTheme.panel(context),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.inbox, size: 40, color: jp.textFaint),
|
||||
const SizedBox(height: 10),
|
||||
text('No containers yet', 14, TextType.Regular, color: jp.textDim),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'Tap "Run container" to start one.',
|
||||
12,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _containerCard(DockerContainer c) {
|
||||
final jp = context.jp;
|
||||
final busy = _busyId == c.id;
|
||||
final (color, label) = switch (c.state.toLowerCase()) {
|
||||
'running' => (PeachTheme.seed, 'UP'),
|
||||
'exited' => (jp.textFaint, 'EXITED'),
|
||||
'paused' => (PeachTheme.warn, 'PAUSED'),
|
||||
'created' => (PeachTheme.accent, 'CREATED'),
|
||||
'restarting' => (PeachTheme.warn, 'RESTARTING'),
|
||||
_ => (PeachTheme.danger, c.state.toUpperCase()),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.widgets,
|
||||
size: 18,
|
||||
color: c.up ? PeachTheme.seed : jp.textFaint,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: text(
|
||||
c.name,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(label, 10, TextType.Bold, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
c.image,
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
text(
|
||||
c.status,
|
||||
11.5,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (c.ports.trim().isNotEmpty)
|
||||
text(
|
||||
c.ports,
|
||||
11.5,
|
||||
TextType.Light,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (busy)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else ...[
|
||||
if (c.up) ...[
|
||||
_act(Icons.restart_alt, 'Restart', () => _onRestart(c)),
|
||||
_act(Icons.stop, 'Stop', () => _onStop(c)),
|
||||
] else
|
||||
_act(Icons.play_arrow, 'Start', () => _onStart(c)),
|
||||
_act(Icons.article_outlined, 'Logs', () => _onShowLogs(c)),
|
||||
_act(
|
||||
Icons.delete_outline,
|
||||
'Remove',
|
||||
() => _onRemove(c),
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _act(IconData icon, String tip, VoidCallback onTap, {Color? color}) =>
|
||||
IconButton(
|
||||
tooltip: tip,
|
||||
iconSize: 20,
|
||||
color: color ?? context.jp.textDim,
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon),
|
||||
);
|
||||
}
|
||||
117
lib/Peach/see/docker/manager/ViewDockerManager.dart
Normal file
117
lib/Peach/see/docker/manager/ViewDockerManager.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import '../../../about/external/data/Docker.dart';
|
||||
import '../../../about/external/data/EnvCheck.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectDockerManager.dart';
|
||||
|
||||
class ViewDockerManager extends ParentViewModel {
|
||||
ConnectDockerManager connection;
|
||||
|
||||
ViewDockerManager(super.context, this.connection);
|
||||
|
||||
/// Reads Docker's state and, when the daemon is up, its containers.
|
||||
void loadDocker(ServerProfile server) async {
|
||||
if (!await canReach(server, () => loadDocker(server))) return;
|
||||
try {
|
||||
final status = await getDataManager().dockerStatus(server);
|
||||
var containers = <DockerContainer>[];
|
||||
if (status.installed && status.running) {
|
||||
containers = await getDataManager().dockerPs(server, all: true);
|
||||
}
|
||||
connection.onDockerLoaded(status, containers);
|
||||
} catch (error) {
|
||||
connection.onDockerFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// start | stop | restart | rm on one container.
|
||||
void runAction(
|
||||
ServerProfile server,
|
||||
String containerId,
|
||||
String action,
|
||||
) async {
|
||||
try {
|
||||
final output = await getDataManager().dockerAction(
|
||||
server,
|
||||
containerId,
|
||||
action,
|
||||
);
|
||||
connection.onActionFinished(output);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => runAction(server, containerId, action),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void loadContainerLogs(
|
||||
ServerProfile server,
|
||||
String containerId,
|
||||
String name,
|
||||
) async {
|
||||
try {
|
||||
final output = await getDataManager().dockerLogs(server, containerId);
|
||||
connection.onOutputReady(
|
||||
'$name · logs',
|
||||
output.isEmpty ? '(no output)' : output,
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => loadContainerLogs(server, containerId, name),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `hello-world` to prove the daemon can pull and execute an image.
|
||||
void runTest(ServerProfile server) async {
|
||||
showLoading("Running hello-world …");
|
||||
try {
|
||||
final output = await getDataManager().dockerTest(server);
|
||||
closeLoading();
|
||||
connection.onOutputReady('docker run --rm hello-world', output);
|
||||
} catch (error) {
|
||||
handleError(error, () => runTest(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the right package-manager command for this distro, then hands
|
||||
/// it back so the screen can confirm before anything is installed.
|
||||
void prepareInstall(ServerProfile server) async {
|
||||
if (!await canReach(server, () => prepareInstall(server))) return;
|
||||
try {
|
||||
final manager = await getDataManager().detectPackageManager(server);
|
||||
if (manager == 'unknown') {
|
||||
connection.onDockerFailed(
|
||||
'Unknown package manager — install Docker manually.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final tool = kEnvTools.firstWhere((t) => t.key == 'docker');
|
||||
connection.onInstallCommandReady(
|
||||
installCommandFor(manager, tool.packagesFor(manager)),
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, () => prepareInstall(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
void installDocker(ServerProfile server, String command) async {
|
||||
try {
|
||||
await getDataManager().installPackages(
|
||||
server: server,
|
||||
command: command,
|
||||
enableUnit: 'docker',
|
||||
);
|
||||
connection.onInstallFinished();
|
||||
} catch (error) {
|
||||
connection.onInstallFinished();
|
||||
handleError(error, () => installDocker(server, command), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
5
lib/Peach/see/docker/run/ConnectDockerRun.dart
Normal file
5
lib/Peach/see/docker/run/ConnectDockerRun.dart
Normal 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);
|
||||
}
|
||||
14
lib/Peach/see/docker/run/DockerRun.dart
Normal file
14
lib/Peach/see/docker/run/DockerRun.dart
Normal 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();
|
||||
}
|
||||
313
lib/Peach/see/docker/run/DockerRunState.dart
Normal file
313
lib/Peach/see/docker/run/DockerRunState.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/Peach/see/docker/run/ViewDockerRun.dart
Normal file
25
lib/Peach/see/docker/run/ViewDockerRun.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user