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

View 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();
}

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

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