diff --git a/native/assets/docker.svg b/native/assets/docker.svg
new file mode 100644
index 0000000..ceaa6b8
--- /dev/null
+++ b/native/assets/docker.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/native/lib/features/docker/docker_container.dart b/native/lib/features/docker/docker_container.dart
new file mode 100644
index 0000000..9557ae1
--- /dev/null
+++ b/native/lib/features/docker/docker_container.dart
@@ -0,0 +1,57 @@
+class DockerContainer {
+ const DockerContainer({
+ required this.id,
+ required this.name,
+ required this.image,
+ required this.status,
+ required this.ports,
+ required this.createdAt,
+ required this.uptime,
+ });
+
+ final String id;
+ final String name;
+ final String image;
+ final String status;
+ final List ports;
+ final String createdAt;
+ final String uptime;
+
+ factory DockerContainer.fromJson(Map json) {
+ final rawPorts = json['ports'];
+ return DockerContainer(
+ id: (json['id'] ?? '').toString(),
+ name: (json['name'] ?? '').toString(),
+ image: (json['image'] ?? '').toString(),
+ status: (json['status'] ?? '').toString(),
+ ports: rawPorts is List
+ ? rawPorts.map((port) => port.toString()).toList(growable: false)
+ : const [],
+ createdAt: (json['createdAt'] ?? '').toString(),
+ uptime: (json['uptime'] ?? '').toString(),
+ );
+ }
+
+ String get shortId => id.length <= 12 ? id : id.substring(0, 12);
+ bool get isRunning => status == 'running';
+}
+
+class DockerOperationResult {
+ const DockerOperationResult({required this.success, required this.message});
+
+ final bool success;
+ final String message;
+
+ factory DockerOperationResult.fromJson(dynamic json) {
+ if (json is Map) {
+ return DockerOperationResult(
+ success: json['success'] == true,
+ message: (json['message'] ?? '').toString(),
+ );
+ }
+ return const DockerOperationResult(
+ success: false,
+ message: 'Unknown error',
+ );
+ }
+}
diff --git a/native/lib/features/docker/docker_icon.dart b/native/lib/features/docker/docker_icon.dart
new file mode 100644
index 0000000..a6a1a43
--- /dev/null
+++ b/native/lib/features/docker/docker_icon.dart
@@ -0,0 +1,31 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+class DockerIcon extends StatelessWidget {
+ const DockerIcon({super.key, this.size, this.visualScale = 0.82});
+
+ final double? size;
+ final double visualScale;
+
+ @override
+ Widget build(BuildContext context) {
+ final iconTheme = IconTheme.of(context);
+ final iconSize = size ?? iconTheme.size ?? 24;
+ final imageSize = iconSize * visualScale;
+ return SizedBox.square(
+ dimension: iconSize,
+ child: Center(
+ child: SvgPicture.asset(
+ 'assets/docker.svg',
+ width: imageSize,
+ height: imageSize,
+ fit: BoxFit.contain,
+ colorFilter: ColorFilter.mode(
+ iconTheme.color ?? Theme.of(context).colorScheme.onSurface,
+ BlendMode.srcIn,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/native/lib/features/docker/docker_session_manager.dart b/native/lib/features/docker/docker_session_manager.dart
new file mode 100644
index 0000000..58fe8ae
--- /dev/null
+++ b/native/lib/features/docker/docker_session_manager.dart
@@ -0,0 +1,218 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart' show ChangeNotifier;
+
+import '../../core/api/cookie_store.dart';
+import '../auth/auth_session.dart';
+import '../servers/server_model.dart';
+import 'docker_container.dart';
+import 'docker_socket_client.dart';
+
+enum DockerConnectionStatus { connecting, connected, disconnected, error }
+
+class DockerSessionState {
+ DockerSessionState({
+ required this.server,
+ required this.client,
+ this.status = DockerConnectionStatus.connecting,
+ this.containers = const [],
+ this.logs = '',
+ this.errorMessage,
+ this.loading = true,
+ this.refreshing = false,
+ });
+
+ final ServerModel server;
+ final DockerSocketClient client;
+ DockerConnectionStatus status;
+ List containers;
+ String logs;
+ String? errorMessage;
+ bool loading;
+ bool refreshing;
+ final Set operatingIds = {};
+}
+
+class DockerSessionManager extends ChangeNotifier {
+ final Map _sessions = {};
+ final Map>> _subscriptions = {};
+ String? _activeHostId;
+
+ List get sessions =>
+ _sessions.values.toList(growable: false);
+
+ DockerSessionState? get activeSession {
+ final id = _activeHostId;
+ if (id == null) return null;
+ return _sessions[id];
+ }
+
+ String? get activeHostId => _activeHostId;
+
+ bool isConnected(String hostId) =>
+ _sessions[hostId]?.status == DockerConnectionStatus.connected;
+
+ Future connect({
+ required ServerModel server,
+ required AuthSession authSession,
+ required SessionCookieStore cookieStore,
+ }) async {
+ final existing = _sessions[server.id];
+ if (existing != null) {
+ _activeHostId = server.id;
+ if (existing.status == DockerConnectionStatus.disconnected ||
+ existing.status == DockerConnectionStatus.error) {
+ existing.status = DockerConnectionStatus.connecting;
+ existing.loading = true;
+ existing.errorMessage = null;
+ notifyListeners();
+ await existing.client.connect();
+ } else {
+ notifyListeners();
+ }
+ return;
+ }
+
+ final client = DockerSocketClient(
+ authSession: authSession,
+ cookieStore: cookieStore,
+ hostId: server.id,
+ );
+ final session = DockerSessionState(server: server, client: client);
+ _sessions[server.id] = session;
+ _activeHostId = server.id;
+ _subscriptions[server.id] = [
+ client.connectedStream.listen((connected) {
+ if (connected) {
+ session.status = DockerConnectionStatus.connected;
+ } else {
+ if (session.status == DockerConnectionStatus.connecting) return;
+ session.status = DockerConnectionStatus.disconnected;
+ session.loading = false;
+ }
+ notifyListeners();
+ }),
+ client.containersStream.listen((containers) {
+ session.containers = containers;
+ session.loading = false;
+ session.refreshing = false;
+ session.operatingIds.clear();
+ session.errorMessage = null;
+ session.status = DockerConnectionStatus.connected;
+ notifyListeners();
+ }),
+ client.logsStream.listen((logs) {
+ session.logs = logs;
+ notifyListeners();
+ }),
+ client.operationStream.listen((result) {
+ if (!result.success) {
+ session.operatingIds.clear();
+ session.errorMessage = result.message;
+ }
+ session.loading = false;
+ notifyListeners();
+ if (result.success) client.refresh();
+ }),
+ client.errorStream.listen((message) {
+ session.status = DockerConnectionStatus.error;
+ session.loading = false;
+ session.refreshing = false;
+ session.errorMessage = message;
+ notifyListeners();
+ }),
+ ];
+ notifyListeners();
+ await client.connect();
+ }
+
+ void activate(String hostId) {
+ if (!_sessions.containsKey(hostId)) return;
+ _activeHostId = hostId;
+ notifyListeners();
+ }
+
+ void refreshActive() {
+ final session = activeSession;
+ if (session == null) return;
+ session.refreshing = true;
+ session.errorMessage = null;
+ notifyListeners();
+ session.client.refresh();
+ }
+
+ void reconnectActive() {
+ final session = activeSession;
+ if (session == null) return;
+ session.status = DockerConnectionStatus.connecting;
+ session.loading = true;
+ session.errorMessage = null;
+ notifyListeners();
+ unawaited(session.client.connect());
+ }
+
+ void getLogs(DockerContainer container) {
+ final session = activeSession;
+ if (session == null) return;
+ session.logs = '';
+ notifyListeners();
+ session.client.getLogs(container.id);
+ }
+
+ void refreshLogs(DockerContainer container) {
+ activeSession?.client.getLogs(container.id);
+ }
+
+ void start(DockerContainer container) => _operate(container, (client) {
+ client.start(container.id);
+ });
+
+ void stop(DockerContainer container) => _operate(container, (client) {
+ client.stop(container.id);
+ });
+
+ void restart(DockerContainer container) => _operate(container, (client) {
+ client.restart(container.id);
+ });
+
+ void delete(DockerContainer container) => _operate(container, (client) {
+ client.delete(container.id);
+ });
+
+ void _operate(
+ DockerContainer container,
+ void Function(DockerSocketClient client) send,
+ ) {
+ final session = activeSession;
+ if (session == null) return;
+ session.operatingIds.add(container.id);
+ session.errorMessage = null;
+ notifyListeners();
+ send(session.client);
+ }
+
+ void disconnectActive() {
+ final id = _activeHostId;
+ if (id == null) return;
+ _disconnect(id);
+ _activeHostId = _sessions.keys.isEmpty ? null : _sessions.keys.first;
+ notifyListeners();
+ }
+
+ void _disconnect(String hostId) {
+ final session = _sessions.remove(hostId);
+ if (session == null) return;
+ for (final sub in _subscriptions.remove(hostId) ?? const []) {
+ sub.cancel();
+ }
+ session.client.disconnect();
+ }
+
+ @override
+ void dispose() {
+ for (final hostId in _sessions.keys.toList(growable: false)) {
+ _disconnect(hostId);
+ }
+ super.dispose();
+ }
+}
diff --git a/native/lib/features/docker/docker_socket_client.dart b/native/lib/features/docker/docker_socket_client.dart
new file mode 100644
index 0000000..dd1f152
--- /dev/null
+++ b/native/lib/features/docker/docker_socket_client.dart
@@ -0,0 +1,205 @@
+import 'dart:async';
+import 'dart:collection';
+
+import 'package:flutter/foundation.dart';
+import 'package:socket_io_client/socket_io_client.dart' as sio;
+
+import '../../core/api/cookie_store.dart';
+import '../auth/auth_session.dart';
+import 'docker_container.dart';
+
+class DockerSocketClient {
+ DockerSocketClient({
+ required AuthSession authSession,
+ required SessionCookieStore cookieStore,
+ required this.hostId,
+ }) : _authSession = authSession,
+ _cookieStore = cookieStore;
+
+ final AuthSession _authSession;
+ final SessionCookieStore _cookieStore;
+ final String hostId;
+
+ final StreamController> _containersController =
+ StreamController>.broadcast();
+ final StreamController _logsController =
+ StreamController.broadcast();
+ final StreamController _operationController =
+ StreamController.broadcast();
+ final StreamController _errorController =
+ StreamController.broadcast();
+ final StreamController _connectedController =
+ StreamController.broadcast();
+
+ sio.Socket? _socket;
+ List _serverCandidates = const [];
+ int _candidateIndex = 0;
+ bool _disposed = false;
+
+ Stream> get containersStream =>
+ _containersController.stream;
+ Stream get logsStream => _logsController.stream;
+ Stream get operationStream =>
+ _operationController.stream;
+ Stream get errorStream => _errorController.stream;
+ Stream get connectedStream => _connectedController.stream;
+
+ bool get connected => _socket?.connected ?? false;
+
+ Future connect() async {
+ if (_disposed) return;
+ disconnect(closeStreams: false);
+ final cookie = await _cookieStore.readCookieHeader();
+ _serverCandidates = _buildServerCandidates(_authSession.serverAddress);
+ _candidateIndex = 0;
+ if (cookie == null || cookie.isEmpty) {
+ _emitError('No Cookie');
+ return;
+ }
+
+ _connectCandidate(cookie);
+ }
+
+ void _connectCandidate(String cookie) {
+ if (_disposed || _candidateIndex >= _serverCandidates.length) return;
+ final serverAddress = _serverCandidates[_candidateIndex];
+
+ final socket = sio.io(
+ serverAddress,
+ sio.OptionBuilder()
+ .setTransports(['websocket'])
+ .setPath('/docker/')
+ .setAuth({'token': _authSession.token})
+ .setExtraHeaders({'Cookie': cookie, 'Origin': _authSession.serverAddress})
+ .disableAutoConnect()
+ .disableReconnection()
+ .build(),
+ );
+
+ socket.onConnect((_) {
+ _connectedController.add(true);
+ socket.emit('ws_docker', {'hostId': hostId});
+ });
+
+ socket.onConnectError((error) {
+ final message = _stringifyError(error);
+ socket.dispose();
+ if (_tryNextCandidate(cookie)) return;
+ _emitError(message);
+ });
+
+ socket.onError((error) {
+ _emitError(_stringifyError(error));
+ });
+
+ socket.onDisconnect((_) {
+ if (!_disposed) _connectedController.add(false);
+ });
+
+ socket.on('docker_containers_data', (data) {
+ if (data is! List) return;
+ _containersController.add(
+ data
+ .whereType